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 #[serde(default, skip_serializing_if = "Option::is_none")]
1801 pub endpoint: Option<std::net::SocketAddr>,
1802 pub max_input_tokens: std::num::NonZeroU32,
1803 pub max_output_tokens: std::num::NonZeroU32,
1804 pub max_evidence_bytes: std::num::NonZeroUsize,
1805 pub max_evidence_refs: std::num::NonZeroUsize,
1806}
1807
1808impl PatrolModelConfig {
1809 pub fn validate(&self) -> Result<(), String> {
1810 if self.provider.is_empty()
1811 || self.provider.len() > 256
1812 || self
1813 .provider
1814 .chars()
1815 .any(|ch| ch.is_whitespace() || ch.is_control())
1816 {
1817 return Err("patrol_model requires an explicit provider identifier".into());
1818 }
1819 if self.max_input_tokens.get() > MAX_INPUT_TOKENS
1820 || self.max_output_tokens.get() > MAX_OUTPUT_TOKENS
1821 || self.max_evidence_bytes.get() > MAX_EVIDENCE_BYTES
1822 || self.max_evidence_refs.get() > MAX_EVIDENCE_REFS
1823 {
1824 return Err(format!(
1825 "patrol_model limits must be at most {MAX_INPUT_TOKENS} input tokens, \
1826 {MAX_OUTPUT_TOKENS} output tokens, {MAX_EVIDENCE_BYTES} evidence bytes, \
1827 and {MAX_EVIDENCE_REFS} evidence references"
1828 ));
1829 }
1830 if self
1831 .endpoint
1832 .is_some_and(|endpoint| !endpoint.ip().is_loopback() || endpoint.port() == 0)
1833 {
1834 return Err(
1835 "patrol_model endpoint requires a numeric loopback address and port".into(),
1836 );
1837 }
1838 match self.protocol {
1839 PatrolModelProtocol::LlamaCppRawV1 if self.model == "gpt-oss-20b" => Ok(()),
1840 PatrolModelProtocol::LlamaCppRawV1 => {
1841 Err("llama_cpp_raw_v1 requires the gpt-oss-20b patrol model".into())
1842 }
1843 }
1844 }
1845}
1846
1847#[derive(Debug, Clone, Serialize, Deserialize)]
1852#[doc(hidden)]
1853pub struct ConfigValues {
1854 #[serde(default)]
1856 pub http_proxy: String,
1857 #[serde(default)]
1859 pub https_proxy: String,
1860 #[serde(skip_serializing)]
1864 pub proxy_auth: Option<ProxyAuth>,
1865 #[serde(default, skip_serializing_if = "Option::is_none")]
1867 pub proxy_auth_encrypted: Option<String>,
1868 #[serde(default, skip_serializing_if = "Option::is_none")]
1870 pub proxy_auth_credential_ref: Option<crate::CredentialRef>,
1871 #[serde(default)]
1873 pub headless_auth: bool,
1874
1875 #[serde(default = "default_provider")]
1877 pub provider: String,
1878
1879 #[serde(default, skip_serializing_if = "Option::is_none")]
1881 pub defaults: Option<DefaultsConfig>,
1882
1883 #[serde(default, skip_serializing_if = "HashMap::is_empty")]
1890 pub provider_instances: HashMap<String, ProviderInstanceConfig>,
1891
1892 #[serde(default, skip_serializing_if = "Option::is_none")]
1896 pub default_provider_instance: Option<String>,
1897
1898 #[serde(default)]
1900 pub server: ServerConfig,
1901
1902 #[serde(default)]
1906 pub keyword_masking: KeywordMaskingConfig,
1907
1908 #[serde(default)]
1912 pub anthropic_model_mapping: AnthropicModelMapping,
1913
1914 #[serde(default)]
1918 pub gemini_model_mapping: GeminiModelMapping,
1919
1920 #[serde(default, skip_serializing_if = "Option::is_none")]
1922 pub patrol_model: Option<PatrolModelConfig>,
1923
1924 #[serde(default)]
1929 pub hooks: HooksConfig,
1930
1931 #[serde(default, skip_serializing_if = "LifecycleHooksConfig::is_empty")]
1937 pub lifecycle_hooks: LifecycleHooksConfig,
1938
1939 #[serde(default, skip_serializing_if = "ToolsConfig::is_empty")]
1943 pub tools: ToolsConfig,
1944
1945 #[serde(default, skip_serializing_if = "SkillsConfig::is_empty")]
1950 pub skills: SkillsConfig,
1951
1952 #[serde(default, skip_serializing_if = "Vec::is_empty")]
1956 pub env_vars: Vec<EnvVarEntry>,
1957
1958 #[serde(default, skip_serializing_if = "Option::is_none")]
1960 pub default_work_area: Option<DefaultWorkAreaConfig>,
1961
1962 #[serde(default, skip_serializing_if = "WorktreeSettings::is_empty")]
1963 pub worktrees: WorktreeSettings,
1964
1965 #[serde(default, skip_serializing_if = "Option::is_none")]
1967 pub access_control: Option<AccessControlConfig>,
1968
1969 #[serde(default)]
1971 pub features: FeatureFlags,
1972
1973 #[serde(default)]
1978 pub run_budget: RunBudgetConfig,
1979
1980 #[serde(default)]
1982 pub stream_timeout: StreamTimeoutConfig,
1983
1984 #[serde(default, skip_serializing_if = "Option::is_none")]
1993 pub llm_retry: Option<LlmRetryConfig>,
1994
1995 #[serde(default, skip_serializing_if = "ContextManagementConfig::is_default")]
1997 pub context_management: ContextManagementConfig,
1998
1999 #[serde(
2003 default,
2004 skip_serializing_if = "crate::cluster_fabric::ClusterFabricConfig::is_empty"
2005 )]
2006 pub cluster_fabric: crate::cluster_fabric::ClusterFabricConfig,
2007
2008 #[serde(default, rename = "mcpServers", alias = "mcp")]
2014 pub mcp: bamboo_domain::mcp_config::McpConfig,
2015
2016 #[serde(default)]
2020 pub notifications: NotificationsConfig,
2021
2022 #[serde(default, skip_serializing_if = "connect_config_is_empty")]
2027 pub connect: ConnectConfig,
2028
2029 #[serde(default)]
2033 pub plugin_trust: PluginTrustConfig,
2034
2035 #[serde(default, flatten)]
2041 pub extra: BTreeMap<String, Value>,
2042}
2043
2044impl Default for ConfigValues {
2045 fn default() -> Self {
2046 Self {
2047 http_proxy: String::new(),
2048 https_proxy: String::new(),
2049 proxy_auth: None,
2050 proxy_auth_encrypted: None,
2051 proxy_auth_credential_ref: None,
2052 headless_auth: false,
2053 run_budget: RunBudgetConfig::default(),
2054 stream_timeout: StreamTimeoutConfig::default(),
2055 llm_retry: None,
2056 context_management: ContextManagementConfig::default(),
2057 cluster_fabric: crate::cluster_fabric::ClusterFabricConfig::default(),
2058 provider: default_provider(),
2059 provider_instances: HashMap::new(),
2060 default_provider_instance: None,
2061 server: ServerConfig::default(),
2062 keyword_masking: KeywordMaskingConfig::default(),
2063 anthropic_model_mapping: AnthropicModelMapping::default(),
2064 gemini_model_mapping: GeminiModelMapping::default(),
2065 patrol_model: None,
2066 hooks: HooksConfig::default(),
2067 lifecycle_hooks: LifecycleHooksConfig::default(),
2068 tools: ToolsConfig::default(),
2069 skills: SkillsConfig::default(),
2070 env_vars: Vec::new(),
2071 default_work_area: None,
2072 worktrees: WorktreeSettings::default(),
2073 access_control: None,
2074 features: FeatureFlags::default(),
2075 defaults: None,
2076 mcp: bamboo_domain::mcp_config::McpConfig::default(),
2077 notifications: NotificationsConfig::default(),
2078 connect: ConnectConfig::default(),
2079 plugin_trust: PluginTrustConfig::default(),
2080 extra: BTreeMap::new(),
2081 }
2082 }
2083}
2084
2085#[derive(Debug, Clone, Default, Serialize, Deserialize)]
2088struct NetworkConfigSection {
2089 #[serde(default)]
2090 http_proxy: String,
2091 #[serde(default)]
2092 https_proxy: String,
2093 #[serde(skip_serializing)]
2094 proxy_auth: Option<ProxyAuth>,
2095 #[serde(default, skip_serializing_if = "Option::is_none")]
2096 proxy_auth_encrypted: Option<String>,
2097 #[serde(default, skip_serializing_if = "Option::is_none")]
2098 proxy_auth_credential_ref: Option<crate::CredentialRef>,
2099 #[serde(default)]
2100 headless_auth: bool,
2101 #[serde(default)]
2102 server: ServerConfig,
2103}
2104
2105#[derive(Debug, Clone, Serialize, Deserialize)]
2106struct ProviderRoutingConfigSection {
2107 #[serde(default = "default_provider")]
2108 provider: String,
2109 #[serde(default, skip_serializing_if = "Option::is_none")]
2110 defaults: Option<DefaultsConfig>,
2111 #[serde(default, skip_serializing_if = "HashMap::is_empty")]
2112 provider_instances: HashMap<String, ProviderInstanceConfig>,
2113 #[serde(default, skip_serializing_if = "Option::is_none")]
2114 default_provider_instance: Option<String>,
2115}
2116
2117impl Default for ProviderRoutingConfigSection {
2118 fn default() -> Self {
2119 Self {
2120 provider: default_provider(),
2121 defaults: None,
2122 provider_instances: HashMap::new(),
2123 default_provider_instance: None,
2124 }
2125 }
2126}
2127
2128#[derive(Debug, Clone, Default, Serialize, Deserialize)]
2129struct ModelBehaviorConfigSection {
2130 #[serde(default)]
2131 keyword_masking: KeywordMaskingConfig,
2132 #[serde(default)]
2133 anthropic_model_mapping: AnthropicModelMapping,
2134 #[serde(default)]
2135 gemini_model_mapping: GeminiModelMapping,
2136 #[serde(default, skip_serializing_if = "Option::is_none")]
2137 patrol_model: Option<PatrolModelConfig>,
2138 #[serde(default)]
2139 hooks: HooksConfig,
2140 #[serde(default, skip_serializing_if = "LifecycleHooksConfig::is_empty")]
2141 lifecycle_hooks: LifecycleHooksConfig,
2142}
2143
2144#[derive(Debug, Clone, Default, Serialize, Deserialize)]
2145struct ToolingConfigSection {
2146 #[serde(default, skip_serializing_if = "ToolsConfig::is_empty")]
2147 tools: ToolsConfig,
2148 #[serde(default, skip_serializing_if = "SkillsConfig::is_empty")]
2149 skills: SkillsConfig,
2150 #[serde(default, rename = "mcpServers", alias = "mcp")]
2151 mcp: bamboo_domain::mcp_config::McpConfig,
2152}
2153
2154#[derive(Debug, Clone, Default, Serialize, Deserialize)]
2155struct WorkspaceConfigSection {
2156 #[serde(default, skip_serializing_if = "Vec::is_empty")]
2157 env_vars: Vec<EnvVarEntry>,
2158 #[serde(default, skip_serializing_if = "Option::is_none")]
2159 default_work_area: Option<DefaultWorkAreaConfig>,
2160 #[serde(default, skip_serializing_if = "WorktreeSettings::is_empty")]
2161 worktrees: WorktreeSettings,
2162 #[serde(default, skip_serializing_if = "Option::is_none")]
2163 access_control: Option<AccessControlConfig>,
2164}
2165
2166#[derive(Debug, Clone, Default, Serialize, Deserialize)]
2167struct ExecutionConfigSection {
2168 #[serde(default)]
2169 features: FeatureFlags,
2170 #[serde(default)]
2171 run_budget: RunBudgetConfig,
2172 #[serde(default)]
2173 stream_timeout: StreamTimeoutConfig,
2174 #[serde(default, skip_serializing_if = "Option::is_none")]
2175 llm_retry: Option<LlmRetryConfig>,
2176 #[serde(default, skip_serializing_if = "ContextManagementConfig::is_default")]
2177 context_management: ContextManagementConfig,
2178 #[serde(
2179 default,
2180 skip_serializing_if = "crate::cluster_fabric::ClusterFabricConfig::is_empty"
2181 )]
2182 cluster_fabric: crate::cluster_fabric::ClusterFabricConfig,
2183}
2184
2185#[derive(Debug, Clone, Default, Serialize, Deserialize)]
2186struct IntegrationConfigSection {
2187 #[serde(default)]
2188 notifications: NotificationsConfig,
2189 #[serde(default, skip_serializing_if = "connect_config_is_empty")]
2190 connect: ConnectConfig,
2191}
2192
2193#[derive(Debug, Clone, Default, Serialize, Deserialize)]
2194struct PluginSecurityConfigSection {
2195 #[serde(default)]
2196 plugin_trust: PluginTrustConfig,
2197}
2198
2199macro_rules! count_fields {
2202 ($($field:ident),* $(,)?) => {
2203 <[()]>::len(&[$(count_fields!(@one $field)),*])
2204 };
2205 (@one $field:ident) => { () };
2206}
2207
2208macro_rules! define_counted_struct {
2209 (
2210 $(#[$struct_meta:meta])*
2211 $visibility:vis struct $name:ident {
2212 $(
2213 $(#[$field_meta:meta])*
2214 $field_visibility:vis $field:ident: $field_type:ty
2215 ),* $(,)?
2216 }
2217 count $count_visibility:vis $count_name:ident;
2218 ) => {
2219 $(#[$struct_meta])*
2220 $visibility struct $name {
2221 $(
2222 $(#[$field_meta])*
2223 $field_visibility $field: $field_type,
2224 )*
2225 }
2226
2227 $count_visibility const $count_name: usize = count_fields!($($field),*);
2228 };
2229}
2230
2231define_counted_struct! {
2232 #[derive(Debug, Clone, Default, Serialize, Deserialize)]
2238 struct ConfigRoot {
2239 #[serde(flatten)]
2240 network: NetworkConfigSection,
2241 #[serde(flatten)]
2242 provider_routing: ProviderRoutingConfigSection,
2243 #[serde(flatten)]
2244 model_behavior: ModelBehaviorConfigSection,
2245 #[serde(flatten)]
2246 tooling: ToolingConfigSection,
2247 #[serde(flatten)]
2248 workspace: WorkspaceConfigSection,
2249 #[serde(flatten)]
2250 execution: ExecutionConfigSection,
2251 #[serde(flatten)]
2252 integrations: IntegrationConfigSection,
2253 #[serde(flatten)]
2254 plugin_security: PluginSecurityConfigSection,
2255 #[serde(default, flatten)]
2256 extra: BTreeMap<String, Value>,
2257 }
2258 count pub PERSISTED_ROOT_FIELD_COUNT;
2259}
2260
2261impl From<ConfigValues> for ConfigRoot {
2262 fn from(values: ConfigValues) -> Self {
2263 let ConfigValues {
2266 http_proxy,
2267 https_proxy,
2268 proxy_auth,
2269 proxy_auth_encrypted,
2270 proxy_auth_credential_ref,
2271 headless_auth,
2272 provider,
2273 defaults,
2274 provider_instances,
2275 default_provider_instance,
2276 server,
2277 keyword_masking,
2278 anthropic_model_mapping,
2279 gemini_model_mapping,
2280 patrol_model,
2281 hooks,
2282 lifecycle_hooks,
2283 tools,
2284 skills,
2285 env_vars,
2286 default_work_area,
2287 worktrees,
2288 access_control,
2289 features,
2290 run_budget,
2291 stream_timeout,
2292 llm_retry,
2293 context_management,
2294 cluster_fabric,
2295 mcp,
2296 notifications,
2297 connect,
2298 plugin_trust,
2299 extra,
2300 } = values;
2301
2302 Self {
2303 network: NetworkConfigSection {
2304 http_proxy,
2305 https_proxy,
2306 proxy_auth,
2307 proxy_auth_encrypted,
2308 proxy_auth_credential_ref,
2309 headless_auth,
2310 server,
2311 },
2312 provider_routing: ProviderRoutingConfigSection {
2313 provider,
2314 defaults,
2315 provider_instances,
2316 default_provider_instance,
2317 },
2318 model_behavior: ModelBehaviorConfigSection {
2319 keyword_masking,
2320 anthropic_model_mapping,
2321 gemini_model_mapping,
2322 patrol_model,
2323 hooks,
2324 lifecycle_hooks,
2325 },
2326 tooling: ToolingConfigSection { tools, skills, mcp },
2327 workspace: WorkspaceConfigSection {
2328 env_vars,
2329 default_work_area,
2330 worktrees,
2331 access_control,
2332 },
2333 execution: ExecutionConfigSection {
2334 features,
2335 run_budget,
2336 stream_timeout,
2337 llm_retry,
2338 context_management,
2339 cluster_fabric,
2340 },
2341 integrations: IntegrationConfigSection {
2342 notifications,
2343 connect,
2344 },
2345 plugin_security: PluginSecurityConfigSection { plugin_trust },
2346 extra,
2347 }
2348 }
2349}
2350
2351impl From<ConfigRoot> for ConfigValues {
2352 fn from(root: ConfigRoot) -> Self {
2353 let ConfigRoot {
2356 network,
2357 provider_routing,
2358 model_behavior,
2359 tooling,
2360 workspace,
2361 execution,
2362 integrations,
2363 plugin_security,
2364 extra,
2365 } = root;
2366 let NetworkConfigSection {
2367 http_proxy,
2368 https_proxy,
2369 proxy_auth,
2370 proxy_auth_encrypted,
2371 proxy_auth_credential_ref,
2372 headless_auth,
2373 server,
2374 } = network;
2375 let ProviderRoutingConfigSection {
2376 provider,
2377 defaults,
2378 provider_instances,
2379 default_provider_instance,
2380 } = provider_routing;
2381 let ModelBehaviorConfigSection {
2382 keyword_masking,
2383 anthropic_model_mapping,
2384 gemini_model_mapping,
2385 patrol_model,
2386 hooks,
2387 lifecycle_hooks,
2388 } = model_behavior;
2389 let ToolingConfigSection { tools, skills, mcp } = tooling;
2390 let WorkspaceConfigSection {
2391 env_vars,
2392 default_work_area,
2393 worktrees,
2394 access_control,
2395 } = workspace;
2396 let ExecutionConfigSection {
2397 features,
2398 run_budget,
2399 stream_timeout,
2400 llm_retry,
2401 context_management,
2402 cluster_fabric,
2403 } = execution;
2404 let IntegrationConfigSection {
2405 notifications,
2406 connect,
2407 } = integrations;
2408 let PluginSecurityConfigSection { plugin_trust } = plugin_security;
2409
2410 Self {
2411 http_proxy,
2412 https_proxy,
2413 proxy_auth,
2414 proxy_auth_encrypted,
2415 proxy_auth_credential_ref,
2416 headless_auth,
2417 provider,
2418 defaults,
2419 provider_instances,
2420 default_provider_instance,
2421 server,
2422 keyword_masking,
2423 anthropic_model_mapping,
2424 gemini_model_mapping,
2425 patrol_model,
2426 hooks,
2427 lifecycle_hooks,
2428 tools,
2429 skills,
2430 env_vars,
2431 default_work_area,
2432 worktrees,
2433 access_control,
2434 features,
2435 run_budget,
2436 stream_timeout,
2437 llm_retry,
2438 context_management,
2439 cluster_fabric,
2440 mcp,
2441 notifications,
2442 connect,
2443 plugin_trust,
2444 extra,
2445 }
2446 }
2447}
2448
2449fn durable_root_value(values: ConfigValues) -> serde_json::Result<Value> {
2456 let instance_native = values
2457 .default_provider_instance
2458 .as_ref()
2459 .is_some_and(|id| values.provider_instances.contains_key(id));
2460 let mut value = serde_json::to_value(ConfigRoot::from(values))?;
2461 if instance_native {
2462 if let Some(object) = value.as_object_mut() {
2463 object.remove("provider");
2464 object.remove("providers");
2465 }
2466 }
2467 Ok(value)
2468}
2469
2470define_counted_struct! {
2471 #[derive(Debug, Clone)]
2474 pub struct Config {
2475 values: ConfigValues,
2476 pub(crate) memory: crate::MemoryConfigModule,
2477 pub(crate) subagents: crate::SubagentsConfigModule,
2478 pub(crate) providers: crate::ProviderConfigsModule,
2479 recovery_status: Option<ConfigRecoveryStatus>,
2480 }
2481 count pub CONFIG_FIELD_COUNT;
2482}
2483
2484pub const PHASE_39_CONFIG_FIELD_BASELINE: usize = 31;
2486const _: () = assert!(CONFIG_FIELD_COUNT * 2 <= PHASE_39_CONFIG_FIELD_BASELINE);
2487const _: () = assert!(PERSISTED_ROOT_FIELD_COUNT * 2 <= PHASE_39_CONFIG_FIELD_BASELINE);
2488
2489impl std::ops::Deref for Config {
2490 type Target = ConfigValues;
2491
2492 fn deref(&self) -> &Self::Target {
2493 &self.values
2494 }
2495}
2496
2497impl std::ops::DerefMut for Config {
2498 fn deref_mut(&mut self) -> &mut Self::Target {
2499 &mut self.values
2500 }
2501}
2502
2503impl Serialize for Config {
2504 fn serialize<S>(&self, serializer: S) -> std::result::Result<S::Ok, S::Error>
2505 where
2506 S: serde::Serializer,
2507 {
2508 self.to_compatibility_value()
2509 .map_err(serde::ser::Error::custom)?
2510 .serialize(serializer)
2511 }
2512}
2513
2514impl<'de> Deserialize<'de> for Config {
2515 fn deserialize<D>(deserializer: D) -> std::result::Result<Self, D::Error>
2516 where
2517 D: serde::Deserializer<'de>,
2518 {
2519 use serde::de::Error;
2520
2521 let mut value = Value::deserialize(deserializer)?;
2522 let object = value
2523 .as_object_mut()
2524 .ok_or_else(|| D::Error::custom("config must be a JSON object"))?;
2525 let memory = object
2526 .remove("memory")
2527 .map(serde_json::from_value)
2528 .transpose()
2529 .map_err(D::Error::custom)?
2530 .unwrap_or_default();
2531 let subagents = object
2532 .remove("subagents")
2533 .map(serde_json::from_value)
2534 .transpose()
2535 .map_err(D::Error::custom)?
2536 .unwrap_or_default();
2537 let providers = object
2538 .remove("providers")
2539 .map(serde_json::from_value)
2540 .transpose()
2541 .map_err(D::Error::custom)?
2542 .unwrap_or_default();
2543 let root: ConfigRoot = serde_json::from_value(value).map_err(D::Error::custom)?;
2544 if let Some(model) = &root.model_behavior.patrol_model {
2545 model.validate().map_err(D::Error::custom)?;
2546 }
2547
2548 Ok(Self::from_parts(root.into(), memory, subagents, providers))
2549 }
2550}
2551
2552#[derive(Debug, Clone, PartialEq, Serialize)]
2554#[serde(tag = "kind", rename_all = "snake_case")]
2555pub enum ConfigRecoverySource {
2556 Salvaged { fields: Vec<String> },
2561 Backup { generation: usize },
2564 Defaults,
2566}
2567
2568#[derive(Debug, Clone, PartialEq, Serialize)]
2580pub struct ConfigRecoveryStatus {
2581 pub source: ConfigRecoverySource,
2583 pub quarantine_path: Option<PathBuf>,
2589 pub confirmed: bool,
2592}
2593
2594#[derive(Debug, Clone, Default, Serialize, Deserialize)]
2598pub struct ProviderConfigs {
2599 #[serde(skip_serializing_if = "Option::is_none")]
2601 pub openai: Option<OpenAIConfig>,
2602 #[serde(skip_serializing_if = "Option::is_none")]
2604 pub anthropic: Option<AnthropicConfig>,
2605 #[serde(skip_serializing_if = "Option::is_none")]
2607 pub gemini: Option<GeminiConfig>,
2608 #[serde(skip_serializing_if = "Option::is_none")]
2610 pub copilot: Option<CopilotConfig>,
2611 #[serde(skip_serializing_if = "Option::is_none")]
2613 pub bodhi: Option<BodhiConfig>,
2614
2615 #[serde(default, flatten)]
2617 pub extra: BTreeMap<String, Value>,
2618}
2619
2620impl ProviderConfigs {
2621 pub fn clear_legacy_builtin_aliases(&mut self) {
2624 self.openai = None;
2625 self.anthropic = None;
2626 self.gemini = None;
2627 self.copilot = None;
2628 self.bodhi = None;
2629 self.extra.remove("provider");
2630 }
2631}
2632
2633#[derive(Debug, Clone, Default, Serialize, Deserialize)]
2635pub struct FeatureFlags {
2636 #[serde(default)]
2638 pub ticket_mutation: bool,
2639 #[serde(default)]
2641 pub ticket_dispatch: bool,
2642 #[serde(default)]
2644 pub provider_model_ref: bool,
2645 #[serde(default)]
2647 pub dynamic_model_routing: bool,
2648 #[serde(default)]
2650 pub final_evidence_check: bool,
2651}
2652
2653#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
2657pub struct DefaultsConfig {
2658 pub chat: bamboo_domain::ProviderModelRef,
2659 #[serde(default, skip_serializing_if = "Option::is_none")]
2660 pub fast: Option<bamboo_domain::ProviderModelRef>,
2661 #[serde(default, skip_serializing_if = "Option::is_none")]
2662 pub task_summary: Option<bamboo_domain::ProviderModelRef>,
2663 #[serde(default, skip_serializing_if = "Option::is_none")]
2664 pub vision: Option<bamboo_domain::ProviderModelRef>,
2665 #[serde(default, skip_serializing_if = "Option::is_none")]
2666 pub memory_background: Option<bamboo_domain::ProviderModelRef>,
2667 #[serde(default, skip_serializing_if = "Option::is_none")]
2670 pub planning: Option<bamboo_domain::ProviderModelRef>,
2671 #[serde(default, skip_serializing_if = "Option::is_none")]
2674 pub search: Option<bamboo_domain::ProviderModelRef>,
2675 #[serde(default, skip_serializing_if = "Option::is_none")]
2678 pub code_review: Option<bamboo_domain::ProviderModelRef>,
2679 #[serde(
2682 default,
2683 skip_serializing_if = "Option::is_none",
2684 alias = "sub_session"
2685 )]
2686 pub sub_agent: Option<bamboo_domain::ProviderModelRef>,
2687 #[serde(default, skip_serializing_if = "HashMap::is_empty")]
2691 pub subagent_models: HashMap<String, bamboo_domain::ProviderModelRef>,
2692}
2693
2694#[derive(Debug, Clone, Default, Serialize, Deserialize)]
2696pub struct HooksConfig {
2697 #[serde(default)]
2699 pub image_fallback: ImageFallbackHookConfig,
2700}
2701
2702pub const DEFAULT_LIFECYCLE_HOOK_TIMEOUT_MS: u64 = 60_000;
2704pub const MIN_LIFECYCLE_HOOK_TIMEOUT_MS: u64 = 1;
2706pub const MAX_LIFECYCLE_HOOK_TIMEOUT_MS: u64 = 600_000;
2710
2711pub const LIFECYCLE_HOOK_EVENT_NAMES: [&str; 9] = [
2713 "SessionStart",
2714 "UserPromptSubmit",
2715 "PreToolUse",
2716 "PostToolUse",
2717 "Stop",
2718 "SessionEnd",
2719 "PreCompact",
2720 "Notification",
2721 "BambooModelCallUsage",
2722];
2723
2724fn default_lifecycle_hook_timeout_ms() -> u64 {
2725 DEFAULT_LIFECYCLE_HOOK_TIMEOUT_MS
2726}
2727
2728fn lifecycle_hook_timeout_is_default(value: &u64) -> bool {
2729 *value == DEFAULT_LIFECYCLE_HOOK_TIMEOUT_MS
2730}
2731
2732fn lifecycle_hook_enabled_default() -> bool {
2733 true
2734}
2735
2736fn lifecycle_hook_enabled_is_default(value: &bool) -> bool {
2737 *value
2738}
2739
2740#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
2745pub struct LifecycleHooksConfig {
2746 #[serde(default)]
2747 pub enabled: bool,
2748 #[serde(
2749 default,
2750 rename = "SessionStart",
2751 skip_serializing_if = "Vec::is_empty"
2752 )]
2753 pub session_start: Vec<LifecycleHookGroup>,
2754 #[serde(
2755 default,
2756 rename = "UserPromptSubmit",
2757 skip_serializing_if = "Vec::is_empty"
2758 )]
2759 pub user_prompt_submit: Vec<LifecycleHookGroup>,
2760 #[serde(default, rename = "PreToolUse", skip_serializing_if = "Vec::is_empty")]
2761 pub pre_tool_use: Vec<LifecycleHookGroup>,
2762 #[serde(default, rename = "PostToolUse", skip_serializing_if = "Vec::is_empty")]
2763 pub post_tool_use: Vec<LifecycleHookGroup>,
2764 #[serde(default, rename = "Stop", skip_serializing_if = "Vec::is_empty")]
2765 pub stop: Vec<LifecycleHookGroup>,
2766 #[serde(default, rename = "SessionEnd", skip_serializing_if = "Vec::is_empty")]
2767 pub session_end: Vec<LifecycleHookGroup>,
2768 #[serde(default, rename = "PreCompact", skip_serializing_if = "Vec::is_empty")]
2769 pub pre_compact: Vec<LifecycleHookGroup>,
2770 #[serde(
2771 default,
2772 rename = "Notification",
2773 skip_serializing_if = "Vec::is_empty"
2774 )]
2775 pub notification: Vec<LifecycleHookGroup>,
2776 #[serde(
2777 default,
2778 rename = "BambooModelCallUsage",
2779 skip_serializing_if = "Vec::is_empty"
2780 )]
2781 pub model_call_usage: Vec<LifecycleHookGroup>,
2782}
2783
2784impl LifecycleHooksConfig {
2785 pub fn is_empty(&self) -> bool {
2786 !self.enabled
2787 && self.session_start.is_empty()
2788 && self.user_prompt_submit.is_empty()
2789 && self.pre_tool_use.is_empty()
2790 && self.post_tool_use.is_empty()
2791 && self.stop.is_empty()
2792 && self.session_end.is_empty()
2793 && self.pre_compact.is_empty()
2794 && self.notification.is_empty()
2795 && self.model_call_usage.is_empty()
2796 }
2797}
2798
2799#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
2801pub struct LifecycleHookGroup {
2802 #[serde(
2805 default = "lifecycle_hook_enabled_default",
2806 skip_serializing_if = "lifecycle_hook_enabled_is_default"
2807 )]
2808 pub enabled: bool,
2809 #[serde(default, skip_serializing_if = "Option::is_none")]
2810 pub matcher: Option<String>,
2811 #[serde(default)]
2812 pub hooks: Vec<LifecycleHookHandler>,
2813}
2814
2815impl Default for LifecycleHookGroup {
2816 fn default() -> Self {
2817 Self {
2818 enabled: true,
2819 matcher: None,
2820 hooks: Vec::new(),
2821 }
2822 }
2823}
2824
2825#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
2830#[serde(tag = "type", rename_all = "lowercase")]
2831pub enum LifecycleHookHandler {
2832 Command {
2833 command: String,
2834 #[serde(
2835 default = "default_lifecycle_hook_timeout_ms",
2836 skip_serializing_if = "lifecycle_hook_timeout_is_default"
2837 )]
2838 timeout_ms: u64,
2839 },
2840 Script {
2841 path: String,
2842 #[serde(default, skip_serializing_if = "LifecycleScriptRunner::is_auto")]
2843 runner: LifecycleScriptRunner,
2844 #[serde(
2845 default = "default_lifecycle_hook_timeout_ms",
2846 skip_serializing_if = "lifecycle_hook_timeout_is_default"
2847 )]
2848 timeout_ms: u64,
2849 },
2850}
2851
2852#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
2858#[serde(rename_all = "lowercase")]
2859pub enum LifecycleScriptRunner {
2860 #[default]
2861 Auto,
2862 Node,
2863 Bun,
2864 Python,
2865 Bash,
2866 PowerShell,
2867 Cmd,
2868}
2869
2870impl LifecycleScriptRunner {
2871 pub fn is_auto(&self) -> bool {
2872 matches!(self, Self::Auto)
2873 }
2874
2875 pub fn as_str(self) -> &'static str {
2876 match self {
2877 Self::Auto => "auto",
2878 Self::Node => "node",
2879 Self::Bun => "bun",
2880 Self::Python => "python",
2881 Self::Bash => "bash",
2882 Self::PowerShell => "powershell",
2883 Self::Cmd => "cmd",
2884 }
2885 }
2886
2887 pub fn supports_path(self, path: &str) -> bool {
2889 let extension = lifecycle_script_extension(path);
2890 match self {
2891 Self::Auto => extension.is_some(),
2892 Self::Node | Self::Bun => {
2893 matches!(extension.as_deref(), Some("js" | "mjs" | "cjs"))
2894 }
2895 Self::Python => matches!(extension.as_deref(), Some("py")),
2896 Self::Bash => matches!(extension.as_deref(), Some("sh")),
2897 Self::PowerShell => matches!(extension.as_deref(), Some("ps1")),
2898 Self::Cmd => matches!(extension.as_deref(), Some("bat" | "cmd")),
2899 }
2900 }
2901}
2902
2903pub fn lifecycle_script_extension(path: &str) -> Option<String> {
2906 let extension = std::path::Path::new(path)
2907 .extension()?
2908 .to_str()?
2909 .to_ascii_lowercase();
2910 matches!(
2911 extension.as_str(),
2912 "js" | "mjs" | "cjs" | "py" | "sh" | "ps1" | "bat" | "cmd"
2913 )
2914 .then_some(extension)
2915}
2916
2917impl LifecycleHookHandler {
2918 pub fn command(command: impl Into<String>, timeout_ms: u64) -> Self {
2919 Self::Command {
2920 command: command.into(),
2921 timeout_ms,
2922 }
2923 }
2924
2925 pub fn script(path: impl Into<String>, runner: LifecycleScriptRunner, timeout_ms: u64) -> Self {
2926 Self::Script {
2927 path: path.into(),
2928 runner,
2929 timeout_ms,
2930 }
2931 }
2932
2933 pub fn timeout_ms(&self) -> u64 {
2934 match self {
2935 Self::Command { timeout_ms, .. } | Self::Script { timeout_ms, .. } => *timeout_ms,
2936 }
2937 }
2938}
2939
2940#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
2947pub struct RequestOverridesConfig {
2948 #[serde(default, skip_serializing_if = "RequestScopeOverride::is_empty")]
2950 pub common: RequestScopeOverride,
2951 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
2953 pub endpoints: BTreeMap<String, RequestScopeOverride>,
2954 #[serde(default, skip_serializing_if = "Vec::is_empty")]
2956 pub rules: Vec<ModelRequestRule>,
2957}
2958
2959#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
2961pub struct ModelRequestRule {
2962 pub model_pattern: String,
2964 #[serde(default, skip_serializing_if = "Option::is_none")]
2966 pub endpoint: Option<String>,
2967 #[serde(default, skip_serializing_if = "RequestScopeOverride::is_empty")]
2969 pub scope: RequestScopeOverride,
2970}
2971
2972#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
2974pub struct RequestScopeOverride {
2975 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
2977 pub headers: BTreeMap<String, TemplateExpr>,
2978 #[serde(default, skip_serializing_if = "Vec::is_empty")]
2980 pub body_patch: Vec<BodyPatch>,
2981}
2982
2983impl RequestScopeOverride {
2984 pub fn is_empty(&self) -> bool {
2985 self.headers.is_empty() && self.body_patch.is_empty()
2986 }
2987}
2988
2989#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
2991pub struct BodyPatch {
2992 pub path: String,
2994 #[serde(default)]
2996 pub op: BodyPatchOp,
2997 #[serde(default, skip_serializing_if = "Option::is_none")]
2999 pub value: Option<PatchValue>,
3000}
3001
3002#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq, Eq)]
3004#[serde(rename_all = "snake_case")]
3005pub enum BodyPatchOp {
3006 #[default]
3007 Set,
3008 Remove,
3009}
3010
3011#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
3013#[serde(untagged)]
3014pub enum PatchValue {
3015 Template(TemplateExpr),
3016 Json(Value),
3017}
3018
3019#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
3021#[serde(untagged)]
3022pub enum TemplateExpr {
3023 Literal(String),
3025 Structured(TemplateExprSpec),
3027}
3028
3029#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
3031#[serde(tag = "type", rename_all = "snake_case")]
3032pub enum TemplateExprSpec {
3033 Literal { value: String },
3035 EnvRef {
3037 name: String,
3038 #[serde(default, skip_serializing_if = "Option::is_none")]
3039 fallback: Option<String>,
3040 },
3041 Generated { generator: GeneratedValue },
3043 Format { template: String },
3045}
3046
3047#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
3049#[serde(rename_all = "snake_case")]
3050pub enum GeneratedValue {
3051 Uuid,
3052 UnixMs,
3053}
3054
3055#[derive(Debug, Clone, Default, Serialize, Deserialize)]
3057pub struct ToolsConfig {
3058 #[serde(default, skip_serializing_if = "Vec::is_empty")]
3060 pub disabled: Vec<String>,
3061
3062 #[serde(default, flatten)]
3064 pub extra: BTreeMap<String, Value>,
3065}
3066
3067impl ToolsConfig {
3068 fn is_empty(&self) -> bool {
3069 self.disabled.is_empty() && self.extra.is_empty()
3070 }
3071}
3072
3073#[derive(Debug, Clone, Default, Serialize, Deserialize)]
3075pub struct SkillsConfig {
3076 #[serde(default, skip_serializing_if = "Vec::is_empty")]
3078 pub disabled: Vec<String>,
3079
3080 #[serde(default, flatten)]
3082 pub extra: BTreeMap<String, Value>,
3083}
3084
3085impl SkillsConfig {
3086 fn is_empty(&self) -> bool {
3087 self.disabled.is_empty() && self.extra.is_empty()
3088 }
3089}
3090
3091#[derive(Debug, Clone, Serialize, Deserialize)]
3096pub struct ImageFallbackHookConfig {
3097 #[serde(default = "default_true_hooks")]
3098 pub enabled: bool,
3099
3100 #[serde(default = "default_image_fallback_mode")]
3102 pub mode: String,
3103}
3104
3105impl Default for ImageFallbackHookConfig {
3106 fn default() -> Self {
3107 Self {
3108 enabled: default_true_hooks(),
3109 mode: default_image_fallback_mode(),
3110 }
3111 }
3112}
3113
3114fn default_image_fallback_mode() -> String {
3115 "placeholder".to_string()
3116}
3117
3118fn default_true_hooks() -> bool {
3119 false
3122}
3123
3124pub const OPENAI_EXPLICIT_PROMPT_CACHE_CONFIG_KEY: &str = "explicit_prompt_cache";
3136
3137#[derive(Debug, Clone, Default, Serialize, Deserialize)]
3138pub struct OpenAIConfig {
3139 #[serde(default, skip_serializing)]
3143 pub api_key: String,
3144 #[serde(default, skip_serializing_if = "Option::is_none")]
3146 pub api_key_encrypted: Option<String>,
3147 #[serde(default, skip_serializing_if = "Option::is_none")]
3149 pub credential_ref: Option<crate::CredentialRef>,
3150 #[serde(skip)]
3155 pub api_key_from_env: bool,
3156 #[serde(skip_serializing_if = "Option::is_none")]
3158 pub base_url: Option<String>,
3159 #[serde(skip_serializing_if = "Option::is_none")]
3161 pub model: Option<String>,
3162 #[serde(default, skip_serializing_if = "Option::is_none")]
3165 pub fast_model: Option<String>,
3166 #[serde(default, skip_serializing_if = "Option::is_none")]
3169 pub vision_model: Option<String>,
3170 #[serde(skip_serializing_if = "Option::is_none")]
3172 pub reasoning_effort: Option<ReasoningEffort>,
3173
3174 #[serde(default, skip_serializing_if = "Vec::is_empty")]
3181 pub responses_only_models: Vec<String>,
3182 #[serde(default, skip_serializing_if = "Option::is_none")]
3184 pub request_overrides: Option<RequestOverridesConfig>,
3185
3186 #[serde(default, flatten)]
3188 pub extra: BTreeMap<String, Value>,
3189}
3190
3191impl OpenAIConfig {
3192 pub fn explicit_prompt_cache_enabled(&self) -> bool {
3199 self.extra
3200 .get(OPENAI_EXPLICIT_PROMPT_CACHE_CONFIG_KEY)
3201 .and_then(Value::as_bool)
3202 .unwrap_or(true)
3203 }
3204}
3205
3206#[derive(Debug, Clone, Default, Serialize, Deserialize)]
3218pub struct AnthropicConfig {
3219 #[serde(default, skip_serializing)]
3223 pub api_key: String,
3224 #[serde(default, skip_serializing_if = "Option::is_none")]
3226 pub api_key_encrypted: Option<String>,
3227 #[serde(default, skip_serializing_if = "Option::is_none")]
3229 pub credential_ref: Option<crate::CredentialRef>,
3230 #[serde(skip)]
3235 pub api_key_from_env: bool,
3236 #[serde(skip_serializing_if = "Option::is_none")]
3238 pub base_url: Option<String>,
3239 #[serde(skip_serializing_if = "Option::is_none")]
3241 pub model: Option<String>,
3242 #[serde(default, skip_serializing_if = "Option::is_none")]
3245 pub fast_model: Option<String>,
3246 #[serde(default, skip_serializing_if = "Option::is_none")]
3249 pub vision_model: Option<String>,
3250 #[serde(skip_serializing_if = "Option::is_none")]
3252 pub max_tokens: Option<u32>,
3253 #[serde(skip_serializing_if = "Option::is_none")]
3255 pub reasoning_effort: Option<ReasoningEffort>,
3256 #[serde(default, skip_serializing_if = "Option::is_none")]
3258 pub request_overrides: Option<RequestOverridesConfig>,
3259
3260 #[serde(default, skip_serializing_if = "Option::is_none")]
3277 pub thinking_replay_always: Option<bool>,
3278
3279 #[serde(default, flatten)]
3281 pub extra: BTreeMap<String, Value>,
3282}
3283
3284#[derive(Debug, Clone, Default, Serialize, Deserialize)]
3295pub struct GeminiConfig {
3296 #[serde(default, skip_serializing)]
3300 pub api_key: String,
3301 #[serde(default, skip_serializing_if = "Option::is_none")]
3303 pub api_key_encrypted: Option<String>,
3304 #[serde(default, skip_serializing_if = "Option::is_none")]
3306 pub credential_ref: Option<crate::CredentialRef>,
3307 #[serde(skip)]
3312 pub api_key_from_env: bool,
3313 #[serde(skip_serializing_if = "Option::is_none")]
3315 pub base_url: Option<String>,
3316 #[serde(skip_serializing_if = "Option::is_none")]
3318 pub model: Option<String>,
3319 #[serde(default, skip_serializing_if = "Option::is_none")]
3322 pub fast_model: Option<String>,
3323 #[serde(default, skip_serializing_if = "Option::is_none")]
3326 pub vision_model: Option<String>,
3327 #[serde(skip_serializing_if = "Option::is_none")]
3329 pub reasoning_effort: Option<ReasoningEffort>,
3330 #[serde(default, skip_serializing_if = "Option::is_none")]
3332 pub request_overrides: Option<RequestOverridesConfig>,
3333
3334 #[serde(default, flatten)]
3336 pub extra: BTreeMap<String, Value>,
3337}
3338
3339#[derive(Debug, Clone, Default, Serialize, Deserialize)]
3351pub struct CopilotConfig {
3352 #[serde(default)]
3354 pub enabled: bool,
3355 #[serde(default)]
3357 pub headless_auth: bool,
3358 #[serde(skip_serializing_if = "Option::is_none")]
3360 pub model: Option<String>,
3361 #[serde(default, skip_serializing_if = "Option::is_none")]
3364 pub fast_model: Option<String>,
3365 #[serde(default, skip_serializing_if = "Option::is_none")]
3368 pub vision_model: Option<String>,
3369 #[serde(skip_serializing_if = "Option::is_none")]
3371 pub reasoning_effort: Option<ReasoningEffort>,
3372
3373 #[serde(default, skip_serializing_if = "Vec::is_empty")]
3382 pub responses_only_models: Vec<String>,
3383 #[serde(default, skip_serializing_if = "Option::is_none")]
3385 pub request_overrides: Option<RequestOverridesConfig>,
3386
3387 #[serde(default, flatten)]
3389 pub extra: BTreeMap<String, Value>,
3390}
3391
3392#[derive(Debug, Clone, Serialize, Deserialize)]
3397pub struct BodhiConfig {
3398 #[serde(default, skip_serializing)]
3400 pub api_key: String,
3401 #[serde(default, skip_serializing_if = "Option::is_none")]
3403 pub api_key_encrypted: Option<String>,
3404 #[serde(default, skip_serializing_if = "Option::is_none")]
3406 pub credential_ref: Option<crate::CredentialRef>,
3407 #[serde(skip_serializing_if = "Option::is_none")]
3409 pub base_url: Option<String>,
3410 #[serde(skip_serializing_if = "Option::is_none")]
3412 pub target_provider: Option<String>,
3413 #[serde(skip_serializing_if = "Option::is_none")]
3415 pub reasoning_effort: Option<ReasoningEffort>,
3416
3417 #[serde(default, flatten)]
3419 pub extra: BTreeMap<String, Value>,
3420}
3421
3422fn default_provider() -> String {
3424 "anthropic".to_string()
3425}
3426
3427#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
3459pub struct ProviderInstanceConfig {
3460 pub provider_type: String,
3465
3466 #[serde(default, skip_serializing_if = "Option::is_none")]
3468 pub label: Option<String>,
3469
3470 #[serde(default, skip_serializing)]
3472 pub api_key: String,
3473
3474 #[serde(default, skip_serializing_if = "Option::is_none")]
3477 pub api_key_encrypted: Option<String>,
3478
3479 #[serde(default, skip_serializing_if = "Option::is_none")]
3481 pub credential_ref: Option<crate::CredentialRef>,
3482
3483 #[serde(default, skip_serializing_if = "Option::is_none")]
3485 pub base_url: Option<String>,
3486
3487 #[serde(default, skip_serializing_if = "Option::is_none")]
3489 pub model: Option<String>,
3490
3491 #[serde(default, skip_serializing_if = "Option::is_none")]
3493 pub fast_model: Option<String>,
3494
3495 #[serde(default, skip_serializing_if = "Option::is_none")]
3497 pub vision_model: Option<String>,
3498
3499 #[serde(default, skip_serializing_if = "Option::is_none")]
3501 pub reasoning_effort: Option<bamboo_domain::ReasoningEffort>,
3502
3503 #[serde(default, skip_serializing_if = "Vec::is_empty")]
3505 pub responses_only_models: Vec<String>,
3506
3507 #[serde(default, skip_serializing_if = "Option::is_none")]
3509 pub request_overrides: Option<RequestOverridesConfig>,
3510
3511 #[serde(default = "default_true")]
3514 pub enabled: bool,
3515
3516 #[serde(default, flatten)]
3518 pub extra: BTreeMap<String, Value>,
3519}
3520
3521fn default_true() -> bool {
3522 true
3523}
3524
3525fn default_port() -> u16 {
3527 9562
3528}
3529
3530fn default_bind() -> String {
3532 "127.0.0.1".to_string()
3533}
3534
3535fn default_workers() -> usize {
3537 10
3538}
3539
3540fn default_data_dir() -> PathBuf {
3542 super::paths::bamboo_dir()
3543}
3544
3545#[derive(Debug, Clone, Serialize, Deserialize)]
3547pub struct ServerConfig {
3548 #[serde(default = "default_port")]
3550 pub port: u16,
3551
3552 #[serde(default = "default_bind")]
3554 pub bind: String,
3555
3556 pub static_dir: Option<PathBuf>,
3558
3559 #[serde(default = "default_workers")]
3561 pub workers: usize,
3562
3563 #[serde(default, skip_serializing_if = "Option::is_none")]
3571 pub tls: Option<TlsConfig>,
3572
3573 #[serde(default, flatten)]
3575 pub extra: BTreeMap<String, Value>,
3576}
3577
3578impl Default for ServerConfig {
3579 fn default() -> Self {
3580 Self {
3581 port: default_port(),
3582 bind: default_bind(),
3583 static_dir: None,
3584 workers: default_workers(),
3585 tls: None,
3586 extra: BTreeMap::new(),
3587 }
3588 }
3589}
3590
3591#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
3597pub struct TlsConfig {
3598 pub cert_file: PathBuf,
3600 pub key_file: PathBuf,
3602}
3603
3604#[derive(Debug, Clone, Serialize, Deserialize)]
3606pub struct ProxyAuth {
3607 pub username: String,
3609 pub password: String,
3611}
3612
3613fn parse_bool_env(value: &str) -> bool {
3617 matches!(
3618 value.trim().to_ascii_lowercase().as_str(),
3619 "1" | "true" | "yes" | "y" | "on"
3620 )
3621}
3622
3623fn expand_user_path(value: &str) -> PathBuf {
3624 let trimmed = value.trim();
3625 if let Some(rest) = trimmed.strip_prefix("~/") {
3626 if let Some(home) = dirs::home_dir() {
3627 return home.join(rest);
3628 }
3629 }
3630 PathBuf::from(trimmed)
3631}
3632
3633impl Default for Config {
3634 fn default() -> Self {
3635 Self::create_default()
3644 }
3645}
3646
3647#[derive(Debug, Clone, PartialEq, Eq)]
3649pub struct PromptSafeEnvVarEntry {
3650 pub name: String,
3651 pub secret: bool,
3652 pub description: Option<String>,
3653}
3654
3655static ENV_VARS_CACHE: OnceLock<RwLock<HashMap<String, String>>> = OnceLock::new();
3659
3660static PROMPT_SAFE_ENV_VARS_CACHE: OnceLock<RwLock<Vec<PromptSafeEnvVarEntry>>> = OnceLock::new();
3661
3662fn env_vars_cache() -> &'static RwLock<HashMap<String, String>> {
3663 ENV_VARS_CACHE.get_or_init(|| RwLock::new(HashMap::new()))
3664}
3665
3666fn prompt_safe_env_vars_cache() -> &'static RwLock<Vec<PromptSafeEnvVarEntry>> {
3667 PROMPT_SAFE_ENV_VARS_CACHE.get_or_init(|| RwLock::new(Vec::new()))
3668}
3669
3670impl Config {
3671 fn from_parts(
3672 values: ConfigValues,
3673 memory: Option<MemoryConfig>,
3674 subagents: SubagentsConfig,
3675 providers: ProviderConfigs,
3676 ) -> Self {
3677 Self {
3678 values,
3679 memory: crate::MemoryConfigModule(memory),
3680 subagents: crate::SubagentsConfigModule(subagents),
3681 providers: crate::ProviderConfigsModule(providers),
3682 recovery_status: None,
3683 }
3684 }
3685
3686 pub(crate) fn section_values(&self) -> ConfigValues {
3692 self.values.clone()
3693 }
3694
3695 pub(crate) fn from_section_parts(
3696 values: ConfigValues,
3697 memory: Option<MemoryConfig>,
3698 subagents: SubagentsConfig,
3699 providers: ProviderConfigs,
3700 ) -> Self {
3701 Self::from_parts(values, memory, subagents, providers)
3702 }
3703
3704 pub fn memory(&self) -> &Option<MemoryConfig> {
3706 &self.memory.0
3707 }
3708
3709 pub fn memory_mut(&mut self) -> &mut Option<MemoryConfig> {
3710 &mut self.memory.0
3711 }
3712
3713 pub fn subagents(&self) -> &SubagentsConfig {
3715 &self.subagents.0
3716 }
3717
3718 pub fn subagents_mut(&mut self) -> &mut SubagentsConfig {
3719 &mut self.subagents.0
3720 }
3721
3722 pub fn providers(&self) -> &ProviderConfigs {
3724 &self.providers.0
3725 }
3726
3727 pub fn providers_mut(&mut self) -> &mut ProviderConfigs {
3728 &mut self.providers.0
3729 }
3730
3731 pub(crate) fn clear_legacy_provider_aliases_for_instance_mode(&mut self) {
3735 if self
3736 .default_provider_instance
3737 .as_ref()
3738 .is_some_and(|id| self.provider_instances.contains_key(id))
3739 {
3740 self.providers.0.clear_legacy_builtin_aliases();
3741 }
3742 }
3743
3744 pub fn to_compatibility_value(&self) -> serde_json::Result<Value> {
3750 let mut value = serde_json::to_value(ConfigRoot::from(self.values.clone()))?;
3751 let object = value
3752 .as_object_mut()
3753 .expect("ConfigRoot always serializes as a JSON object");
3754 object.insert("memory".to_string(), serde_json::to_value(self.memory())?);
3755 object.insert(
3756 "subagents".to_string(),
3757 serde_json::to_value(self.subagents())?,
3758 );
3759 object.insert(
3760 "providers".to_string(),
3761 serde_json::to_value(self.providers())?,
3762 );
3763 Ok(value)
3764 }
3765
3766 pub fn new() -> Self {
3785 Self::from_data_dir(None)
3786 }
3787
3788 fn from_completed_facade(data_dir: &Path, publish: bool, apply_env: bool) -> Self {
3789 let mut config = match crate::ConfigFacade::open_or_migrate(data_dir) {
3790 Ok(facade) => facade.effective_config(),
3791 Err(error) => {
3792 tracing::warn!(
3793 error = %error,
3794 "completed modular configuration is unavailable; refusing legacy-root fallback"
3795 );
3796 Self::create_default()
3797 }
3798 };
3799
3800 if let Err(error) = config.hydrate_proxy_auth_from_store(data_dir) {
3801 tracing::warn!(error = %error, "proxy auth credential hydration unavailable");
3802 config.proxy_auth = None;
3803 }
3804 if let Err(error) = config.hydrate_provider_credentials_from_store(data_dir) {
3805 tracing::warn!(error = %error, "provider credential hydration unavailable");
3806 }
3807 if let Err(error) = config.hydrate_mcp_credentials_from_store(data_dir) {
3808 tracing::warn!(error = %error, "MCP credential hydration unavailable");
3809 }
3810 if let Err(error) = config.hydrate_env_var_credentials_from_store(data_dir) {
3811 tracing::warn!(error = %error, "env credential hydration unavailable");
3812 for entry in &mut config.env_vars {
3813 if entry.secret {
3814 entry.value.clear();
3815 }
3816 }
3817 }
3818 if let Err(error) = config.hydrate_cluster_credentials_from_store(data_dir) {
3819 tracing::warn!(error = %error, "cluster credential hydration unavailable");
3820 config.clear_cluster_runtime_credentials();
3821 }
3822 if let Err(error) = config.hydrate_notification_credentials_from_store(data_dir) {
3823 tracing::warn!(error = %error, "notification credential hydration unavailable");
3824 config.notifications.ntfy.token = None;
3825 config.notifications.bark.device_key = None;
3826 }
3827 if let Err(error) = config.hydrate_connect_credentials_from_store(data_dir) {
3828 tracing::warn!(error = %error, "connect credential hydration unavailable");
3829 for platform in &mut config.connect.platforms {
3830 platform.token = None;
3831 platform.app_secret = None;
3832 }
3833 }
3834 if let Err(error) = config.hydrate_access_control_credentials_from_store(data_dir) {
3835 tracing::warn!(error = %error, "access-control credential hydration unavailable");
3836 config.clear_access_control_runtime_verifiers();
3837 }
3838 if let Some(broker) = config.subagents_mut().broker.as_mut() {
3839 if let Err(error) = broker.hydrate_credential_from_store(data_dir) {
3840 tracing::warn!(error = %error, "external broker credential hydration unavailable");
3841 broker.token.clear();
3842 }
3843 }
3844 config.normalize_tool_settings();
3845 config.normalize_skill_settings();
3846 config.normalize_plugin_trust_settings();
3847 config.extra.remove("data_dir");
3848 if apply_env {
3849 config.apply_env_overrides();
3850 }
3851 if publish {
3852 config.publish_env_vars();
3853 }
3854 config
3855 }
3856
3857 fn from_data_dir_impl(data_dir: Option<PathBuf>, publish: bool, apply_env: bool) -> Self {
3866 let data_dir = data_dir
3868 .or_else(|| std::env::var("BAMBOO_DATA_DIR").ok().map(PathBuf::from))
3869 .unwrap_or_else(default_data_dir);
3870
3871 match crate::modular_authority_boundary_present(&data_dir) {
3872 Ok(true) => return Self::from_completed_facade(&data_dir, publish, apply_env),
3873 Ok(false) => {}
3874 Err(error) => {
3875 tracing::warn!(
3876 error = %error,
3877 "modular configuration marker is unavailable; refusing legacy-root fallback"
3878 );
3879 let mut config = Self::create_default();
3880 if apply_env {
3881 config.apply_env_overrides();
3882 }
3883 if publish {
3884 config.publish_env_vars();
3885 }
3886 return config;
3887 }
3888 }
3889
3890 let provider_mcp_ready = crate::migrate_provider_mcp_credentials(&data_dir)
3895 .and_then(|_| crate::ensure_provider_mcp_migration_ready(&data_dir))
3896 .map_err(|error| {
3897 tracing::warn!(error = %error, "provider/MCP/root credential migration unavailable");
3898 error
3899 })
3900 .is_ok();
3901 let cluster_ready = crate::migrate_cluster_credentials(&data_dir)
3902 .and_then(|_| crate::ensure_provider_mcp_migration_ready(&data_dir))
3903 .map_err(|error| {
3904 tracing::warn!(error = %error, "cluster credential migration unavailable");
3905 error
3906 })
3907 .is_ok();
3908
3909 match crate::modular_authority_boundary_present(&data_dir) {
3914 Ok(true) => return Self::from_completed_facade(&data_dir, publish, apply_env),
3915 Ok(false) => {}
3916 Err(error) => {
3917 tracing::warn!(
3918 error = %error,
3919 "modular configuration boundary is unavailable; refusing legacy-root fallback"
3920 );
3921 let mut config = Self::create_default();
3922 if apply_env {
3923 config.apply_env_overrides();
3924 }
3925 if publish {
3926 config.publish_env_vars();
3927 }
3928 return config;
3929 }
3930 }
3931
3932 let config_path = data_dir.join("config.json");
3933
3934 let mut config = if config_path.exists() {
3935 if let Ok(content) = std::fs::read_to_string(&config_path) {
3936 Self::parse_and_hydrate(&content).unwrap_or_else(|e| {
3937 tracing::warn!(
3947 "Failed to parse config.json ({}); quarantining it and attempting recovery",
3948 e
3949 );
3950 let quarantine_path = quarantine_corrupt_config(&config_path);
3951 let (mut recovered, source) = Self::salvage_partial(&content, &data_dir)
3952 .map(|(cfg, fields)| (cfg, ConfigRecoverySource::Salvaged { fields }))
3953 .or_else(|| {
3954 Self::load_backup(&data_dir).map(|(cfg, generation)| {
3955 (cfg, ConfigRecoverySource::Backup { generation })
3956 })
3957 })
3958 .unwrap_or_else(|| {
3959 tracing::warn!(
3960 "Could not salvage and no usable config.json.bak; using defaults"
3961 );
3962 (Self::create_default(), ConfigRecoverySource::Defaults)
3963 });
3964 recovered.recovery_status = Some(ConfigRecoveryStatus {
3965 source,
3966 quarantine_path,
3967 confirmed: false,
3968 });
3969 recovered
3970 })
3971 } else {
3972 Self::create_default()
3973 }
3974 } else {
3975 Self::create_default()
3976 };
3977
3978 let mut memory_module = config.memory.clone();
3984 match memory_module.load_sync(&data_dir) {
3985 Ok(true) => config.memory = memory_module,
3986 Ok(false) => {}
3987 Err(error) => tracing::warn!(
3988 "Failed to load memory.json; using legacy config.json memory: {error}"
3989 ),
3990 }
3991 let mut subagents_module = config.subagents.clone();
3992 match subagents_module.load_sync(&data_dir) {
3993 Ok(true) => config.subagents = subagents_module,
3994 Ok(false) => {}
3995 Err(error) => tracing::warn!(
3996 "Failed to load subagents.json; using legacy config.json subagents: {error}"
3997 ),
3998 }
3999 if provider_mcp_ready {
4000 let mut providers_module = config.providers.clone();
4001 match providers_module.load_sync(&data_dir) {
4002 Ok(true) => config.providers = providers_module,
4003 Ok(false) => {}
4004 Err(error) => tracing::warn!(
4005 "Failed to load providers.json; using legacy config.json providers: {error}"
4006 ),
4007 }
4008 }
4009
4010 config.hydrate_proxy_auth_from_encrypted();
4012 if provider_mcp_ready {
4013 if let Err(error) = config.hydrate_proxy_auth_from_store(&data_dir) {
4014 tracing::warn!(error = %error, "proxy auth credential hydration unavailable");
4015 config.proxy_auth = None;
4016 }
4017 }
4018 config.hydrate_provider_api_keys_from_encrypted();
4020 config.hydrate_provider_instance_api_keys_from_encrypted();
4022 config.hydrate_mcp_secrets_from_encrypted();
4024 if provider_mcp_ready {
4025 if let Err(error) = config.hydrate_provider_credentials_from_store(&data_dir) {
4026 tracing::warn!(error = %error, "provider credential hydration unavailable");
4027 }
4028 if let Err(error) = config.hydrate_mcp_credentials_from_store(&data_dir) {
4029 tracing::warn!(error = %error, "MCP credential hydration unavailable");
4030 }
4031 }
4032 config.hydrate_env_vars_from_encrypted();
4034 if provider_mcp_ready {
4035 if let Err(error) = config.hydrate_env_var_credentials_from_store(&data_dir) {
4036 tracing::warn!(error = %error, "env credential hydration unavailable");
4037 for entry in &mut config.env_vars {
4038 if entry.secret {
4039 entry.value.clear();
4040 }
4041 }
4042 }
4043 }
4044 if cluster_ready {
4049 if let Err(error) = config.hydrate_cluster_credentials_from_store(&data_dir) {
4050 tracing::warn!(error = %error, "cluster credential hydration unavailable");
4051 config.clear_cluster_runtime_credentials();
4052 }
4053 } else {
4054 config.clear_cluster_runtime_credentials();
4055 }
4056 config.hydrate_broker_token_from_encrypted();
4058 config.hydrate_notifications_from_encrypted();
4060 if provider_mcp_ready {
4061 if let Err(error) = config.hydrate_notification_credentials_from_store(&data_dir) {
4062 tracing::warn!(error = %error, "notification credential hydration unavailable");
4063 config.notifications.ntfy.token = None;
4064 config.notifications.bark.device_key = None;
4065 }
4066 } else {
4067 config.notifications.ntfy.token = None;
4072 config.notifications.bark.device_key = None;
4073 }
4074 config.merge_connect_config(&data_dir);
4079 scrub_legacy_connect_from_config_backups(&data_dir);
4089 config.hydrate_connect_platform_tokens_from_encrypted();
4091 if provider_mcp_ready {
4092 if let Err(error) = config.hydrate_connect_credentials_from_store(&data_dir) {
4093 tracing::warn!(error = %error, "connect credential hydration unavailable");
4094 for platform in &mut config.connect.platforms {
4095 platform.token = None;
4096 platform.app_secret = None;
4097 }
4098 }
4099 }
4100 if provider_mcp_ready {
4101 if let Err(error) = config.hydrate_access_control_credentials_from_store(&data_dir) {
4102 tracing::warn!(error = %error, "access-control credential hydration unavailable");
4103 config.clear_access_control_runtime_verifiers();
4104 }
4105 } else {
4106 config.clear_access_control_runtime_verifiers();
4107 }
4108 config.normalize_tool_settings();
4109 config.normalize_skill_settings();
4110 config.normalize_plugin_trust_settings();
4111
4112 config.extra.remove("data_dir");
4115
4116 if apply_env {
4120 config.apply_env_overrides();
4121 }
4122
4123 if publish {
4127 config.publish_env_vars();
4128 }
4129
4130 config
4131 }
4132
4133 fn apply_env_overrides(&mut self) {
4137 if let Ok(port) = std::env::var("BAMBOO_PORT") {
4138 if let Ok(port) = port.parse() {
4139 self.server.port = port;
4140 }
4141 }
4142
4143 if let Ok(bind) = std::env::var("BAMBOO_BIND") {
4144 self.server.bind = bind;
4145 }
4146
4147 if let Ok(provider) = crate::runtime_env_var("BAMBOO_PROVIDER") {
4152 let provider = provider.trim().to_string();
4153 if !provider.is_empty() {
4154 self.provider = provider.clone();
4155 if !self.provider_instances.is_empty() {
4156 let selected = self
4157 .provider_instances
4158 .contains_key(&provider)
4159 .then_some(provider.clone())
4160 .or_else(|| {
4161 let mut matching = self
4162 .provider_instances
4163 .iter()
4164 .filter(|(_, instance)| {
4165 instance.enabled && instance.provider_type == provider
4166 })
4167 .map(|(id, _)| id.clone())
4168 .collect::<Vec<_>>();
4169 matching.sort();
4170 matching.into_iter().next()
4171 });
4172 self.default_provider_instance = selected;
4173 }
4174 }
4175 }
4176
4177 if let Ok(headless) = std::env::var("BAMBOO_HEADLESS") {
4178 self.headless_auth = parse_bool_env(&headless);
4179 }
4180
4181 if let Ok(project_prompt_injection) =
4182 std::env::var("BAMBOO_MEMORY_PROJECT_PROMPT_INJECTION")
4183 {
4184 let memory = self.memory.get_or_insert_with(MemoryConfig::default);
4185 memory.project_prompt_injection = parse_bool_env(&project_prompt_injection);
4186 }
4187
4188 if let Ok(relevant_recall) = std::env::var("BAMBOO_MEMORY_RELEVANT_RECALL") {
4189 let memory = self.memory.get_or_insert_with(MemoryConfig::default);
4190 memory.relevant_recall = parse_bool_env(&relevant_recall);
4191 }
4192
4193 if let Ok(relevant_recall_rerank) = std::env::var("BAMBOO_MEMORY_RELEVANT_RECALL_RERANK") {
4194 let memory = self.memory.get_or_insert_with(MemoryConfig::default);
4195 memory.relevant_recall_rerank = parse_bool_env(&relevant_recall_rerank);
4196 }
4197
4198 if let Ok(project_first_dream) = std::env::var("BAMBOO_MEMORY_PROJECT_FIRST_DREAM") {
4199 let memory = self.memory.get_or_insert_with(MemoryConfig::default);
4200 memory.project_first_dream = parse_bool_env(&project_first_dream);
4201 }
4202
4203 if let Ok(key) = crate::runtime_env_var("BAMBOO_OPENAI_API_KEY") {
4210 let key = key.trim();
4211 if !key.is_empty() {
4212 if self.provider_instances.is_empty() {
4213 let openai = self
4214 .providers
4215 .openai
4216 .get_or_insert_with(OpenAIConfig::default);
4217 openai.api_key = key.to_string();
4218 openai.api_key_from_env = true;
4219 } else {
4220 self.apply_provider_instance_env_key("openai", key);
4221 }
4222 }
4223 }
4224 if let Ok(key) = crate::runtime_env_var("BAMBOO_ANTHROPIC_API_KEY") {
4225 let key = key.trim();
4226 if !key.is_empty() {
4227 if self.provider_instances.is_empty() {
4228 let anthropic = self
4229 .providers
4230 .anthropic
4231 .get_or_insert_with(AnthropicConfig::default);
4232 anthropic.api_key = key.to_string();
4233 anthropic.api_key_from_env = true;
4234 } else {
4235 self.apply_provider_instance_env_key("anthropic", key);
4236 }
4237 }
4238 }
4239 if let Ok(key) = crate::runtime_env_var("BAMBOO_GEMINI_API_KEY") {
4240 let key = key.trim();
4241 if !key.is_empty() {
4242 if self.provider_instances.is_empty() {
4243 let gemini = self
4244 .providers
4245 .gemini
4246 .get_or_insert_with(GeminiConfig::default);
4247 gemini.api_key = key.to_string();
4248 gemini.api_key_from_env = true;
4249 } else {
4250 self.apply_provider_instance_env_key("gemini", key);
4251 }
4252 }
4253 }
4254 }
4255
4256 fn apply_provider_instance_env_key(&mut self, provider_type: &str, key: &str) {
4257 for instance in self.provider_instances.values_mut().filter(|instance| {
4258 instance.provider_type == provider_type
4259 && crate::provider_instance_api_key_from_env(instance)
4260 }) {
4261 instance.api_key = key.to_string();
4262 instance.api_key_encrypted = None;
4263 }
4264 }
4265
4266 pub fn apply_runtime_env_overrides(&mut self) {
4271 self.apply_env_overrides();
4272 }
4273
4274 pub fn from_data_dir(data_dir: Option<PathBuf>) -> Self {
4280 Self::from_data_dir_impl(data_dir, true, true)
4281 }
4282
4283 pub fn from_data_dir_without_publish(data_dir: Option<PathBuf>) -> Self {
4287 Self::from_data_dir_impl(data_dir, false, true)
4288 }
4289
4290 pub fn from_data_dir_without_env(data_dir: Option<PathBuf>) -> Self {
4297 Self::from_data_dir_impl(data_dir, false, false)
4298 }
4299
4300 fn merge_connect_config(&mut self, data_dir: &std::path::Path) {
4328 let connect_path = data_dir.join("connect.json");
4329 match std::fs::read_to_string(&connect_path) {
4330 Ok(content) => match serde_json::from_str::<ConnectConfig>(&content) {
4331 Ok(connect) => {
4332 let legacy_key_present = !connect_config_is_empty(&self.connect);
4333 if legacy_key_present {
4334 tracing::warn!(
4335 "config.json still has a legacy `connect` key alongside \
4336 connect.json; connect.json takes precedence — dropping the \
4337 stale key from config.json now"
4338 );
4339 }
4340 self.connect = connect;
4341 if legacy_key_present {
4342 strip_legacy_connect_key_from_config_json(data_dir);
4343 }
4344 }
4345 Err(e) => {
4346 tracing::error!(
4347 "Failed to parse {:?} ({}); continuing with an empty (inert) \
4348 connect config instead of falling back to a legacy config.json copy",
4349 connect_path,
4350 e
4351 );
4352 quarantine_corrupt_connect(&connect_path);
4353 self.connect = ConnectConfig::default();
4354 }
4355 },
4356 Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
4357 if !connect_config_is_empty(&self.connect) {
4358 tracing::info!(
4359 "Migrating legacy `connect` config from config.json to a \
4360 standalone connect.json"
4361 );
4362 strip_legacy_connect_key_from_config_json(data_dir);
4366 if let Err(e) = save_connect_config(&self.connect, data_dir) {
4367 tracing::error!("Failed to write connect.json during migration: {}", e);
4368 }
4369 }
4370 }
4371 Err(e) => {
4372 tracing::error!(
4373 "Failed to read {:?} ({}); continuing with an empty (inert) connect config",
4374 connect_path,
4375 e
4376 );
4377 self.connect = ConnectConfig::default();
4378 }
4379 }
4380 }
4381
4382 fn parse_and_hydrate(content: &str) -> std::result::Result<Self, serde_json::Error> {
4385 serde_json::from_str::<Config>(content).map(|mut config| {
4386 config.hydrate_proxy_auth_from_encrypted();
4387 config.hydrate_provider_api_keys_from_encrypted();
4388 config.hydrate_provider_instance_api_keys_from_encrypted();
4389 config.hydrate_mcp_secrets_from_encrypted();
4390 config.hydrate_env_vars_from_encrypted();
4391 config.hydrate_cluster_fabric_from_encrypted();
4392 config.hydrate_broker_token_from_encrypted();
4393 config.hydrate_notifications_from_encrypted();
4394 config.hydrate_connect_platform_tokens_from_encrypted();
4395 config.normalize_tool_settings();
4396 config.normalize_skill_settings();
4397 config
4398 })
4399 }
4400
4401 fn load_backup(data_dir: &std::path::Path) -> Option<(Self, usize)> {
4407 let config_path = data_dir.join("config.json");
4408 for gen in 0..BAK_GENERATIONS {
4409 let backup = backup_path_for(&config_path, gen);
4410 let Ok(content) = std::fs::read_to_string(&backup) else {
4411 continue;
4412 };
4413 match Self::parse_and_hydrate(&content) {
4414 Ok(config) => {
4415 tracing::info!("Recovered configuration from {:?}", backup);
4416 return Some((config, gen));
4417 }
4418 Err(e) => {
4419 tracing::warn!(
4420 "Backup {:?} is unparseable ({}); trying an older generation",
4421 backup,
4422 e
4423 );
4424 }
4425 }
4426 }
4427 None
4428 }
4429
4430 const SALVAGE_MAX_KEYS: usize = 512;
4436
4437 fn salvage_partial(content: &str, data_dir: &std::path::Path) -> Option<(Self, Vec<String>)> {
4464 let corrupt: serde_json::Value = serde_json::from_str(content).ok()?;
4467 let corrupt_obj = corrupt.as_object()?;
4468 if corrupt_obj.len() > Self::SALVAGE_MAX_KEYS {
4469 tracing::warn!(
4470 "config.json has {} top-level keys (> {}); skipping salvage to avoid an O(n^2) load",
4471 corrupt_obj.len(),
4472 Self::SALVAGE_MAX_KEYS
4473 );
4474 return None;
4475 }
4476
4477 let mut base = Self::load_backup(data_dir)
4481 .and_then(|(backup, _generation)| backup.to_compatibility_value().ok())
4482 .or_else(|| Self::create_default().to_compatibility_value().ok())?;
4483 let base_obj = base.as_object_mut()?;
4484
4485 let mut salvaged: Vec<String> = Vec::new();
4486 for (key, value) in corrupt_obj {
4487 let previous = base_obj.insert(key.clone(), value.clone());
4488 if serde_json::from_value::<Self>(serde_json::Value::Object(base_obj.clone())).is_ok() {
4492 salvaged.push(key.clone());
4493 } else {
4494 match previous {
4495 Some(prev) => {
4496 base_obj.insert(key.clone(), prev);
4497 }
4498 None => {
4499 base_obj.remove(key);
4500 }
4501 }
4502 }
4503 }
4504
4505 tracing::warn!(
4506 "Salvaged {} field(s) from corrupt config.json ({}); corrupt fields kept the \
4507 last-known-good/default value",
4508 salvaged.len(),
4509 salvaged.join(", ")
4510 );
4511
4512 let rebuilt = serde_json::to_string(&base).ok()?;
4516 Self::parse_and_hydrate(&rebuilt)
4517 .ok()
4518 .map(|config| (config, salvaged))
4519 }
4520
4521 pub fn get_model(&self) -> Option<String> {
4529 if self.features.provider_model_ref {
4530 if let Some(model_ref) = self.defaults.as_ref().map(|d| &d.chat) {
4531 return Some(model_ref.model.clone());
4532 }
4533 }
4534 let provider = self.effective_default_provider();
4535 if let Some(instance) = self.provider_instances.get(provider) {
4536 return instance
4537 .model
4538 .clone()
4539 .or_else(|| (instance.provider_type == "copilot").then(|| "gpt-4o".to_string()));
4540 }
4541 match provider {
4542 "openai" => self.providers.openai.as_ref().and_then(|c| c.model.clone()),
4543 "anthropic" => self
4544 .providers
4545 .anthropic
4546 .as_ref()
4547 .and_then(|c| c.model.clone()),
4548 "gemini" => self.providers.gemini.as_ref().and_then(|c| c.model.clone()),
4549 "copilot" => Some(
4550 self.providers
4551 .copilot
4552 .as_ref()
4553 .and_then(|c| c.model.clone())
4554 .unwrap_or_else(|| "gpt-4o".to_string()),
4555 ),
4556 _ => None,
4557 }
4558 }
4559
4560 pub fn get_fast_model(&self) -> Option<String> {
4568 if self.features.provider_model_ref {
4569 if let Some(model_ref) = self.defaults.as_ref().and_then(|d| d.fast.as_ref()) {
4570 return Some(model_ref.model.clone());
4571 }
4572 }
4573 let provider = self.effective_default_provider();
4574 let fast = if let Some(instance) = self.provider_instances.get(provider) {
4575 instance.fast_model.clone()
4576 } else {
4577 match provider {
4578 "openai" => self
4579 .providers
4580 .openai
4581 .as_ref()
4582 .and_then(|c| c.fast_model.clone()),
4583 "anthropic" => self
4584 .providers
4585 .anthropic
4586 .as_ref()
4587 .and_then(|c| c.fast_model.clone()),
4588 "gemini" => self
4589 .providers
4590 .gemini
4591 .as_ref()
4592 .and_then(|c| c.fast_model.clone()),
4593 "copilot" => self
4594 .providers
4595 .copilot
4596 .as_ref()
4597 .and_then(|c| c.fast_model.clone()),
4598 _ => None,
4599 }
4600 };
4601 fast.or_else(|| self.get_model())
4602 }
4603
4604 pub fn get_task_summary_model(&self) -> Option<String> {
4612 if self.features.provider_model_ref {
4613 if let Some(model_ref) = self
4614 .defaults
4615 .as_ref()
4616 .and_then(|d| d.task_summary.as_ref())
4617 .or_else(|| self.defaults.as_ref().and_then(|d| d.fast.as_ref()))
4618 .or_else(|| self.defaults.as_ref().map(|d| &d.chat))
4619 {
4620 return Some(model_ref.model.clone());
4621 }
4622 }
4623
4624 self.get_fast_model()
4625 }
4626
4627 pub fn get_memory_background_model(&self) -> Option<String> {
4635 if self.features.provider_model_ref {
4636 if let Some(model_ref) = self
4637 .defaults
4638 .as_ref()
4639 .and_then(|d| d.memory_background.as_ref())
4640 {
4641 return Some(model_ref.model.clone());
4642 }
4643 if let Some(model_ref) = self.defaults.as_ref().and_then(|d| d.fast.as_ref()) {
4644 return Some(model_ref.model.clone());
4645 }
4646 if let Some(model_ref) = self.defaults.as_ref().map(|d| &d.chat) {
4647 return Some(model_ref.model.clone());
4648 }
4649 }
4650 let configured = self
4651 .memory
4652 .as_ref()
4653 .and_then(|memory| memory.background_model.as_ref())
4654 .map(|value| value.trim())
4655 .filter(|value| !value.is_empty())
4656 .map(ToString::to_string);
4657 configured.or_else(|| self.get_fast_model())
4658 }
4659
4660 pub fn get_default_work_area_path(&self) -> Option<PathBuf> {
4668 let raw = self
4669 .default_work_area
4670 .as_ref()
4671 .and_then(|config| config.path.as_ref())
4672 .map(|value| value.trim())
4673 .filter(|value| !value.is_empty())?;
4674
4675 let candidate = expand_user_path(raw);
4676 if candidate.is_absolute() {
4677 let canonical = std::fs::canonicalize(&candidate).ok();
4678 return canonical
4679 .as_ref()
4680 .filter(|path| path.is_dir())
4681 .map(|_| candidate.clone())
4682 .or_else(|| candidate.is_dir().then_some(candidate));
4683 }
4684
4685 let from_bamboo_dir = crate::paths::bamboo_dir().join(&candidate);
4686 let canonical = std::fs::canonicalize(&from_bamboo_dir).ok();
4687 canonical
4688 .as_ref()
4689 .filter(|path| path.is_dir())
4690 .map(|_| from_bamboo_dir.clone())
4691 .or_else(|| from_bamboo_dir.is_dir().then_some(from_bamboo_dir))
4692 .or_else(|| candidate.is_dir().then_some(candidate))
4693 }
4694
4695 pub fn get_vision_model(&self) -> Option<String> {
4701 if self.features.provider_model_ref {
4702 if let Some(model_ref) = self
4703 .defaults
4704 .as_ref()
4705 .and_then(|defaults| defaults.vision.as_ref())
4706 .or_else(|| {
4707 self.defaults
4708 .as_ref()
4709 .and_then(|defaults| defaults.fast.as_ref())
4710 })
4711 .or_else(|| self.defaults.as_ref().map(|defaults| &defaults.chat))
4712 {
4713 return Some(model_ref.model.clone());
4714 }
4715 }
4716 let provider = self.effective_default_provider();
4717 let vision = if let Some(instance) = self.provider_instances.get(provider) {
4718 instance.vision_model.clone()
4719 } else {
4720 match provider {
4721 "openai" => self
4722 .providers
4723 .openai
4724 .as_ref()
4725 .and_then(|c| c.vision_model.clone()),
4726 "anthropic" => self
4727 .providers
4728 .anthropic
4729 .as_ref()
4730 .and_then(|c| c.vision_model.clone()),
4731 "gemini" => self
4732 .providers
4733 .gemini
4734 .as_ref()
4735 .and_then(|c| c.vision_model.clone()),
4736 "copilot" => self
4737 .providers
4738 .copilot
4739 .as_ref()
4740 .and_then(|c| c.vision_model.clone()),
4741 _ => None,
4742 }
4743 };
4744 vision.or_else(|| self.get_fast_model())
4745 }
4746
4747 pub fn get_reasoning_effort(&self) -> Option<ReasoningEffort> {
4751 if self.features.provider_model_ref {
4752 if let Some(chat) = self.defaults.as_ref().map(|defaults| &defaults.chat) {
4753 return chat
4754 .reasoning_effort
4755 .or_else(|| self.reasoning_effort_for_key(&chat.provider));
4756 }
4757 }
4758 self.reasoning_effort_for_key(self.effective_default_provider())
4759 }
4760
4761 pub fn reasoning_effort_for_key(&self, key: &str) -> Option<ReasoningEffort> {
4771 let trimmed = key.trim();
4772 if trimmed.is_empty() {
4773 return None;
4774 }
4775
4776 if let Some(instance) = self.provider_instances.get(trimmed) {
4778 return instance.reasoning_effort;
4779 }
4780
4781 match trimmed {
4783 "openai" => self
4784 .providers
4785 .openai
4786 .as_ref()
4787 .and_then(|c| c.reasoning_effort),
4788 "anthropic" => self
4789 .providers
4790 .anthropic
4791 .as_ref()
4792 .and_then(|c| c.reasoning_effort),
4793 "gemini" => self
4794 .providers
4795 .gemini
4796 .as_ref()
4797 .and_then(|c| c.reasoning_effort),
4798 "copilot" => self
4799 .providers
4800 .copilot
4801 .as_ref()
4802 .and_then(|c| c.reasoning_effort),
4803 "bodhi" => self
4804 .providers
4805 .bodhi
4806 .as_ref()
4807 .and_then(|c| c.reasoning_effort),
4808 _ => None,
4809 }
4810 }
4811
4812 pub fn disabled_tool_references(&self) -> BTreeSet<String> {
4819 self.tools
4820 .disabled
4821 .iter()
4822 .map(|name| name.trim())
4823 .filter(|name| !name.is_empty())
4824 .map(str::to_string)
4825 .collect()
4826 }
4827
4828 pub fn disabled_tool_names(&self) -> BTreeSet<String> {
4833 self.disabled_tool_references()
4834 .into_iter()
4835 .map(|reference| normalize_tool_ref(&reference).unwrap_or(reference))
4836 .collect()
4837 }
4838
4839 pub fn normalize_tool_settings(&mut self) {
4841 self.tools.disabled = self.disabled_tool_references().into_iter().collect();
4842 }
4843
4844 pub fn disabled_skill_ids(&self) -> BTreeSet<String> {
4846 self.skills
4847 .disabled
4848 .iter()
4849 .map(|id| id.trim())
4850 .filter(|id| !id.is_empty())
4851 .map(|id| id.to_string())
4852 .collect()
4853 }
4854
4855 pub fn normalize_skill_settings(&mut self) {
4857 self.skills.disabled = self.disabled_skill_ids().into_iter().collect();
4858 }
4859
4860 pub fn normalize_plugin_trust_settings(&mut self) {
4868 self.plugin_trust.trusted_hosts = self
4869 .plugin_trust
4870 .trusted_hosts
4871 .iter()
4872 .map(|entry| entry.trim().to_ascii_lowercase())
4873 .filter(|entry| !entry.is_empty())
4874 .collect();
4875 }
4876
4877 pub fn effective_default_provider(&self) -> &str {
4882 self.default_provider_instance
4883 .as_deref()
4884 .unwrap_or(&self.provider)
4885 }
4886
4887 pub fn has_provider_instances(&self) -> bool {
4889 !self.provider_instances.is_empty()
4890 }
4891
4892 pub fn env_vars_as_map(&self) -> HashMap<String, String> {
4894 self.env_vars
4895 .iter()
4896 .filter(|e| !e.value.trim().is_empty())
4897 .map(|e| (e.name.clone(), e.value.clone()))
4898 .collect()
4899 }
4900
4901 fn prompt_safe_env_vars(&self) -> Vec<PromptSafeEnvVarEntry> {
4902 self.env_vars
4903 .iter()
4904 .filter(|entry| !entry.name.trim().is_empty() && !entry.value.trim().is_empty())
4905 .map(|entry| PromptSafeEnvVarEntry {
4906 name: entry.name.clone(),
4907 secret: entry.secret,
4908 description: entry
4909 .description
4910 .as_ref()
4911 .map(|value| value.trim().to_string())
4912 .filter(|value| !value.is_empty()),
4913 })
4914 .collect()
4915 }
4916
4917 pub fn publish_env_vars(&self) {
4919 let map = self.env_vars_as_map();
4920
4921 #[cfg(any(test, feature = "test-utils"))]
4922 if crate::test_support::env_vars_cache_override_is_active() {
4923 crate::test_support::publish_env_vars_to_override(map, self.prompt_safe_env_vars());
4924 return;
4925 }
4926
4927 let mut env_guard = env_vars_cache().write().recover_poison();
4928 *env_guard = map;
4929
4930 let prompt_safe = self.prompt_safe_env_vars();
4931 let mut prompt_guard = prompt_safe_env_vars_cache().write().recover_poison();
4932 *prompt_guard = prompt_safe;
4933 }
4934
4935 pub fn current_env_vars() -> HashMap<String, String> {
4937 #[cfg(any(test, feature = "test-utils"))]
4938 if let Some(env_vars) = crate::test_support::current_env_vars_override() {
4939 return env_vars;
4940 }
4941
4942 env_vars_cache().read().recover_poison().clone()
4943 }
4944
4945 pub fn current_prompt_safe_env_vars() -> Vec<PromptSafeEnvVarEntry> {
4947 #[cfg(any(test, feature = "test-utils"))]
4948 if let Some(env_vars) = crate::test_support::current_prompt_safe_env_vars_override() {
4949 return env_vars;
4950 }
4951
4952 prompt_safe_env_vars_cache().read().recover_poison().clone()
4953 }
4954
4955 fn create_default() -> Self {
4957 Self::from_parts(
4958 ConfigValues {
4959 http_proxy: String::new(),
4960 https_proxy: String::new(),
4961 proxy_auth: None,
4962 proxy_auth_encrypted: None,
4963 proxy_auth_credential_ref: None,
4964 headless_auth: false,
4965 run_budget: RunBudgetConfig::default(),
4966 stream_timeout: StreamTimeoutConfig::default(),
4967 llm_retry: None,
4968 context_management: ContextManagementConfig::default(),
4969 cluster_fabric: crate::cluster_fabric::ClusterFabricConfig::default(),
4970 provider: default_provider(),
4971 provider_instances: HashMap::new(),
4972 default_provider_instance: None,
4973 server: ServerConfig::default(),
4974 keyword_masking: KeywordMaskingConfig::default(),
4975 anthropic_model_mapping: AnthropicModelMapping::default(),
4976 gemini_model_mapping: GeminiModelMapping::default(),
4977 patrol_model: None,
4978 hooks: HooksConfig::default(),
4979 lifecycle_hooks: LifecycleHooksConfig::default(),
4980 tools: ToolsConfig::default(),
4981 skills: SkillsConfig::default(),
4982 env_vars: Vec::new(),
4983 default_work_area: None,
4984 worktrees: WorktreeSettings::default(),
4985 access_control: None,
4986 features: FeatureFlags::default(),
4987 defaults: None,
4988 mcp: bamboo_domain::mcp_config::McpConfig::default(),
4989 notifications: NotificationsConfig::default(),
4990 connect: ConnectConfig::default(),
4991 plugin_trust: PluginTrustConfig::default(),
4992 extra: BTreeMap::new(),
4993 },
4994 None,
4995 SubagentsConfig::default(),
4996 ProviderConfigs::default(),
4997 )
4998 }
4999
5000 pub fn server_addr(&self) -> String {
5002 format!("{}:{}", self.server.bind, self.server.port)
5003 }
5004
5005 pub fn save(&self) -> Result<()> {
5007 self.save_to_dir(default_data_dir())
5008 }
5009
5010 pub fn save_memory_to_dir(&self, data_dir: &std::path::Path) -> Result<()> {
5013 self.memory.save_sync(data_dir)
5014 }
5015
5016 pub fn save_subagents_to_dir(&self, data_dir: &std::path::Path) -> Result<()> {
5019 self.subagents.save_sync(data_dir)
5020 }
5021
5022 pub fn save_providers_to_dir(&self, data_dir: &std::path::Path) -> Result<()> {
5026 crate::config_module::ensure_legacy_module_layout(data_dir)?;
5027 crate::validate_runtime_model_admission(self).map_err(anyhow::Error::msg)?;
5028 let mut config = self.clone();
5029 config.clear_legacy_provider_aliases_for_instance_mode();
5030 config.refresh_provider_api_keys_encrypted()?;
5031 config.providers.save_sync(data_dir)
5032 }
5033
5034 pub(crate) fn prepare_provider_transaction_documents(
5038 &self,
5039 provider_document: &[u8],
5040 ) -> Result<(Vec<u8>, Vec<u8>)> {
5041 crate::validate_runtime_model_admission(self).map_err(anyhow::Error::msg)?;
5042 if let Some(status) = self.recovery_status.as_ref().filter(|s| !s.confirmed) {
5043 anyhow::bail!(
5044 "refusing to overwrite config.json: recovery from {:?} is unconfirmed",
5045 status.source
5046 );
5047 }
5048
5049 let mut to_save = self.clone();
5050 to_save.clear_legacy_provider_aliases_for_instance_mode();
5051 to_save.extra.remove("data_dir");
5052 to_save.extra.remove("model");
5053 to_save.refresh_encrypted_secrets()?;
5054 to_save.ensure_provider_instance_credentials_isolated()?;
5055 to_save.sanitize_mcp_credential_refs_for_disk();
5056 to_save.sanitize_env_vars_for_disk();
5057 to_save.sanitize_notifications_for_disk();
5058 to_save.sanitize_cluster_fabric_for_disk();
5059 to_save.assign_connect_platform_ids();
5060 to_save.normalize_tool_settings();
5061 to_save.normalize_skill_settings();
5062
5063 let mut root = durable_root_value(to_save.values.clone())
5064 .context("Failed to serialize root config DTO to JSON")?;
5065 if let Some(object) = root.as_object_mut() {
5066 object.remove("connect");
5067 }
5068 let root = serde_json::to_vec_pretty(&root)?;
5069
5070 let mut providers = to_save.providers.0.clone();
5071 macro_rules! sanitize_provider {
5072 ($field:ident) => {
5073 if let Some(provider) = providers.$field.as_mut() {
5074 if !provider.api_key.trim().is_empty()
5075 && !provider.api_key_from_env
5076 && provider.credential_ref.is_none()
5077 {
5078 anyhow::bail!(
5079 "provider secret requires credential transaction before persistence"
5080 );
5081 }
5082 provider.api_key_encrypted = None;
5083 }
5084 };
5085 }
5086 sanitize_provider!(openai);
5087 sanitize_provider!(anthropic);
5088 sanitize_provider!(gemini);
5089 if let Some(provider) = providers.bodhi.as_mut() {
5090 if !provider.api_key.trim().is_empty() && provider.credential_ref.is_none() {
5091 anyhow::bail!("provider secret requires credential transaction before persistence");
5092 }
5093 provider.api_key_encrypted = None;
5094 }
5095
5096 let existing_provider_value = if provider_document.is_empty() {
5097 None
5098 } else {
5099 Some(
5100 serde_json::from_slice::<Value>(provider_document)
5101 .context("provider metadata document is invalid")?,
5102 )
5103 };
5104 let provider_value = match existing_provider_value {
5105 Some(Value::Object(mut envelope))
5106 if envelope.contains_key("schema_version")
5107 || envelope.contains_key("revision")
5108 || envelope.contains_key("data") =>
5109 {
5110 let revision = envelope
5111 .get("revision")
5112 .and_then(Value::as_u64)
5113 .ok_or_else(|| anyhow::anyhow!("provider revision envelope is invalid"))?;
5114 let schema_version = envelope
5115 .get("schema_version")
5116 .and_then(Value::as_u64)
5117 .ok_or_else(|| anyhow::anyhow!("provider revision envelope is invalid"))?;
5118 if schema_version != 1 || !envelope.contains_key("data") {
5119 anyhow::bail!("provider revision envelope is unsupported");
5120 }
5121 let revision = revision
5122 .checked_add(1)
5123 .ok_or_else(|| anyhow::anyhow!("provider revision counter exhausted"))?;
5124 envelope.insert("revision".into(), Value::from(revision));
5125 envelope.insert("data".into(), serde_json::to_value(providers)?);
5126 Value::Object(envelope)
5127 }
5128 Some(_) | None => serde_json::to_value(providers)?,
5129 };
5130 Ok((root, serde_json::to_vec_pretty(&provider_value)?))
5131 }
5132
5133 pub fn recovery_status(&self) -> Option<&ConfigRecoveryStatus> {
5137 self.recovery_status.as_ref()
5138 }
5139
5140 pub fn confirm_recovery(&mut self) {
5146 if let Some(status) = self.recovery_status.as_mut() {
5147 status.confirmed = true;
5148 }
5149 }
5150
5151 pub fn confirm_recovery_and_save_to_dir(&mut self, data_dir: PathBuf) -> Result<()> {
5159 if self.recovery_status.is_none() {
5160 anyhow::bail!("No pending config-corruption recovery to confirm");
5161 }
5162 self.confirm_recovery();
5163 self.save_to_dir(data_dir)?;
5164 self.recovery_status = None;
5165 Ok(())
5166 }
5167
5168 pub fn assign_connect_platform_ids(&mut self) {
5184 for platform in &mut self.connect.platforms {
5185 if platform.id.is_none() {
5186 platform.id = Some(uuid::Uuid::new_v4().to_string());
5187 }
5188 }
5189 }
5190
5191 pub fn save_to_dir(&self, data_dir: PathBuf) -> Result<()> {
5203 crate::validate_runtime_model_admission(self).map_err(anyhow::Error::msg)?;
5204 if let Some(status) = self.recovery_status.as_ref().filter(|s| !s.confirmed) {
5205 anyhow::bail!(
5206 "refusing to overwrite config.json: it was recovered from corruption ({:?}) and \
5207 has not been confirmed; the corrupt original is preserved at {:?}. Call \
5208 Config::confirm_recovery (or the recovery-confirm API) first. (#153)",
5209 status.source,
5210 status.quarantine_path,
5211 );
5212 }
5213 if self.proxy_auth_credential_ref.is_none()
5214 && (self.proxy_auth.is_some() || self.proxy_auth_encrypted.is_some())
5215 {
5216 anyhow::bail!(
5217 "proxy auth requires the isolated credential transaction before persistence"
5218 );
5219 }
5220 if self.env_vars.iter().any(|entry| {
5221 entry.secret
5222 && entry.credential_ref.is_none()
5223 && (entry.configured || !entry.value.is_empty() || entry.value_encrypted.is_some())
5224 }) {
5225 anyhow::bail!(
5226 "secret env vars require the isolated credential transaction before persistence"
5227 );
5228 }
5229 if [
5230 (
5231 &self.notifications.ntfy.token,
5232 &self.notifications.ntfy.token_encrypted,
5233 &self.notifications.ntfy.credential_ref,
5234 ),
5235 (
5236 &self.notifications.bark.device_key,
5237 &self.notifications.bark.device_key_encrypted,
5238 &self.notifications.bark.credential_ref,
5239 ),
5240 ]
5241 .iter()
5242 .any(|(plaintext, ciphertext, reference)| {
5243 reference.is_none()
5244 && (plaintext
5245 .as_deref()
5246 .is_some_and(|value| !value.trim().is_empty())
5247 || ciphertext.is_some())
5248 }) {
5249 anyhow::bail!(
5250 "notification secrets require the isolated credential transaction before persistence"
5251 );
5252 }
5253
5254 if crate::modular_authority_boundary_present(&data_dir)
5255 .context("Failed to inspect modular configuration authority boundary")?
5256 {
5257 crate::ConfigFacade::open_or_migrate(&data_dir)
5258 .context("Failed to recover modular configuration before persistence")?;
5259 crate::persist_facade_effective_config(&data_dir, self)
5260 .context("Failed to persist modular configuration sections")?;
5261 return Ok(());
5262 }
5263
5264 if crate::section_layout_is_active(&data_dir)
5265 .context("Failed to inspect modular configuration layout")?
5266 {
5267 crate::persist_facade_effective_config(&data_dir, self)
5268 .context("Failed to persist modular configuration sections")?;
5269 return Ok(());
5270 }
5271
5272 let path = data_dir.join("config.json");
5273
5274 if let Some(parent) = path.parent() {
5275 std::fs::create_dir_all(parent)
5276 .with_context(|| format!("Failed to create config dir: {:?}", parent))?;
5277 }
5278
5279 let mut to_save = self.clone();
5280 to_save.clear_legacy_provider_aliases_for_instance_mode();
5281 to_save.extra.remove("data_dir");
5283 to_save.extra.remove("model");
5285 to_save.refresh_encrypted_secrets()?;
5288 to_save.ensure_provider_instance_credentials_isolated()?;
5289 to_save.sanitize_mcp_credential_refs_for_disk();
5290 to_save.sanitize_env_vars_for_disk();
5291 to_save.sanitize_notifications_for_disk();
5292 to_save.sanitize_cluster_fabric_for_disk();
5293 to_save.assign_connect_platform_ids();
5294 to_save.normalize_tool_settings();
5295 to_save.normalize_skill_settings();
5296
5297 let mut config_value = durable_root_value(to_save.values.clone())
5307 .context("Failed to serialize root config DTO to JSON")?;
5308 if let Some(obj) = config_value.as_object_mut() {
5309 obj.remove("connect");
5310 }
5311 let content = serde_json::to_string_pretty(&config_value)
5312 .context("Failed to serialize config to JSON")?;
5313
5314 to_save.memory.save_sync(&data_dir)?;
5321 to_save.subagents.save_sync(&data_dir)?;
5322 to_save.providers.save_sync(&data_dir)?;
5323
5324 if path.exists()
5331 && std::fs::read_to_string(&path)
5332 .ok()
5333 .is_some_and(|c| Self::parse_and_hydrate(&c).is_ok())
5334 {
5335 rotate_backups(&path, BAK_GENERATIONS);
5339 let backup = backup_path_for(&path, 0);
5340 let backup_result = std::fs::read(&path).and_then(|bytes| {
5341 let mut value: Value = serde_json::from_slice(&bytes)
5342 .map_err(|error| std::io::Error::new(std::io::ErrorKind::InvalidData, error))?;
5343 if sanitize_ref_backed_mcp_json(&mut value) {
5344 let sanitized = serde_json::to_vec_pretty(&value).map_err(|error| {
5345 std::io::Error::new(std::io::ErrorKind::InvalidData, error)
5346 })?;
5347 write_atomic(&backup, &sanitized)
5348 } else {
5349 std::fs::copy(&path, &backup).map(|_| ())
5350 }
5351 });
5352 if let Err(e) = backup_result {
5353 tracing::warn!("Failed to back up config.json before save: {}", e);
5354 }
5355 scrub_ref_backed_mcp_from_config_backups(&path);
5356 }
5357
5358 write_atomic(&path, content.as_bytes())
5359 .with_context(|| format!("Failed to write config file: {:?}", path))?;
5360
5361 save_connect_config(&to_save.connect, &data_dir)?;
5362
5363 Ok(())
5364 }
5365}
5366
5367fn sanitize_ref_backed_mcp_json(root: &mut Value) -> bool {
5372 let Some(object) = root.as_object_mut() else {
5373 return false;
5374 };
5375 let Some(mcp) = (if object.contains_key("mcpServers") {
5376 object.get_mut("mcpServers")
5377 } else {
5378 object.get_mut("mcp")
5379 }) else {
5380 return false;
5381 };
5382 let mut changed = false;
5383 if let Some(servers) = mcp.get_mut("servers").and_then(Value::as_array_mut) {
5384 for server in servers {
5385 if let Some(object) = server.as_object_mut() {
5386 if let Some(transport) = object.get_mut("transport").and_then(Value::as_object_mut)
5387 {
5388 changed |= sanitize_ref_backed_mcp_transport(transport);
5389 }
5390 }
5391 }
5392 } else if let Some(servers) = mcp.as_object_mut() {
5393 for server in servers.values_mut() {
5394 if let Some(object) = server.as_object_mut() {
5395 changed |= sanitize_ref_backed_mcp_transport(object);
5396 if let Some(transport) = object.get_mut("transport").and_then(Value::as_object_mut)
5397 {
5398 changed |= sanitize_ref_backed_mcp_transport(transport);
5399 }
5400 }
5401 }
5402 }
5403 changed
5404}
5405
5406fn sanitize_ref_backed_mcp_transport(object: &mut serde_json::Map<String, Value>) -> bool {
5407 let mut changed = false;
5408 let env_names = object
5409 .get("env_credential_refs")
5410 .and_then(Value::as_object)
5411 .map(|refs| refs.keys().cloned().collect::<Vec<_>>())
5412 .unwrap_or_default();
5413 for name in env_names {
5414 for field in ["env", "env_encrypted"] {
5415 if object
5416 .get_mut(field)
5417 .and_then(Value::as_object_mut)
5418 .and_then(|values| values.remove(&name))
5419 .is_some()
5420 {
5421 changed = true;
5422 }
5423 }
5424 }
5425 let header_names = object
5426 .get("header_credential_refs")
5427 .and_then(Value::as_object)
5428 .map(|refs| refs.keys().cloned().collect::<Vec<_>>())
5429 .unwrap_or_default();
5430 for name in header_names {
5431 for field in ["headers", "headers_encrypted"] {
5432 if object
5433 .get_mut(field)
5434 .and_then(Value::as_object_mut)
5435 .and_then(|values| values.remove(&name))
5436 .is_some()
5437 {
5438 changed = true;
5439 }
5440 }
5441 }
5442 if let Some(headers) = object.get_mut("headers").and_then(Value::as_array_mut) {
5443 for header in headers {
5444 let Some(header) = header.as_object_mut() else {
5445 continue;
5446 };
5447 if header.get("credential_ref").is_some_and(Value::is_string) {
5448 changed |= header.remove("value").is_some();
5449 changed |= header.remove("value_encrypted").is_some();
5450 }
5451 }
5452 }
5453 changed
5454}
5455
5456fn scrub_ref_backed_mcp_from_config_backups(path: &std::path::Path) {
5457 for generation in 0..BAK_GENERATIONS {
5458 let backup = backup_path_for(path, generation);
5459 let Ok(bytes) = std::fs::read(&backup) else {
5460 continue;
5461 };
5462 let Ok(mut value) = serde_json::from_slice::<Value>(&bytes) else {
5463 continue;
5464 };
5465 if sanitize_ref_backed_mcp_json(&mut value) {
5466 if let Ok(sanitized) = serde_json::to_vec_pretty(&value) {
5467 if let Err(error) = write_atomic(&backup, &sanitized) {
5468 tracing::warn!(
5469 "Failed to scrub MCP credentials from {:?}: {}",
5470 backup,
5471 error
5472 );
5473 }
5474 }
5475 }
5476 }
5477}
5478
5479fn save_connect_config(connect: &ConnectConfig, data_dir: &std::path::Path) -> Result<()> {
5489 let path = data_dir.join("connect.json");
5490 if connect_config_is_empty(connect) && !path.exists() {
5491 return Ok(());
5492 }
5493
5494 if path.exists() {
5495 let backup = path.with_extension("json.bak");
5496 if let Err(e) = std::fs::copy(&path, &backup) {
5497 tracing::warn!("Failed to back up connect.json before save: {}", e);
5498 }
5499 }
5500
5501 let content = serde_json::to_string_pretty(connect)
5502 .context("Failed to serialize connect config to JSON")?;
5503 write_atomic(&path, content.as_bytes())
5504 .with_context(|| format!("Failed to write connect config file: {:?}", path))?;
5505 Ok(())
5506}
5507
5508fn strip_legacy_connect_key_from_config_json(data_dir: &std::path::Path) {
5526 let config_path = data_dir.join("config.json");
5527 let content = match std::fs::read_to_string(&config_path) {
5528 Ok(content) => content,
5529 Err(e) => {
5530 tracing::error!(
5531 "Failed to read config.json to strip legacy `connect` key: {}",
5532 e
5533 );
5534 return;
5535 }
5536 };
5537 let mut value: serde_json::Value = match serde_json::from_str(&content) {
5538 Ok(value) => value,
5539 Err(e) => {
5540 tracing::error!(
5541 "Failed to parse config.json to strip legacy `connect` key: {}",
5542 e
5543 );
5544 return;
5545 }
5546 };
5547 let Some(obj) = value.as_object_mut() else {
5548 return;
5549 };
5550 if obj.remove("connect").is_none() {
5551 return;
5554 }
5555 let rewritten = match serde_json::to_string_pretty(&value) {
5556 Ok(rewritten) => rewritten,
5557 Err(e) => {
5558 tracing::error!(
5559 "Failed to serialize config.json after stripping legacy `connect` key: {}",
5560 e
5561 );
5562 return;
5563 }
5564 };
5565 if let Err(e) = write_atomic(&config_path, rewritten.as_bytes()) {
5566 tracing::error!(
5567 "Failed to write config.json after stripping legacy `connect` key: {}",
5568 e
5569 );
5570 }
5571}
5572
5573fn scrub_legacy_connect_from_config_backups(data_dir: &std::path::Path) {
5610 let config_path = data_dir.join("config.json");
5611 for gen in 0..BAK_GENERATIONS {
5612 let backup = backup_path_for(&config_path, gen);
5613 let content = match std::fs::read_to_string(&backup) {
5614 Ok(content) => content,
5615 Err(e) => {
5616 if e.kind() != std::io::ErrorKind::NotFound {
5617 tracing::warn!(
5618 "Failed to read {:?} while scanning for legacy connect data ({}); \
5619 leaving it untouched",
5620 backup,
5621 e
5622 );
5623 }
5624 continue;
5625 }
5626 };
5627 let mut value: serde_json::Value = match serde_json::from_str(&content) {
5628 Ok(value) => value,
5629 Err(e) => {
5630 tracing::warn!(
5631 "Skipping unparsable backup {:?} while scanning for legacy connect data \
5632 ({}); left untouched (never deleted)",
5633 backup,
5634 e
5635 );
5636 continue;
5637 }
5638 };
5639 let Some(obj) = value.as_object_mut() else {
5640 continue;
5643 };
5644 if obj.remove("connect").is_none() {
5645 continue;
5648 }
5649 let rewritten = match serde_json::to_string_pretty(&value) {
5650 Ok(rewritten) => rewritten,
5651 Err(e) => {
5652 tracing::error!(
5653 "Failed to serialize {:?} after stripping legacy connect data: {}",
5654 backup,
5655 e
5656 );
5657 continue;
5658 }
5659 };
5660 match write_atomic(&backup, rewritten.as_bytes()) {
5661 Ok(()) => tracing::info!(
5662 "Scrubbed legacy embedded connect data from backup generation {:?} (#468)",
5663 backup
5664 ),
5665 Err(e) => tracing::error!(
5666 "Failed to write {:?} after stripping legacy connect data: {}",
5667 backup,
5668 e
5669 ),
5670 }
5671 }
5672}
5673
5674fn quarantine_corrupt_connect(connect_path: &std::path::Path) {
5689 let backup = connect_path.with_extension("json.bak");
5690 match std::fs::rename(connect_path, &backup) {
5691 Ok(()) => tracing::warn!("Quarantined corrupt connect.json to {:?}", backup),
5692 Err(e) => {
5693 tracing::warn!(
5694 "Failed to rename corrupt connect.json to {:?} ({}); falling back to copy+remove",
5695 backup,
5696 e
5697 );
5698 if let Err(e) = std::fs::copy(connect_path, &backup) {
5699 tracing::error!("Failed to quarantine corrupt connect.json: {}", e);
5700 return;
5701 }
5702 if let Err(e) = std::fs::remove_file(connect_path) {
5703 tracing::error!(
5704 "Quarantined corrupt connect.json to {:?} but failed to remove the \
5705 original {:?}: {}",
5706 backup,
5707 connect_path,
5708 e
5709 );
5710 }
5711 }
5712 }
5713}
5714
5715const QUARANTINE_KEEP: usize = 5;
5718
5719fn quarantine_corrupt_config(config_path: &std::path::Path) -> Option<PathBuf> {
5728 let nanos = std::time::SystemTime::now()
5729 .duration_since(std::time::UNIX_EPOCH)
5730 .map(|d| d.as_nanos())
5731 .unwrap_or(0);
5732 let mut quarantine = config_path.with_extension(format!("json.corrupted.{nanos}"));
5736 let mut dedup = 1u32;
5737 while quarantine.exists() {
5738 quarantine = config_path.with_extension(format!("json.corrupted.{nanos}.{dedup}"));
5739 dedup += 1;
5740 }
5741 let result = match std::fs::copy(config_path, &quarantine) {
5742 Ok(_) => {
5743 tracing::warn!("Quarantined corrupt config.json to {:?}", quarantine);
5744 Some(quarantine)
5745 }
5746 Err(e) => {
5747 tracing::error!("Failed to quarantine corrupt config.json: {}", e);
5748 None
5749 }
5750 };
5751 prune_quarantine_files(config_path, QUARANTINE_KEEP);
5752 result
5753}
5754
5755fn prune_quarantine_files(config_path: &std::path::Path, keep: usize) {
5758 let Some(dir) = config_path.parent() else {
5759 return;
5760 };
5761 let prefix = "config.json.corrupted.";
5762 let mut quarantines: Vec<std::path::PathBuf> = match std::fs::read_dir(dir) {
5763 Ok(entries) => entries
5764 .filter_map(|e| e.ok())
5765 .map(|e| e.path())
5766 .filter(|p| {
5767 p.file_name()
5768 .and_then(|n| n.to_str())
5769 .is_some_and(|n| n.starts_with(prefix))
5770 })
5771 .collect(),
5772 Err(_) => return,
5773 };
5774 if quarantines.len() <= keep {
5775 return;
5776 }
5777 quarantines.sort_by_key(|p| std::fs::metadata(p).and_then(|m| m.modified()).ok());
5779 let remove = quarantines.len() - keep;
5780 for stale in quarantines.into_iter().take(remove) {
5781 if let Err(e) = std::fs::remove_file(&stale) {
5782 tracing::warn!("Failed to prune old quarantine file {:?}: {}", stale, e);
5783 }
5784 }
5785}
5786
5787const BAK_GENERATIONS: usize = 3;
5790
5791fn backup_path_for(config_path: &std::path::Path, gen: usize) -> std::path::PathBuf {
5793 if gen == 0 {
5794 config_path.with_extension("json.bak")
5795 } else {
5796 config_path.with_extension(format!("json.bak.{gen}"))
5797 }
5798}
5799
5800fn rotate_backups(config_path: &std::path::Path, generations: usize) {
5806 for gen in (1..generations).rev() {
5807 let from = backup_path_for(config_path, gen - 1);
5808 let to = backup_path_for(config_path, gen);
5809 if from.exists() {
5810 if let Err(e) = std::fs::rename(&from, &to) {
5811 tracing::warn!("Failed to rotate backup {:?} -> {:?}: {}", from, to, e);
5812 }
5813 }
5814 }
5815}
5816
5817pub(crate) fn write_atomic(path: &std::path::Path, content: &[u8]) -> std::io::Result<()> {
5818 crate::config_store::AtomicFileStore::new(path)
5819 .write_bytes_without_backup(content)
5820 .map_err(|error| match error {
5821 crate::config_store::ConfigStoreError::Io(error) => error,
5822 other => std::io::Error::other(other),
5823 })
5824}
5825
5826#[cfg(test)]
5827mod tests {
5828 use super::*;
5829 use std::ffi::OsString;
5830 use std::path::PathBuf;
5831 use std::sync::Mutex;
5832 use std::time::{SystemTime, UNIX_EPOCH};
5833
5834 #[test]
5835 fn tools_config_preserves_unknown_keys_across_roundtrip() {
5836 let input = serde_json::json!({
5837 "disabled": ["bash"],
5838 "plugin_runtime": {
5839 "timeout_ms": 5_000,
5840 "sandbox": true
5841 },
5842 "future_flag": "enabled"
5843 });
5844
5845 let config: ToolsConfig = serde_json::from_value(input.clone()).unwrap();
5846 assert_eq!(config.disabled, vec!["bash"]);
5847 assert_eq!(config.extra["plugin_runtime"]["timeout_ms"], 5_000);
5848 assert_eq!(config.extra["future_flag"], "enabled");
5849 assert_eq!(serde_json::to_value(config).unwrap(), input);
5850 }
5851
5852 #[test]
5853 fn tools_config_keeps_section_when_only_unknown_keys_present() {
5854 let input = serde_json::json!({
5855 "tool_extension": {
5856 "mode": "strict",
5857 "options": ["one", "two"]
5858 }
5859 });
5860 let config: Config = serde_json::from_value(serde_json::json!({
5861 "tools": input.clone()
5862 }))
5863 .unwrap();
5864
5865 assert!(config.tools.disabled.is_empty());
5866 assert_eq!(config.tools.extra["tool_extension"]["mode"], "strict");
5867 assert_eq!(serde_json::to_value(&config).unwrap()["tools"], input);
5868
5869 let temp_home = TempHome::new();
5870 config.save_to_dir(temp_home.path.clone()).unwrap();
5871 let persisted: Value =
5872 serde_json::from_slice(&std::fs::read(temp_home.path.join("config.json")).unwrap())
5873 .unwrap();
5874 assert_eq!(persisted["tools"], input);
5875 }
5876
5877 #[test]
5878 fn skills_config_preserves_unknown_keys_across_roundtrip() {
5879 let input = serde_json::json!({
5880 "external_catalog": {
5881 "path": "/opt/bamboo/skills",
5882 "refresh": false
5883 },
5884 "schema_version": 2
5885 });
5886
5887 let config: Config = serde_json::from_value(serde_json::json!({
5888 "skills": input.clone()
5889 }))
5890 .unwrap();
5891 assert!(config.skills.disabled.is_empty());
5892 assert_eq!(config.skills.extra["external_catalog"]["refresh"], false);
5893 assert_eq!(config.skills.extra["schema_version"], 2);
5894 assert_eq!(serde_json::to_value(&config).unwrap()["skills"], input);
5895
5896 let temp_home = TempHome::new();
5897 config.save_to_dir(temp_home.path.clone()).unwrap();
5898 let persisted: Value =
5899 serde_json::from_slice(&std::fs::read(temp_home.path.join("config.json")).unwrap())
5900 .unwrap();
5901 assert_eq!(persisted["skills"], input);
5902 }
5903
5904 #[test]
5905 fn lifecycle_hooks_round_trip_as_a_distinct_top_level_section() {
5906 let config: Config = serde_json::from_value(serde_json::json!({
5907 "hooks": {
5908 "image_fallback": {"enabled": true, "mode": "placeholder"}
5909 },
5910 "lifecycle_hooks": {
5911 "enabled": true,
5912 "PreToolUse": [{
5913 "matcher": "bash|write_file",
5914 "hooks": [{"type": "command", "command": "guard.sh"}]
5915 }],
5916 "SessionStart": [{
5917 "hooks": [{"type": "command", "command": "setup.sh", "timeout_ms": 25}]
5918 }]
5919 }
5920 }))
5921 .expect("lifecycle hook config should deserialize");
5922
5923 assert!(config.lifecycle_hooks.enabled);
5924 assert_eq!(config.lifecycle_hooks.pre_tool_use.len(), 1);
5925 assert!(
5926 config.lifecycle_hooks.pre_tool_use[0].enabled,
5927 "legacy groups without an enabled flag remain active"
5928 );
5929 assert_eq!(
5930 config.lifecycle_hooks.pre_tool_use[0].hooks[0].timeout_ms(),
5931 DEFAULT_LIFECYCLE_HOOK_TIMEOUT_MS
5932 );
5933 assert_eq!(
5934 config.lifecycle_hooks.session_start[0].hooks[0].timeout_ms(),
5935 25
5936 );
5937
5938 let json = serde_json::to_value(&config).expect("lifecycle hook config should serialize");
5939 assert_eq!(json["lifecycle_hooks"]["enabled"], true);
5940 assert_eq!(
5941 json["lifecycle_hooks"]["PreToolUse"][0]["matcher"],
5942 "bash|write_file"
5943 );
5944 assert_eq!(
5945 json["lifecycle_hooks"]["PreToolUse"][0]["hooks"][0]["type"],
5946 "command"
5947 );
5948 assert!(json["lifecycle_hooks"]["PreToolUse"][0]
5949 .get("enabled")
5950 .is_none());
5951 assert!(json["lifecycle_hooks"]["PreToolUse"][0]["hooks"][0]
5952 .get("timeout_ms")
5953 .is_none());
5954 assert!(json.get("hooks").is_some());
5955 }
5956
5957 #[test]
5958 fn script_lifecycle_hook_uses_auto_runner_and_shared_timeout_default() {
5959 let handler: LifecycleHookHandler = serde_json::from_value(serde_json::json!({
5960 "type": "script",
5961 "path": ".bamboo/hooks/check.js"
5962 }))
5963 .expect("script lifecycle hook should deserialize");
5964
5965 assert_eq!(handler.timeout_ms(), DEFAULT_LIFECYCLE_HOOK_TIMEOUT_MS);
5966 assert!(matches!(
5967 handler,
5968 LifecycleHookHandler::Script {
5969 runner: LifecycleScriptRunner::Auto,
5970 ..
5971 }
5972 ));
5973 let json = serde_json::to_value(handler).expect("script hook should serialize");
5974 assert_eq!(json["type"], "script");
5975 assert!(json.get("timeout_ms").is_none());
5976 assert!(json.get("runner").is_none());
5977 }
5978
5979 #[test]
5980 fn script_runner_support_is_extension_aware() {
5981 assert!(LifecycleScriptRunner::Auto.supports_path("guard.PS1"));
5982 assert!(LifecycleScriptRunner::Node.supports_path("guard.mjs"));
5983 assert!(LifecycleScriptRunner::Bun.supports_path("guard.cjs"));
5984 assert!(LifecycleScriptRunner::Python.supports_path("guard.py"));
5985 assert!(LifecycleScriptRunner::Bash.supports_path("guard.sh"));
5986 assert!(LifecycleScriptRunner::PowerShell.supports_path("guard.ps1"));
5987 assert!(LifecycleScriptRunner::Cmd.supports_path("guard.bat"));
5988 assert!(LifecycleScriptRunner::Cmd.supports_path("guard.cmd"));
5989 assert!(!LifecycleScriptRunner::Node.supports_path("guard.py"));
5990 assert!(!LifecycleScriptRunner::Auto.supports_path("guard.rb"));
5991 }
5992
5993 #[test]
5994 fn script_runner_names_round_trip_through_config_json() {
5995 for (runner, name) in [
5996 (LifecycleScriptRunner::Auto, "auto"),
5997 (LifecycleScriptRunner::Node, "node"),
5998 (LifecycleScriptRunner::Bun, "bun"),
5999 (LifecycleScriptRunner::Python, "python"),
6000 (LifecycleScriptRunner::Bash, "bash"),
6001 (LifecycleScriptRunner::PowerShell, "powershell"),
6002 (LifecycleScriptRunner::Cmd, "cmd"),
6003 ] {
6004 let json = serde_json::to_value(runner).unwrap();
6005 assert_eq!(json, name);
6006 assert_eq!(
6007 serde_json::from_value::<LifecycleScriptRunner>(json).unwrap(),
6008 runner
6009 );
6010 }
6011 }
6012
6013 #[test]
6014 fn absent_lifecycle_hooks_remain_disabled_and_omitted() {
6015 let config: Config = serde_json::from_value(serde_json::json!({})).unwrap();
6016 assert_eq!(config.lifecycle_hooks, LifecycleHooksConfig::default());
6017 let json = serde_json::to_value(&config).unwrap();
6018 assert!(json.get("lifecycle_hooks").is_none());
6019 }
6020
6021 #[test]
6022 fn stream_timeout_defaults_are_safe_and_back_compatible() {
6023 let root: ConfigRoot = serde_json::from_value(serde_json::json!({}))
6024 .expect("legacy config without stream_timeout should load");
6025 let values = ConfigValues::from(root);
6026
6027 assert_eq!(
6028 values.stream_timeout,
6029 StreamTimeoutConfig {
6030 transport_idle_timeout_secs: 120,
6031 first_semantic_timeout_secs: 600,
6032 semantic_idle_timeout_secs: 600,
6033 }
6034 );
6035 values
6036 .stream_timeout
6037 .validate()
6038 .expect("defaults are valid");
6039 }
6040
6041 #[test]
6042 fn llm_retry_defaults_load_from_a_config_without_the_key_and_tolerate_slow_routes() {
6043 let root: ConfigRoot = serde_json::from_value(serde_json::json!({}))
6044 .expect("legacy config without llm_retry should load");
6045 let values = ConfigValues::from(root);
6046 assert_eq!(values.llm_retry, None);
6049
6050 let defaults = LlmRetryConfig::default();
6051 assert_eq!(defaults.max_attempts, 8);
6052 assert_eq!(defaults.attempt_timeout_ms, 180_000);
6053 assert_eq!(defaults.total_budget_ms, 900_000);
6054 defaults.validate().expect("defaults are valid");
6055 }
6056
6057 #[test]
6058 fn llm_retry_rejects_out_of_range_or_inconsistent_policies() {
6059 let valid = LlmRetryConfig::default();
6060 assert!(valid.validate().is_ok());
6061 for broken in [
6062 LlmRetryConfig {
6063 max_attempts: 0,
6064 ..valid
6065 },
6066 LlmRetryConfig {
6067 max_attempts: MAX_LLM_RETRY_ATTEMPTS + 1,
6068 ..valid
6069 },
6070 LlmRetryConfig {
6071 attempt_timeout_ms: 999,
6072 ..valid
6073 },
6074 LlmRetryConfig {
6075 attempt_timeout_ms: MAX_LLM_RETRY_ATTEMPT_TIMEOUT_MS + 1,
6076 ..valid
6077 },
6078 LlmRetryConfig {
6079 total_budget_ms: 0,
6080 ..valid
6081 },
6082 LlmRetryConfig {
6083 total_budget_ms: MAX_LLM_RETRY_TOTAL_BUDGET_MS + 1,
6084 ..valid
6085 },
6086 LlmRetryConfig {
6088 base_delay_ms: 2_000,
6089 max_delay_ms: 1_000,
6090 ..valid
6091 },
6092 ] {
6093 assert!(broken.validate().is_err(), "{broken:?} must be rejected");
6094 }
6095 }
6096
6097 #[test]
6098 fn llm_retry_round_trips_through_its_section() {
6099 let policy = LlmRetryConfig {
6100 max_attempts: 12,
6101 attempt_timeout_ms: 300_000,
6102 total_budget_ms: 3_600_000,
6103 base_delay_ms: 250,
6104 max_delay_ms: 60_000,
6105 };
6106 let values = ConfigValues {
6107 llm_retry: Some(policy),
6108 ..ConfigValues::default()
6109 };
6110 let json = serde_json::to_value(&values).expect("serialize");
6111 assert_eq!(json["llm_retry"]["max_attempts"], 12);
6112 let round_tripped: ConfigValues = serde_json::from_value(json).expect("deserialize");
6113 assert_eq!(round_tripped.llm_retry, Some(policy));
6114 }
6115
6116 #[test]
6117 fn stream_timeout_round_trips_through_persistence_dto() {
6118 let values = ConfigValues {
6119 stream_timeout: StreamTimeoutConfig {
6120 transport_idle_timeout_secs: 45,
6121 first_semantic_timeout_secs: 900,
6122 semantic_idle_timeout_secs: 300,
6123 },
6124 ..ConfigValues::default()
6125 };
6126
6127 let json = serde_json::to_value(ConfigRoot::from(values)).expect("serialize config root");
6128 assert_eq!(json["stream_timeout"]["transport_idle_timeout_secs"], 45);
6129 assert_eq!(json["stream_timeout"]["first_semantic_timeout_secs"], 900);
6130 assert_eq!(json["stream_timeout"]["semantic_idle_timeout_secs"], 300);
6131
6132 let root: ConfigRoot = serde_json::from_value(json).expect("deserialize config root");
6133 assert_eq!(
6134 ConfigValues::from(root).stream_timeout,
6135 StreamTimeoutConfig {
6136 transport_idle_timeout_secs: 45,
6137 first_semantic_timeout_secs: 900,
6138 semantic_idle_timeout_secs: 300,
6139 }
6140 );
6141 }
6142
6143 #[test]
6144 fn context_management_defaults_to_summary_and_is_omitted() {
6145 let config: Config = serde_json::from_value(serde_json::json!({})).unwrap();
6146 assert_eq!(
6147 config.context_management.strategy,
6148 ContextManagementStrategy::Summary
6149 );
6150 assert_eq!(
6151 config
6152 .context_management
6153 .retrieval_window
6154 .min_recent_user_turns,
6155 3
6156 );
6157 assert_eq!(
6158 config.context_management.retrieval_target_usage_percent(),
6159 25
6160 );
6161 let json = serde_json::to_value(&config).unwrap();
6162 assert!(json.get("context_management").is_none());
6163 }
6164
6165 #[test]
6166 fn retrieval_window_context_management_round_trips() {
6167 let json = serde_json::json!({
6168 "context_management": {
6169 "strategy": "retrieval_window",
6170 "retrieval_window": {
6171 "min_recent_user_turns": 4,
6172 "trigger_usage_ratio": 0.82,
6173 "target_usage_ratio": 0.61,
6174 "history_tool_required": true,
6175 "fallback_strategy": "summary"
6176 }
6177 }
6178 });
6179 let root: ConfigRoot = serde_json::from_value(json).unwrap();
6180 let values = ConfigValues::from(root);
6181 assert_eq!(
6182 values.context_management.strategy,
6183 ContextManagementStrategy::RetrievalWindow
6184 );
6185 assert_eq!(
6186 values.context_management.retrieval_window.fallback_strategy,
6187 ContextManagementFallbackStrategy::Summary
6188 );
6189
6190 let persisted = serde_json::to_value(ConfigRoot::from(values)).unwrap();
6191 assert_eq!(
6192 persisted["context_management"]["retrieval_window"]["min_recent_user_turns"],
6193 4
6194 );
6195 assert_eq!(
6196 persisted["context_management"]["strategy"],
6197 "retrieval_window"
6198 );
6199 }
6200
6201 #[test]
6202 fn message_summary_context_management_round_trips_without_changing_defaults() {
6203 let root: ConfigRoot = serde_json::from_value(serde_json::json!({
6204 "context_management": {
6205 "strategy": "message_summary"
6206 }
6207 }))
6208 .unwrap();
6209 let values = ConfigValues::from(root);
6210 assert_eq!(
6211 values.context_management.strategy,
6212 ContextManagementStrategy::MessageSummary
6213 );
6214 assert_eq!(
6215 values.context_management.retrieval_window,
6216 RetrievalWindowContextConfig::default()
6217 );
6218
6219 let persisted = serde_json::to_value(ConfigRoot::from(values)).unwrap();
6220 assert_eq!(
6221 persisted["context_management"]["strategy"],
6222 "message_summary"
6223 );
6224 let reloaded: ConfigRoot = serde_json::from_value(persisted).unwrap();
6225 assert_eq!(
6226 ConfigValues::from(reloaded).context_management.strategy,
6227 ContextManagementStrategy::MessageSummary
6228 );
6229 assert_eq!(
6230 ContextManagementConfig::default().strategy,
6231 ContextManagementStrategy::Summary
6232 );
6233 }
6234
6235 #[test]
6236 fn context_management_uses_one_explicit_policy_for_every_strategy() {
6237 for strategy in ["summary", "message_summary", "retrieval_window"] {
6238 let policy: ContextManagementConfig = serde_json::from_value(serde_json::json!({
6239 "strategy": strategy,
6240 "trigger_usage_ratio": 0.72,
6241 "target_usage_ratio": 0.20,
6242 "retrieval_window": {"trigger_usage_ratio": 0.9, "target_usage_ratio": 0.6}
6243 }))
6244 .unwrap();
6245 let budget =
6246 policy.apply_compression_policy(bamboo_domain::TokenBudget::for_model(200_000));
6247 assert_eq!(budget.compression_trigger_percent, 72);
6248 assert_eq!(budget.compression_target_percent, 20);
6249 let saved = serde_json::to_value(&policy).unwrap();
6250 let restored: ContextManagementConfig = serde_json::from_value(saved).unwrap();
6251 assert_eq!(restored, policy);
6252 }
6253 }
6254
6255 #[test]
6256 fn context_management_migrates_stricter_legacy_ratios_without_preserving_old_sixty_percent_target(
6257 ) {
6258 for strategy in ["summary", "message_summary", "retrieval_window"] {
6259 for (trigger, target, expected_trigger, expected_target) in
6260 [(0.80, 0.60, 0.80, 0.25), (0.65, 0.15, 0.65, 0.15)]
6261 {
6262 let policy: ContextManagementConfig = serde_json::from_value(serde_json::json!({
6263 "strategy": strategy,
6264 "retrieval_window": {"trigger_usage_ratio": trigger, "target_usage_ratio": target}
6265 })).unwrap();
6266 assert_eq!(policy.trigger_usage_ratio, expected_trigger);
6267 assert_eq!(policy.target_usage_ratio, expected_target);
6268 }
6269 }
6270 }
6271
6272 #[test]
6273 fn context_management_rejects_unrepresentable_shared_range() {
6274 for (trigger, target) in [(0.80, 0.80), (1.01, 0.25), (0.015, 0.01), (0.80, 0.005)] {
6275 let value =
6276 serde_json::json!({"trigger_usage_ratio": trigger, "target_usage_ratio": target});
6277 assert!(serde_json::from_value::<ContextManagementConfig>(value).is_err());
6278 }
6279 }
6280
6281 #[test]
6282 fn retrieval_window_context_management_rejects_unsafe_policy() {
6283 for invalid in [
6284 serde_json::json!({
6285 "strategy": "retrieval_window",
6286 "retrieval_window": {"min_recent_user_turns": 0}
6287 }),
6288 serde_json::json!({
6289 "strategy": "retrieval_window",
6290 "retrieval_window": {
6291 "target_usage_ratio": 0.8,
6292 "trigger_usage_ratio": 0.8
6293 }
6294 }),
6295 serde_json::json!({
6296 "strategy": "retrieval_window",
6297 "retrieval_window": {
6298 "target_usage_ratio": 0.005,
6299 "trigger_usage_ratio": 0.006
6300 }
6301 }),
6302 serde_json::json!({
6303 "strategy": "retrieval_window",
6304 "retrieval_window": {"history_tool_required": false}
6305 }),
6306 ] {
6307 serde_json::from_value::<ContextManagementConfig>(invalid)
6308 .expect_err("unsafe retrieval-window policy must fail closed");
6309 }
6310 }
6311
6312 #[test]
6313 fn compatibility_serialization_keeps_legacy_provider_in_instance_mode() {
6314 let instance: ProviderInstanceConfig = serde_json::from_value(serde_json::json!({
6315 "provider_type": "openai",
6316 "enabled": true
6317 }))
6318 .unwrap();
6319 let mut config = Config::default();
6320 config.values.provider = "gemini".to_string();
6321 config
6322 .provider_instances
6323 .insert("work".to_string(), instance);
6324 config.default_provider_instance = Some("work".to_string());
6325 config.providers_mut().openai = Some(OpenAIConfig::default());
6326
6327 let json = serde_json::to_value(&config).unwrap();
6328 assert_eq!(json["provider"], "gemini");
6329 assert!(json["providers"]["openai"].is_object());
6330 assert_eq!(json["default_provider_instance"], "work");
6331
6332 let round_trip: Config = serde_json::from_value(json).unwrap();
6333 assert_eq!(round_trip.provider, "gemini");
6334 assert_eq!(
6335 round_trip.default_provider_instance.as_deref(),
6336 Some("work")
6337 );
6338 assert!(round_trip.provider_instances.contains_key("work"));
6339 assert!(round_trip.providers().openai.is_some());
6340 }
6341
6342 #[test]
6343 fn instance_native_durable_writes_remove_only_legacy_builtin_aliases() {
6344 let _key = crate::encryption::set_test_encryption_key([0x73; 32]);
6345 let dir = tempfile::tempdir().unwrap();
6346 let mut config = Config::default();
6347 config.values.provider = "anthropic".to_string();
6348 config.provider_instances.insert(
6349 "work".to_string(),
6350 serde_json::from_value(serde_json::json!({
6351 "provider_type": "openai",
6352 "model": "gpt-instance",
6353 "enabled": true,
6354 "future_instance_metadata": "preserved"
6355 }))
6356 .unwrap(),
6357 );
6358 config.default_provider_instance = Some("work".to_string());
6359 config.features.provider_model_ref = true;
6360 config.providers_mut().openai = Some(OpenAIConfig::default());
6361 config.providers_mut().anthropic = Some(AnthropicConfig::default());
6362 config
6363 .providers_mut()
6364 .extra
6365 .insert("provider".to_string(), serde_json::json!("anthropic"));
6366 config.providers_mut().extra.insert(
6367 "future_provider".to_string(),
6368 serde_json::json!({"kept": true}),
6369 );
6370
6371 let (root_bytes, provider_bytes) =
6372 config.prepare_provider_transaction_documents(&[]).unwrap();
6373 let root: Value = serde_json::from_slice(&root_bytes).unwrap();
6374 let providers: Value = serde_json::from_slice(&provider_bytes).unwrap();
6375 assert!(root.get("provider").is_none());
6376 assert!(root.get("providers").is_none());
6377 assert_eq!(root["default_provider_instance"], "work");
6378 assert_eq!(
6379 root["provider_instances"]["work"]["future_instance_metadata"],
6380 "preserved"
6381 );
6382 assert!(providers.get("provider").is_none());
6383 for key in ["openai", "anthropic", "gemini", "copilot", "bodhi"] {
6384 assert!(providers.get(key).is_none(), "persisted legacy alias {key}");
6385 }
6386 assert_eq!(providers["future_provider"]["kept"], true);
6387
6388 config.save_to_dir(dir.path().to_path_buf()).unwrap();
6389 let saved_root: Value =
6390 serde_json::from_slice(&std::fs::read(dir.path().join("config.json")).unwrap())
6391 .unwrap();
6392 let saved_providers: Value =
6393 serde_json::from_slice(&std::fs::read(dir.path().join("providers.json")).unwrap())
6394 .unwrap();
6395 assert!(saved_root.get("provider").is_none());
6396 assert!(saved_root.get("providers").is_none());
6397 assert_eq!(saved_root["default_provider_instance"], "work");
6398 assert!(saved_providers.get("openai").is_none());
6399 assert!(saved_providers.get("anthropic").is_none());
6400 assert!(saved_providers.get("provider").is_none());
6401 assert_eq!(saved_providers["future_provider"]["kept"], true);
6402
6403 config.providers_mut().gemini = Some(GeminiConfig::default());
6404 config.save_providers_to_dir(dir.path()).unwrap();
6405 let provider_only: Value =
6406 serde_json::from_slice(&std::fs::read(dir.path().join("providers.json")).unwrap())
6407 .unwrap();
6408 assert!(provider_only.get("gemini").is_none());
6409 assert!(provider_only.get("provider").is_none());
6410 assert_eq!(provider_only["future_provider"]["kept"], true);
6411 }
6412
6413 #[test]
6414 fn hybrid_legacy_default_preserves_its_builtin_alias_on_durable_writes() {
6415 let mut config = Config::default();
6416 config.values.provider = "openai".to_string();
6417 config.providers_mut().openai = Some(OpenAIConfig {
6418 model: Some("gpt-legacy".to_string()),
6419 ..OpenAIConfig::default()
6420 });
6421 config.provider_instances.insert(
6422 "work".to_string(),
6423 serde_json::from_value(serde_json::json!({
6424 "provider_type": "copilot",
6425 "enabled": true
6426 }))
6427 .unwrap(),
6428 );
6429 config.default_provider_instance = Some("openai".to_string());
6430
6431 let (root_bytes, provider_bytes) =
6432 config.prepare_provider_transaction_documents(&[]).unwrap();
6433 let root: Value = serde_json::from_slice(&root_bytes).unwrap();
6434 let providers: Value = serde_json::from_slice(&provider_bytes).unwrap();
6435 assert_eq!(root["provider"], "openai");
6436 assert_eq!(root["default_provider_instance"], "openai");
6437 assert!(root["provider_instances"]["work"].is_object());
6438 assert_eq!(providers["openai"]["model"], "gpt-legacy");
6439 }
6440
6441 #[test]
6442 fn persistence_keeps_legacy_provider_without_instance_default() {
6443 let values = ConfigValues {
6444 provider: "gemini".to_string(),
6445 ..ConfigValues::default()
6446 };
6447
6448 let json = serde_json::to_value(ConfigRoot::from(values)).unwrap();
6449 assert_eq!(json["provider"], "gemini");
6450 }
6451
6452 #[test]
6453 fn stream_timeout_rejects_zero_and_unbounded_values() {
6454 for (field, invalid) in [
6455 ("transport_idle_timeout_secs", 0),
6456 ("first_semantic_timeout_secs", MAX_STREAM_TIMEOUT_SECS + 1),
6457 ("semantic_idle_timeout_secs", 0),
6458 ] {
6459 let mut timeout = serde_json::json!({});
6460 timeout[field] = serde_json::json!(invalid);
6461 let error = serde_json::from_value::<StreamTimeoutConfig>(timeout)
6462 .expect_err("invalid timeout must be rejected");
6463 assert!(error.to_string().contains("stream timeout must be between"));
6464 }
6465 }
6466
6467 struct EnvVarGuard {
6468 key: &'static str,
6469 previous: Option<OsString>,
6470 }
6471
6472 impl EnvVarGuard {
6473 fn set(key: &'static str, value: &str) -> Self {
6474 let previous = std::env::var_os(key);
6475 std::env::set_var(key, value);
6476 Self { key, previous }
6477 }
6478
6479 fn unset(key: &'static str) -> Self {
6480 let previous = std::env::var_os(key);
6481 std::env::remove_var(key);
6482 Self { key, previous }
6483 }
6484 }
6485
6486 impl Drop for EnvVarGuard {
6487 fn drop(&mut self) {
6488 match &self.previous {
6489 Some(value) => std::env::set_var(self.key, value),
6490 None => std::env::remove_var(self.key),
6491 }
6492 }
6493 }
6494
6495 #[test]
6496 fn run_budget_config_merge_is_tighten_only_per_field() {
6497 let config_default = RunBudgetConfig {
6498 max_total_tokens: Some(100_000),
6499 max_tool_calls: Some(500),
6500 max_subagents: Some(10),
6501 max_rounds: Some(200),
6502 };
6503
6504 assert_eq!(
6506 config_default.merged_with_override(None),
6507 config_default,
6508 "no override falls back to the config default entirely"
6509 );
6510
6511 let tighten_one = RunBudgetConfig {
6514 max_total_tokens: Some(5_000),
6515 max_tool_calls: None,
6516 max_subagents: None,
6517 max_rounds: None,
6518 };
6519 let merged = config_default.merged_with_override(Some(&tighten_one));
6520 assert_eq!(merged.max_total_tokens, Some(5_000));
6521 assert_eq!(merged.max_tool_calls, Some(500));
6522 assert_eq!(merged.max_subagents, Some(10));
6523 assert_eq!(merged.max_rounds, Some(200));
6524
6525 let loosen_attempt = RunBudgetConfig {
6528 max_total_tokens: Some(999_999_999),
6529 max_tool_calls: Some(10_000),
6530 max_subagents: Some(1_000),
6531 max_rounds: Some(100_000),
6532 };
6533 assert_eq!(
6534 config_default.merged_with_override(Some(&loosen_attempt)),
6535 config_default,
6536 "looser per-request values must be clamped to the config ceiling"
6537 );
6538
6539 let empty_override = RunBudgetConfig::default();
6543 assert_eq!(
6544 config_default.merged_with_override(Some(&empty_override)),
6545 config_default,
6546 "an all-absent override body keeps every configured ceiling"
6547 );
6548
6549 let unlimited_default = RunBudgetConfig::default();
6553 let merged = unlimited_default.merged_with_override(Some(&tighten_one));
6554 assert_eq!(merged.max_total_tokens, Some(5_000));
6555 assert_eq!(merged.max_tool_calls, None);
6556 assert_eq!(merged.max_subagents, None);
6557 assert_eq!(merged.max_rounds, None);
6558
6559 let tighten_rounds = RunBudgetConfig {
6562 max_total_tokens: None,
6563 max_tool_calls: None,
6564 max_subagents: None,
6565 max_rounds: Some(20),
6566 };
6567 let merged = unlimited_default.merged_with_override(Some(&tighten_rounds));
6568 assert_eq!(merged.max_rounds, Some(20));
6569 let merged = config_default.merged_with_override(Some(&tighten_rounds));
6570 assert_eq!(
6571 merged.max_rounds,
6572 Some(20),
6573 "a stricter per-request round cap wins over the config default"
6574 );
6575 }
6576
6577 #[test]
6578 fn run_budget_config_json_round_trips_and_defaults_are_unlimited() {
6579 assert_eq!(RunBudgetConfig::default().max_total_tokens, None);
6580 assert_eq!(RunBudgetConfig::default().max_tool_calls, None);
6581 assert_eq!(RunBudgetConfig::default().max_subagents, None);
6582 assert_eq!(RunBudgetConfig::default().max_rounds, None);
6583
6584 let json = r#"{ "max_total_tokens": 250000, "max_subagents": 3 }"#;
6585 let cfg: RunBudgetConfig = serde_json::from_str(json).expect("deserializes");
6586 assert_eq!(cfg.max_total_tokens, Some(250_000));
6587 assert_eq!(
6588 cfg.max_tool_calls, None,
6589 "absent field defaults to unlimited"
6590 );
6591 assert_eq!(
6592 cfg.max_rounds, None,
6593 "absent round cap defaults to unlimited"
6594 );
6595
6596 assert_eq!(cfg.max_subagents, Some(3));
6597
6598 let json = r#"{ "max_rounds": 250 }"#;
6600 let cfg: RunBudgetConfig = serde_json::from_str(json).expect("deserializes");
6601 assert_eq!(cfg.max_rounds, Some(250));
6602 let round_tripped: RunBudgetConfig =
6603 serde_json::from_str(&serde_json::to_string(&cfg).unwrap()).unwrap();
6604 assert_eq!(round_tripped.max_rounds, Some(250));
6605 assert_eq!(round_tripped.max_total_tokens, None);
6606
6607 let empty = serde_json::to_string(&RunBudgetConfig::default()).unwrap();
6611 assert_eq!(empty, "{}");
6612 }
6613
6614 #[test]
6615 fn subagents_spawn_depth_preserves_default_and_explicit_zero() {
6616 let old: SubagentsConfig = serde_json::from_str("{}").unwrap();
6617 assert_eq!(old.effective_max_spawn_depth(), DEFAULT_MAX_SPAWN_DEPTH);
6618 assert!(!serde_json::to_string(&old)
6619 .unwrap()
6620 .contains("max_spawn_depth"));
6621 for depth in [0, 1, 8] {
6622 let config: SubagentsConfig =
6623 serde_json::from_value(serde_json::json!({"max_spawn_depth": depth})).unwrap();
6624 assert_eq!(config.effective_max_spawn_depth(), depth);
6625 assert_eq!(
6626 serde_json::to_value(config).unwrap()["max_spawn_depth"],
6627 depth
6628 );
6629 }
6630 for invalid in [
6631 serde_json::json!(-1),
6632 serde_json::json!(1.5),
6633 serde_json::json!("4"),
6634 ] {
6635 assert!(serde_json::from_value::<SubagentsConfig>(
6636 serde_json::json!({"max_spawn_depth": invalid}),
6637 )
6638 .is_err());
6639 }
6640 }
6641
6642 #[test]
6643 fn subagents_config_without_remote_placements_deserializes_empty() {
6644 let json = r#"{ "max_concurrent": 4 }"#;
6647 let cfg: SubagentsConfig = serde_json::from_str(json).expect("old config deserializes");
6648 assert_eq!(cfg.max_concurrent, Some(4));
6649 assert!(cfg.remote_placements.is_empty());
6650 let back = serde_json::to_string(&cfg).unwrap();
6652 assert!(
6653 !back.contains("remote_placements"),
6654 "empty vec is skipped: {back}"
6655 );
6656 }
6657
6658 #[test]
6659 fn remote_actor_placement_round_trips() {
6660 let json = r#"{
6661 "remote_placements": [
6662 {
6663 "role": "explorer",
6664 "endpoint": "wss://gpu-host:8443",
6665 "token_env": "WORKER_TOKEN",
6666 "ca_cert_file": "/etc/bamboo/worker.pem"
6667 },
6668 { "role": "writer", "endpoint": "ws://127.0.0.1:9001" }
6669 ]
6670 }"#;
6671 let cfg: SubagentsConfig = serde_json::from_str(json).expect("populated config");
6672 assert_eq!(cfg.remote_placements.len(), 2);
6673 let p0 = &cfg.remote_placements[0];
6674 assert_eq!(p0.role, "explorer");
6675 assert_eq!(p0.endpoint, "wss://gpu-host:8443");
6676 assert_eq!(p0.token_env.as_deref(), Some("WORKER_TOKEN"));
6677 assert_eq!(p0.ca_cert_file.as_deref(), Some("/etc/bamboo/worker.pem"));
6678 let p1 = &cfg.remote_placements[1];
6680 assert_eq!(p1.role, "writer");
6681 assert!(p1.token_env.is_none());
6682 assert!(p1.ca_cert_file.is_none());
6683
6684 let back = serde_json::to_string(&cfg).unwrap();
6685 let reparsed: SubagentsConfig = serde_json::from_str(&back).unwrap();
6686 assert_eq!(cfg, reparsed, "round-trip is stable");
6687 assert!(!back.contains("\"token_env\":null"));
6688 assert!(!back.contains("\"ca_cert_file\":null"));
6689 }
6690
6691 #[test]
6692 fn subagents_config_without_schedulable_placements_deserializes_empty() {
6693 let json = r#"{ "max_concurrent": 4 }"#;
6696 let cfg: SubagentsConfig = serde_json::from_str(json).expect("old config deserializes");
6697 assert!(cfg.schedulable_placements.is_empty());
6698 let back = serde_json::to_string(&cfg).unwrap();
6700 assert!(
6701 !back.contains("schedulable_placements"),
6702 "empty vec is skipped: {back}"
6703 );
6704 }
6705
6706 #[test]
6707 fn schedulable_placement_round_trips() {
6708 let json = r#"{
6709 "schedulable_placements": [
6710 {
6711 "role": "explorer",
6712 "pool": "gpu-pool",
6713 "registry_url": "https://control-plane:9562",
6714 "token_env": "WORKER_TOKEN",
6715 "ca_cert_file": "/etc/bamboo/worker.pem"
6716 },
6717 { "role": "writer", "pool": "cpu-pool", "registry_url": "http://127.0.0.1:8080" }
6718 ]
6719 }"#;
6720 let cfg: SubagentsConfig = serde_json::from_str(json).expect("populated config");
6721 assert_eq!(cfg.schedulable_placements.len(), 2);
6722 let p0 = &cfg.schedulable_placements[0];
6723 assert_eq!(p0.role, "explorer");
6724 assert_eq!(p0.pool, "gpu-pool");
6725 assert_eq!(p0.registry_url, "https://control-plane:9562");
6726 assert_eq!(p0.token_env.as_deref(), Some("WORKER_TOKEN"));
6727 assert_eq!(p0.ca_cert_file.as_deref(), Some("/etc/bamboo/worker.pem"));
6728 let p1 = &cfg.schedulable_placements[1];
6730 assert_eq!(p1.role, "writer");
6731 assert_eq!(p1.pool, "cpu-pool");
6732 assert!(p1.token_env.is_none());
6733 assert!(p1.ca_cert_file.is_none());
6734
6735 let back = serde_json::to_string(&cfg).unwrap();
6736 let reparsed: SubagentsConfig = serde_json::from_str(&back).unwrap();
6737 assert_eq!(cfg, reparsed, "round-trip is stable");
6738 assert!(!back.contains("\"token_env\":null"));
6739 assert!(!back.contains("\"ca_cert_file\":null"));
6740 }
6741
6742 #[test]
6743 fn subagents_config_without_mcp_role_allowlist_deserializes_empty() {
6744 let json = r#"{ "max_concurrent": 4 }"#;
6748 let cfg: SubagentsConfig = serde_json::from_str(json).expect("old config deserializes");
6749 assert!(cfg.mcp_role_allowlist.is_empty());
6750 let back = serde_json::to_string(&cfg).unwrap();
6752 assert!(
6753 !back.contains("mcp_role_allowlist"),
6754 "empty vec is skipped: {back}"
6755 );
6756 }
6757
6758 #[test]
6759 fn mcp_role_allowlist_entry_round_trips() {
6760 let json = r#"{
6761 "mcp_role_allowlist": [
6762 { "role": "researcher", "tools": ["fetch_url"] },
6763 { "role": "sandboxed", "tools": [] }
6764 ]
6765 }"#;
6766 let cfg: SubagentsConfig = serde_json::from_str(json).expect("populated config");
6767 assert_eq!(cfg.mcp_role_allowlist.len(), 2);
6768 assert_eq!(cfg.mcp_role_allowlist[0].role, "researcher");
6769 assert_eq!(cfg.mcp_role_allowlist[0].tools, vec!["fetch_url"]);
6770 assert_eq!(cfg.mcp_role_allowlist[1].role, "sandboxed");
6773 assert!(cfg.mcp_role_allowlist[1].tools.is_empty());
6774
6775 let back = serde_json::to_string(&cfg).unwrap();
6776 let reparsed: SubagentsConfig = serde_json::from_str(&back).unwrap();
6777 assert_eq!(cfg, reparsed, "round-trip is stable");
6778 }
6779
6780 #[test]
6781 fn server_config_without_tls_field_deserializes_back_compat() {
6782 let server: ServerConfig = serde_json::from_value(serde_json::json!({
6785 "port": 9562,
6786 "bind": "127.0.0.1"
6787 }))
6788 .expect("legacy server config without tls should deserialize");
6789
6790 assert_eq!(server.tls, None);
6791 assert_eq!(server.port, 9562);
6792 assert_eq!(server.bind, "127.0.0.1");
6793 }
6794
6795 #[test]
6796 fn server_config_omits_tls_when_none() {
6797 let server = ServerConfig::default();
6800 let value = serde_json::to_value(&server).expect("server config should serialize");
6801 let obj = value
6802 .as_object()
6803 .expect("server config serializes to object");
6804 assert!(
6805 !obj.contains_key("tls"),
6806 "tls must be omitted when None, got: {value}"
6807 );
6808 }
6809
6810 #[test]
6811 fn server_config_with_tls_roundtrips() {
6812 let server: ServerConfig = serde_json::from_value(serde_json::json!({
6813 "port": 9562,
6814 "bind": "0.0.0.0",
6815 "tls": { "cert_file": "/etc/bamboo/cert.pem", "key_file": "/etc/bamboo/key.pem" }
6816 }))
6817 .expect("server config with tls should deserialize");
6818
6819 let tls = server.tls.clone().expect("tls should be Some");
6820 assert_eq!(tls.cert_file, PathBuf::from("/etc/bamboo/cert.pem"));
6821 assert_eq!(tls.key_file, PathBuf::from("/etc/bamboo/key.pem"));
6822
6823 let value = serde_json::to_value(&server).expect("serialize");
6825 assert!(value.as_object().unwrap().contains_key("tls"));
6826 let back: ServerConfig = serde_json::from_value(value).expect("deserialize");
6827 assert_eq!(back.tls, server.tls);
6828 }
6829
6830 #[test]
6831 fn access_control_without_devices_field_deserializes_back_compat() {
6832 let access: AccessControlConfig = serde_json::from_value(serde_json::json!({
6835 "password_enabled": true,
6836 "password_hash": "deadbeef",
6837 "password_salt": "01020304",
6838 }))
6839 .expect("legacy access_control without devices should deserialize");
6840
6841 assert!(access.devices.is_empty());
6842 assert!(access.password_enabled);
6843 }
6844
6845 #[test]
6846 fn access_control_omits_devices_when_empty() {
6847 let access = AccessControlConfig {
6850 password_enabled: true,
6851 repair_required: false,
6852 password_hash: Some("deadbeef".to_string()),
6853 password_salt: Some("01020304".to_string()),
6854 password_credential_ref: None,
6855 password_configured: false,
6856 updated_at: None,
6857 devices: Vec::new(),
6858 };
6859 let value = serde_json::to_value(&access).expect("serialize");
6860 let obj = value.as_object().expect("object");
6861 assert!(
6862 !obj.contains_key("devices"),
6863 "devices must be omitted when empty, got: {value}"
6864 );
6865 }
6866
6867 #[test]
6868 fn access_control_with_devices_roundtrips() {
6869 let device = DeviceCredential {
6870 device_id: "bamboo_0123456789ab".to_string(),
6871 label: "iPhone 15".to_string(),
6872 token_hash: "abcd".to_string(),
6873 token_salt: "ef01".to_string(),
6874 token_credential_ref: None,
6875 token_configured: false,
6876 created_at: "2026-06-23T00:00:00Z".to_string(),
6877 last_used_at: None,
6878 revoked: false,
6879 };
6880 let access = AccessControlConfig {
6881 password_enabled: true,
6882 repair_required: false,
6883 password_hash: Some("deadbeef".to_string()),
6884 password_salt: Some("01020304".to_string()),
6885 password_credential_ref: None,
6886 password_configured: false,
6887 updated_at: None,
6888 devices: vec![device.clone()],
6889 };
6890 let value = serde_json::to_value(&access).expect("serialize");
6891 assert!(value.as_object().unwrap().contains_key("devices"));
6892 assert!(value["devices"][0].get("token_hash").is_none());
6893 assert!(value["devices"][0].get("token_salt").is_none());
6894 let back: AccessControlConfig = serde_json::from_value(value).expect("deserialize");
6895 assert_eq!(back.devices[0].device_id, device.device_id);
6896 assert!(back.devices[0].token_hash.is_empty());
6897 assert!(back.devices[0].token_salt.is_empty());
6898 }
6899
6900 #[test]
6901 fn reasoning_effort_for_key_resolves_instance_id() {
6902 let instance: ProviderInstanceConfig = serde_json::from_value(serde_json::json!({
6906 "provider_type": "copilot",
6907 "reasoning_effort": "high",
6908 }))
6909 .expect("instance config should deserialize");
6910
6911 let mut config = Config::create_default();
6912 config
6913 .provider_instances
6914 .insert("copilot-work".to_string(), instance);
6915
6916 assert_eq!(
6917 config.reasoning_effort_for_key("copilot-work"),
6918 Some(ReasoningEffort::High),
6919 );
6920 }
6921
6922 #[test]
6923 fn reasoning_effort_for_key_resolves_bodhi_legacy() {
6924 let mut config = Config::create_default();
6926 config.providers.bodhi = Some(
6927 serde_json::from_value(serde_json::json!({
6928 "reasoning_effort": "xhigh",
6929 }))
6930 .expect("bodhi config should deserialize"),
6931 );
6932
6933 assert_eq!(
6934 config.reasoning_effort_for_key("bodhi"),
6935 Some(ReasoningEffort::Xhigh),
6936 );
6937 }
6938
6939 #[test]
6940 fn reasoning_effort_for_key_resolves_legacy_provider_type() {
6941 let mut config = Config::create_default();
6942 config.providers.openai = Some(
6943 serde_json::from_value(serde_json::json!({
6944 "api_key": "sk-test",
6945 "reasoning_effort": "low",
6946 }))
6947 .expect("openai config should deserialize"),
6948 );
6949
6950 assert_eq!(
6951 config.reasoning_effort_for_key("openai"),
6952 Some(ReasoningEffort::Low),
6953 );
6954 }
6955
6956 #[test]
6957 fn reasoning_effort_for_key_returns_none_for_unknown_and_empty() {
6958 let config = Config::create_default();
6959 assert_eq!(config.reasoning_effort_for_key("nope"), None);
6960 assert_eq!(config.reasoning_effort_for_key(" "), None);
6961 }
6962
6963 struct TempHome {
6964 path: PathBuf,
6965 }
6966
6967 impl TempHome {
6968 fn new() -> Self {
6969 static NEXT_TEMP_HOME_ID: std::sync::atomic::AtomicU64 =
6981 std::sync::atomic::AtomicU64::new(0);
6982 let unique = NEXT_TEMP_HOME_ID.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
6983 let nanos = SystemTime::now()
6984 .duration_since(UNIX_EPOCH)
6985 .expect("clock should be after unix epoch")
6986 .as_nanos();
6987 let path = std::env::temp_dir().join(format!(
6988 "chat-core-config-test-{}-{}-{}",
6989 std::process::id(),
6990 nanos,
6991 unique
6992 ));
6993 std::fs::create_dir_all(&path).expect("failed to create temp home dir");
6994 Self { path }
6995 }
6996
6997 fn set_config_json(&self, content: &str) {
6998 std::fs::create_dir_all(&self.path).expect("failed to create config dir");
7001 std::fs::write(self.path.join("config.json"), content)
7002 .expect("failed to write config.json");
7003 }
7004 }
7005
7006 impl Drop for TempHome {
7007 fn drop(&mut self) {
7008 let _ = std::fs::remove_dir_all(&self.path);
7009 }
7010 }
7011
7012 fn env_lock() -> &'static Mutex<()> {
7016 crate::test_support::env_cache_lock()
7017 }
7018
7019 fn env_lock_acquire() -> std::sync::MutexGuard<'static, ()> {
7021 env_lock().lock().unwrap_or_else(|poisoned| {
7022 poisoned.into_inner()
7024 })
7025 }
7026
7027 #[test]
7028 fn parse_bool_env_true_values() {
7029 for value in ["1", "true", "TRUE", " yes ", "Y", "on"] {
7030 assert!(parse_bool_env(value), "value {value:?} should be true");
7031 }
7032 }
7033
7034 #[test]
7035 fn parse_bool_env_false_values() {
7036 for value in ["0", "false", "no", "off", "", " "] {
7037 assert!(!parse_bool_env(value), "value {value:?} should be false");
7038 }
7039 }
7040
7041 #[test]
7042 fn config_new_ignores_http_proxy_env_vars() {
7043 let _lock = env_lock_acquire();
7044 let temp_home = TempHome::new();
7045 temp_home.set_config_json(
7046 r#"{
7047 "http_proxy": "",
7048 "https_proxy": ""
7049}"#,
7050 );
7051
7052 let _http_proxy = EnvVarGuard::set("HTTP_PROXY", "http://env-proxy.example.com:8080");
7053 let _https_proxy = EnvVarGuard::set("HTTPS_PROXY", "http://env-proxy.example.com:8443");
7054
7055 let config = Config::from_data_dir(Some(temp_home.path.clone()));
7056
7057 assert!(
7058 config.http_proxy.is_empty(),
7059 "config should ignore HTTP_PROXY env var"
7060 );
7061 assert!(
7062 config.https_proxy.is_empty(),
7063 "config should ignore HTTPS_PROXY env var"
7064 );
7065 }
7066
7067 #[test]
7068 fn config_new_loads_config_when_proxy_fields_omitted() {
7069 let _lock = env_lock_acquire();
7070 let temp_home = TempHome::new();
7071 temp_home.set_config_json(
7072 r#"{
7073 "provider": "openai",
7074 "providers": {
7075 "openai": {
7076 "api_key": "sk-test",
7077 "model": "gpt-4o"
7078 }
7079 }
7080}"#,
7081 );
7082
7083 let _http_proxy = EnvVarGuard::unset("HTTP_PROXY");
7084 let _https_proxy = EnvVarGuard::unset("HTTPS_PROXY");
7085
7086 let config = Config::from_data_dir(Some(temp_home.path.clone()));
7087
7088 assert_eq!(
7089 config
7090 .providers
7091 .openai
7092 .as_ref()
7093 .and_then(|c| c.model.as_deref()),
7094 Some("gpt-4o"),
7095 "config should load provider model from config file even when proxy fields are omitted"
7096 );
7097 assert!(config.http_proxy.is_empty());
7098 assert!(config.https_proxy.is_empty());
7099 }
7100
7101 #[test]
7102 fn publish_env_vars_updates_prompt_safe_snapshot_without_secret_values() {
7103 let _lock = crate::test_support::env_cache_lock_acquire();
7104 let mut config = Config::default();
7105 config.env_vars.extend([
7106 EnvVarEntry {
7107 name: "SECRET_TOKEN".to_string(),
7108 value: "top-secret".to_string(),
7109 secret: true,
7110 value_encrypted: None,
7111 credential_ref: None,
7112 configured: true,
7113 description: Some("Service token".to_string()),
7114 },
7115 EnvVarEntry {
7116 name: "API_BASE".to_string(),
7117 value: "https://internal.example".to_string(),
7118 secret: false,
7119 value_encrypted: None,
7120 credential_ref: None,
7121 configured: true,
7122 description: Some("Internal API base".to_string()),
7123 },
7124 ]);
7125
7126 config.publish_env_vars();
7127
7128 let injected = Config::current_env_vars();
7129 assert_eq!(
7130 injected.get("SECRET_TOKEN").map(String::as_str),
7131 Some("top-secret")
7132 );
7133 assert_eq!(
7134 injected.get("API_BASE").map(String::as_str),
7135 Some("https://internal.example")
7136 );
7137
7138 let prompt_safe = Config::current_prompt_safe_env_vars();
7139 assert_eq!(prompt_safe.len(), 2);
7140 assert!(prompt_safe.iter().any(|entry| {
7141 entry.name == "SECRET_TOKEN"
7142 && entry.secret
7143 && entry.description.as_deref() == Some("Service token")
7144 }));
7145 assert!(prompt_safe.iter().any(|entry| {
7146 entry.name == "API_BASE"
7147 && !entry.secret
7148 && entry.description.as_deref() == Some("Internal API base")
7149 }));
7150 assert!(!prompt_safe
7151 .iter()
7152 .any(|entry| entry.name.contains("top-secret")));
7153 assert!(!prompt_safe.iter().any(|entry| {
7154 entry
7155 .description
7156 .as_deref()
7157 .is_some_and(|value| value.contains("https://internal.example"))
7158 }));
7159 }
7160
7161 #[test]
7162 fn from_data_dir_without_publish_does_not_clobber_global_cache() {
7163 let _lock = crate::test_support::env_cache_lock_acquire();
7164
7165 let mut live = Config::default();
7167 live.env_vars.extend([EnvVarEntry {
7168 name: "BAMBOO_CACHE_OWNER_40".to_string(),
7169 value: "live".to_string(),
7170 secret: false,
7171 value_encrypted: None,
7172 credential_ref: None,
7173 configured: true,
7174 description: None,
7175 }]);
7176 live.publish_env_vars();
7177 assert_eq!(
7178 Config::current_env_vars()
7179 .get("BAMBOO_CACHE_OWNER_40")
7180 .map(String::as_str),
7181 Some("live")
7182 );
7183
7184 let temp = TempHome::new();
7186 temp.set_config_json(
7187 &serde_json::json!({
7188 "env_vars": [{ "name": "BAMBOO_CACHE_OWNER_40", "value": "stale-disk" }]
7189 })
7190 .to_string(),
7191 );
7192
7193 let loaded = Config::from_data_dir_without_publish(Some(temp.path.clone()));
7196 assert_eq!(
7197 loaded
7198 .env_vars
7199 .iter()
7200 .find(|e| e.name == "BAMBOO_CACHE_OWNER_40")
7201 .map(|e| e.value.as_str()),
7202 Some("stale-disk"),
7203 "the returned Config holds the disk value"
7204 );
7205 assert_eq!(
7206 Config::current_env_vars()
7207 .get("BAMBOO_CACHE_OWNER_40")
7208 .map(String::as_str),
7209 Some("live"),
7210 "but the global cache is UNTOUCHED — no clobber (#40)"
7211 );
7212
7213 let _ = Config::from_data_dir(Some(temp.path.clone()));
7215 assert_eq!(
7216 Config::current_env_vars()
7217 .get("BAMBOO_CACHE_OWNER_40")
7218 .map(String::as_str),
7219 Some("stale-disk"),
7220 "the publishing loader clobbers the cache (contrast)"
7221 );
7222 }
7223
7224 fn dir_has_quarantine_file(dir: &std::path::Path) -> bool {
7225 std::fs::read_dir(dir)
7226 .unwrap()
7227 .filter_map(|e| e.ok())
7228 .any(|e| {
7229 e.file_name()
7230 .to_string_lossy()
7231 .contains("config.json.corrupted.")
7232 })
7233 }
7234
7235 #[test]
7236 fn corrupt_config_recovered_from_backup_and_quarantined() {
7237 let temp = TempHome::new();
7238 std::fs::write(
7240 temp.path.join("config.json.bak"),
7241 serde_json::json!({ "http_proxy": "http://from-backup" }).to_string(),
7242 )
7243 .unwrap();
7244 std::fs::write(temp.path.join("config.json"), "{ not valid json ").unwrap();
7246
7247 let config = Config::from_data_dir_without_publish(Some(temp.path.clone()));
7248 assert_eq!(
7249 config.http_proxy, "http://from-backup",
7250 "recovered from config.json.bak instead of losing all config"
7251 );
7252 assert!(
7253 dir_has_quarantine_file(&temp.path),
7254 "corrupt config.json was quarantined (preserved), not discarded"
7255 );
7256 }
7257
7258 #[test]
7259 fn corrupt_config_without_backup_quarantines_then_defaults() {
7260 let temp = TempHome::new();
7261 std::fs::write(temp.path.join("config.json"), "}}} broken").unwrap();
7262
7263 let config = Config::from_data_dir_without_publish(Some(temp.path.clone()));
7264 assert!(
7265 config.http_proxy.is_empty(),
7266 "no backup -> falls back to defaults"
7267 );
7268 assert!(
7269 dir_has_quarantine_file(&temp.path),
7270 "corrupt config.json is quarantined even when there's no backup"
7271 );
7272 }
7273
7274 #[test]
7275 fn salvage_recovers_valid_fields_from_partially_corrupt_config() {
7276 let temp = TempHome::new();
7277 temp.set_config_json(
7282 r#"{"http_proxy":"http://salvaged","env_vars":"this-should-be-an-array"}"#,
7283 );
7284
7285 let config = Config::from_data_dir_without_publish(Some(temp.path.clone()));
7286 assert_eq!(
7287 config.http_proxy, "http://salvaged",
7288 "the valid field was salvaged from a partially-corrupt config (no .bak existed)"
7289 );
7290 assert!(
7291 config.env_vars.is_empty(),
7292 "the corrupt field reset to its default instead of failing the whole load"
7293 );
7294 assert!(
7295 dir_has_quarantine_file(&temp.path),
7296 "the corrupt config.json was still quarantined for inspection"
7297 );
7298 }
7299
7300 #[test]
7301 fn salvage_preferred_over_backup_for_most_recent_intent() {
7302 let temp = TempHome::new();
7303 std::fs::write(
7305 temp.path.join("config.json.bak"),
7306 serde_json::json!({ "http_proxy": "http://old-from-backup" }).to_string(),
7307 )
7308 .unwrap();
7309 temp.set_config_json(
7311 r#"{"http_proxy":"http://new-salvaged","env_vars":"this-should-be-an-array"}"#,
7312 );
7313
7314 let config = Config::from_data_dir_without_publish(Some(temp.path.clone()));
7315 assert_eq!(
7316 config.http_proxy, "http://new-salvaged",
7317 "salvage (recent partial) is tried BEFORE the .bak fallback (older complete)"
7318 );
7319 }
7320
7321 #[test]
7322 fn salvage_merges_backup_baseline_with_corrupt_files_newer_valid_edits() {
7323 let temp = TempHome::new();
7324 std::fs::write(
7326 temp.path.join("config.json.bak"),
7327 serde_json::json!({
7328 "http_proxy": "http://old-from-backup",
7329 "https_proxy": "https://kept-from-backup",
7330 })
7331 .to_string(),
7332 )
7333 .unwrap();
7334 temp.set_config_json(
7337 r#"{"http_proxy":"http://newer-edit","env_vars":"this-should-be-an-array"}"#,
7338 );
7339
7340 let config = Config::from_data_dir_without_publish(Some(temp.path.clone()));
7341 assert_eq!(
7343 config.http_proxy, "http://newer-edit",
7344 "the corrupt file's newer valid edit is applied"
7345 );
7346 assert_eq!(
7348 config.https_proxy, "https://kept-from-backup",
7349 "the backup baseline is preserved for fields not in (or invalid in) the corrupt file"
7350 );
7351 }
7352
7353 #[test]
7354 fn salvage_preserves_legacy_inline_sidecars_from_backup_baseline() {
7355 let temp = TempHome::new();
7356 std::fs::write(
7357 temp.path.join("config.json.bak"),
7358 serde_json::json!({
7359 "providers": {
7360 "anthropic": { "model": "claude-backup" }
7361 },
7362 "memory": {
7363 "auto_dream_enabled": true
7364 },
7365 "subagents": {
7366 "claude_code_model": "claude-code-backup"
7367 }
7368 })
7369 .to_string(),
7370 )
7371 .unwrap();
7372 assert!(!temp.path.join("providers.json").exists());
7373 assert!(!temp.path.join("memory.json").exists());
7374 assert!(!temp.path.join("subagents.json").exists());
7375
7376 let (salvaged, recovered_fields) = Config::salvage_partial(
7377 r#"{"providers":"schema-invalid-provider-section"}"#,
7378 &temp.path,
7379 )
7380 .expect("object-shaped corrupt config should be salvageable");
7381
7382 assert!(
7383 recovered_fields.is_empty(),
7384 "the schema-invalid provider field must not replace the backup baseline"
7385 );
7386 assert_eq!(
7387 salvaged
7388 .providers()
7389 .anthropic
7390 .as_ref()
7391 .and_then(|provider| provider.model.as_deref()),
7392 Some("claude-backup")
7393 );
7394 assert!(
7395 salvaged
7396 .memory()
7397 .as_ref()
7398 .expect("backup memory config survives")
7399 .auto_dream_enabled
7400 );
7401 assert_eq!(
7402 salvaged.subagents().claude_code_model.as_deref(),
7403 Some("claude-code-backup")
7404 );
7405 }
7406
7407 #[test]
7408 fn unparseable_non_object_config_skips_salvage_and_uses_backup() {
7409 let temp = TempHome::new();
7410 std::fs::write(
7413 temp.path.join("config.json.bak"),
7414 serde_json::json!({ "http_proxy": "http://from-backup" }).to_string(),
7415 )
7416 .unwrap();
7417 std::fs::write(temp.path.join("config.json"), "{ not valid json ").unwrap();
7418
7419 let config = Config::from_data_dir_without_publish(Some(temp.path.clone()));
7420 assert_eq!(
7421 config.http_proxy, "http://from-backup",
7422 "garbage (non-object) config skips salvage and recovers from .bak"
7423 );
7424 }
7425
7426 #[test]
7427 fn quarantine_files_are_capped_to_newest_n() {
7428 let temp = TempHome::new();
7429 let config_path = temp.path.join("config.json");
7430 std::fs::write(&config_path, "{}").unwrap();
7431
7432 for _ in 0..(QUARANTINE_KEEP + 3) {
7435 quarantine_corrupt_config(&config_path);
7436 std::thread::sleep(std::time::Duration::from_millis(3));
7437 }
7438
7439 let count = std::fs::read_dir(&temp.path)
7440 .unwrap()
7441 .filter_map(|e| e.ok())
7442 .filter(|e| {
7443 e.file_name()
7444 .to_string_lossy()
7445 .starts_with("config.json.corrupted.")
7446 })
7447 .count();
7448 assert_eq!(
7449 count, QUARANTINE_KEEP,
7450 "old quarantine files are pruned to the newest {QUARANTINE_KEEP}"
7451 );
7452 }
7453
7454 #[test]
7455 fn load_recovers_from_older_backup_generation_when_bak_is_also_corrupt() {
7456 let temp = TempHome::new();
7457 std::fs::write(temp.path.join("config.json"), "CORRUPT-NOT-JSON").unwrap();
7459 std::fs::write(temp.path.join("config.json.bak"), "ALSO-CORRUPT").unwrap();
7460 std::fs::write(
7461 temp.path.join("config.json.bak.1"),
7462 serde_json::json!({ "http_proxy": "http://from-gen-1" }).to_string(),
7463 )
7464 .unwrap();
7465
7466 let config = Config::from_data_dir_without_publish(Some(temp.path.clone()));
7467 assert_eq!(
7468 config.http_proxy, "http://from-gen-1",
7469 "recovered from .bak.1 when both config.json and .bak are corrupt"
7470 );
7471 }
7472
7473 #[test]
7474 fn save_rotates_backup_generations() {
7475 let temp = TempHome::new();
7476 let path = temp.path.join("config.json");
7477 std::fs::write(
7479 &path,
7480 serde_json::json!({ "http_proxy": "http://proxy-v1" }).to_string(),
7481 )
7482 .unwrap();
7483
7484 let mut cfg = Config::create_default();
7485 cfg.http_proxy = "http://proxy-v2".to_string();
7487 cfg.save_to_dir(temp.path.clone()).unwrap();
7488 cfg.http_proxy = "http://proxy-v3".to_string();
7490 cfg.save_to_dir(temp.path.clone()).unwrap();
7491
7492 let bak = std::fs::read_to_string(temp.path.join("config.json.bak")).unwrap();
7493 let bak1 = std::fs::read_to_string(temp.path.join("config.json.bak.1")).unwrap();
7494 assert!(
7495 bak.contains("proxy-v2"),
7496 ".bak holds the previous generation (v2)"
7497 );
7498 assert!(
7499 bak1.contains("proxy-v1"),
7500 ".bak.1 holds the older rotated generation (v1)"
7501 );
7502 }
7503
7504 #[test]
7505 fn save_backs_up_existing_config() {
7506 let temp = TempHome::new();
7507 std::fs::write(
7509 temp.path.join("config.json"),
7510 serde_json::json!({ "http_proxy": "http://old" }).to_string(),
7511 )
7512 .unwrap();
7513
7514 let mut config = Config::create_default();
7515 config.http_proxy = "http://new".to_string();
7516 config
7517 .save_to_dir(temp.path.clone())
7518 .expect("save succeeds");
7519
7520 let backup =
7521 std::fs::read_to_string(temp.path.join("config.json.bak")).expect("config.json.bak");
7522 assert!(
7523 backup.contains("http://old"),
7524 "config.json.bak holds the PREVIOUS config (last-known-good)"
7525 );
7526 let current = std::fs::read_to_string(temp.path.join("config.json")).unwrap();
7527 assert!(
7528 current.contains("http://new"),
7529 "config.json holds the new config"
7530 );
7531 }
7532
7533 #[test]
7534 fn save_does_not_overwrite_good_backup_with_corrupt_config() {
7535 let temp = TempHome::new();
7536 std::fs::write(
7538 temp.path.join("config.json.bak"),
7539 serde_json::json!({ "http_proxy": "http://good-bak" }).to_string(),
7540 )
7541 .unwrap();
7542 std::fs::write(temp.path.join("config.json"), "{{ corrupt").unwrap();
7545
7546 let mut config = Config::create_default();
7547 config.http_proxy = "http://new".to_string();
7548 config
7549 .save_to_dir(temp.path.clone())
7550 .expect("save succeeds");
7551
7552 let backup = std::fs::read_to_string(temp.path.join("config.json.bak")).unwrap();
7554 assert!(
7555 backup.contains("http://good-bak"),
7556 "good last-known-good backup is preserved (not overwritten by corrupt config.json)"
7557 );
7558 }
7559
7560 #[test]
7563 fn recovery_status_set_from_backup_and_quarantine_preserves_corrupt_bytes() {
7564 let temp = TempHome::new();
7565 std::fs::write(
7566 temp.path.join("config.json.bak"),
7567 serde_json::json!({ "http_proxy": "http://from-backup" }).to_string(),
7568 )
7569 .unwrap();
7570 let corrupt_bytes = "{ not valid json ";
7571 std::fs::write(temp.path.join("config.json"), corrupt_bytes).unwrap();
7572
7573 let config = Config::from_data_dir_without_publish(Some(temp.path.clone()));
7574 let status = config
7575 .recovery_status()
7576 .expect("a corrupt load must set a pending recovery status");
7577 assert!(!status.confirmed, "a fresh recovery starts unconfirmed");
7578 assert_eq!(
7579 status.source,
7580 ConfigRecoverySource::Backup { generation: 0 },
7581 "recovered from generation-0 (.bak)"
7582 );
7583 let quarantine_path = status
7584 .quarantine_path
7585 .as_ref()
7586 .expect("quarantine copy should have succeeded");
7587 assert_eq!(
7588 std::fs::read_to_string(quarantine_path).unwrap(),
7589 corrupt_bytes,
7590 "the quarantine copy preserves the corrupt original BYTE FOR BYTE"
7591 );
7592 assert_eq!(
7593 std::fs::read_to_string(temp.path.join("config.json")).unwrap(),
7594 corrupt_bytes,
7595 "the original config.json itself is untouched by the load (only copied, not moved)"
7596 );
7597 }
7598
7599 #[test]
7600 fn recovery_status_set_from_salvage_lists_recovered_fields() {
7601 let temp = TempHome::new();
7602 temp.set_config_json(
7603 r#"{"http_proxy":"http://salvaged","env_vars":"this-should-be-an-array"}"#,
7604 );
7605
7606 let config = Config::from_data_dir_without_publish(Some(temp.path.clone()));
7607 let status = config.recovery_status().expect("pending recovery");
7608 assert!(!status.confirmed);
7609 match &status.source {
7610 ConfigRecoverySource::Salvaged { fields } => {
7611 assert!(
7612 fields.iter().any(|f| f == "http_proxy"),
7613 "salvaged fields should list the recovered key: {fields:?}"
7614 );
7615 }
7616 other => panic!("expected Salvaged source, got {other:?}"),
7617 }
7618 }
7619
7620 #[test]
7621 fn recovery_status_set_from_defaults_when_nothing_salvageable() {
7622 let temp = TempHome::new();
7623 std::fs::write(temp.path.join("config.json"), "}}} broken").unwrap();
7625
7626 let config = Config::from_data_dir_without_publish(Some(temp.path.clone()));
7627 let status = config.recovery_status().expect("pending recovery");
7628 assert!(!status.confirmed);
7629 assert_eq!(status.source, ConfigRecoverySource::Defaults);
7630 }
7631
7632 #[test]
7633 fn clean_load_never_sets_recovery_status() {
7634 let temp = TempHome::new();
7635 temp.set_config_json(r#"{"http_proxy":"http://clean"}"#);
7636
7637 let config = Config::from_data_dir_without_publish(Some(temp.path.clone()));
7638 assert!(
7639 config.recovery_status().is_none(),
7640 "a config.json that parses cleanly must never carry a pending recovery status"
7641 );
7642 }
7643
7644 #[test]
7645 fn save_to_dir_refuses_to_overwrite_until_recovery_confirmed() {
7646 let temp = TempHome::new();
7647 let corrupt_bytes = "}}} broken";
7648 std::fs::write(temp.path.join("config.json"), corrupt_bytes).unwrap();
7649
7650 let config = Config::from_data_dir_without_publish(Some(temp.path.clone()));
7651 assert!(config.recovery_status().is_some());
7652
7653 let err = config
7654 .save_to_dir(temp.path.clone())
7655 .expect_err("save must refuse while recovery is unconfirmed");
7656 assert!(
7657 err.to_string().contains("recovered from corruption")
7658 || err.to_string().contains("confirm"),
7659 "error should explain the refused overwrite: {err}"
7660 );
7661
7662 assert_eq!(
7665 std::fs::read_to_string(temp.path.join("config.json")).unwrap(),
7666 corrupt_bytes,
7667 "a refused save must leave the corrupt original untouched"
7668 );
7669 }
7670
7671 #[test]
7672 fn half_written_truncated_config_is_quarantined_byte_for_byte_and_blocks_overwrite() {
7673 let temp = TempHome::new();
7674 let truncated = r#"{"http_proxy":"http://partial","providers":{"anthro"#;
7676 std::fs::write(temp.path.join("config.json"), truncated).unwrap();
7677
7678 let config = Config::from_data_dir_without_publish(Some(temp.path.clone()));
7679 let status = config.recovery_status().expect("pending recovery");
7680 let quarantine_path = status.quarantine_path.as_ref().expect("quarantined");
7681 assert_eq!(
7682 std::fs::read_to_string(quarantine_path).unwrap(),
7683 truncated,
7684 "truncated original preserved byte for byte in quarantine"
7685 );
7686
7687 let err = config.save_to_dir(temp.path.clone());
7688 assert!(err.is_err(), "unconfirmed recovery must refuse to save");
7689 assert_eq!(
7690 std::fs::read_to_string(temp.path.join("config.json")).unwrap(),
7691 truncated,
7692 "the half-written original stays exactly as it was after a refused save"
7693 );
7694 }
7695
7696 #[test]
7697 fn confirm_recovery_allows_the_next_save() {
7698 let temp = TempHome::new();
7699 std::fs::write(temp.path.join("config.json"), "}}} broken").unwrap();
7700
7701 let mut config = Config::from_data_dir_without_publish(Some(temp.path.clone()));
7702 assert!(config.recovery_status().is_some());
7703
7704 config.confirm_recovery();
7705 assert!(
7706 config.recovery_status().is_some_and(|s| s.confirmed),
7707 "confirm_recovery flips the flag but keeps the status around"
7708 );
7709
7710 config
7711 .save_to_dir(temp.path.clone())
7712 .expect("save must succeed once the recovery is confirmed");
7713 }
7714
7715 #[test]
7716 fn confirm_recovery_and_save_to_dir_persists_and_clears_status() {
7717 let temp = TempHome::new();
7718 std::fs::write(
7719 temp.path.join("config.json"),
7720 r#"{"http_proxy":"http://recovered","env_vars":"bad-type"}"#,
7721 )
7722 .unwrap();
7723
7724 let mut config = Config::from_data_dir_without_publish(Some(temp.path.clone()));
7725 assert!(config.recovery_status().is_some());
7726 let quarantine_path = config
7727 .recovery_status()
7728 .unwrap()
7729 .quarantine_path
7730 .clone()
7731 .unwrap();
7732
7733 config
7734 .confirm_recovery_and_save_to_dir(temp.path.clone())
7735 .expect("confirm+save should succeed");
7736
7737 assert!(
7738 config.recovery_status().is_none(),
7739 "the pending flag is cleared once the recovery is confirmed and persisted"
7740 );
7741 let on_disk = std::fs::read_to_string(temp.path.join("config.json")).unwrap();
7742 assert!(
7743 on_disk.contains("http://recovered"),
7744 "config.json now holds the recovered (salvaged) state"
7745 );
7746 assert!(
7749 quarantine_path.exists(),
7750 "the quarantined original survives confirmation — it's never deleted"
7751 );
7752 }
7753
7754 #[test]
7755 fn confirm_recovery_and_save_to_dir_errors_when_nothing_pending() {
7756 let temp = TempHome::new();
7757 let mut config = Config::create_default();
7758 let err = config.confirm_recovery_and_save_to_dir(temp.path.clone());
7759 assert!(
7760 err.is_err(),
7761 "confirming a recovery that was never pending must error, not silently succeed"
7762 );
7763 }
7764
7765 fn connect_platform_with_encrypted(
7768 platform_type: &str,
7769 token_encrypted: &str,
7770 ) -> ConnectPlatformConfig {
7771 ConnectPlatformConfig {
7772 id: None,
7773 project_id: None,
7774 platform_type: platform_type.to_string(),
7775 token: None,
7776 token_encrypted: Some(token_encrypted.to_string()),
7777 token_credential_ref: None,
7778 token_configured: false,
7779 app_id: None,
7780 app_secret: None,
7781 app_secret_encrypted: None,
7782 app_secret_credential_ref: None,
7783 app_secret_configured: false,
7784 domain: None,
7785 allow_from: vec!["user-1".to_string()],
7786 admin_from: Vec::new(),
7787 }
7788 }
7789
7790 fn connect_json_path(temp: &TempHome) -> PathBuf {
7791 temp.path.join("connect.json")
7792 }
7793
7794 #[test]
7795 fn save_splits_connect_into_sibling_connect_json() {
7796 let _key = crate::encryption::set_test_encryption_key([0x42; 32]);
7797 let temp = TempHome::new();
7798
7799 let mut config = Config::create_default();
7800 config.connect.platforms = vec![connect_platform_with_encrypted("telegram", "")];
7801 config.connect.platforms[0].token = Some("plain-bot-token".to_string());
7802
7803 config
7804 .save_to_dir(temp.path.clone())
7805 .expect("save succeeds");
7806
7807 let config_json: serde_json::Value =
7808 serde_json::from_str(&std::fs::read_to_string(temp.path.join("config.json")).unwrap())
7809 .unwrap();
7810 assert!(
7811 config_json.get("connect").is_none(),
7812 "config.json must not carry the `connect` key after a save"
7813 );
7814
7815 let connect_json: serde_json::Value =
7816 serde_json::from_str(&std::fs::read_to_string(connect_json_path(&temp)).unwrap())
7817 .unwrap();
7818 assert_eq!(connect_json["platforms"][0]["type"], "telegram");
7819 assert!(
7820 connect_json["platforms"][0]["token_encrypted"]
7821 .as_str()
7822 .is_some_and(|v| !v.is_empty()),
7823 "the token is persisted in its encrypted form in connect.json"
7824 );
7825 assert!(
7826 connect_json["platforms"][0].get("token").is_none(),
7827 "the plaintext token is never persisted (skip_serializing)"
7828 );
7829 }
7830
7831 #[test]
7834 fn save_assigns_a_missing_connect_platform_id() {
7835 let _key = crate::encryption::set_test_encryption_key([0x42; 32]);
7836 let temp = TempHome::new();
7837
7838 let mut config = Config::create_default();
7839 config.connect.platforms = vec![connect_platform_with_encrypted("telegram", "cipher")];
7840 assert!(
7841 config.connect.platforms[0].id.is_none(),
7842 "precondition: the entry starts without an id"
7843 );
7844
7845 config
7846 .save_to_dir(temp.path.clone())
7847 .expect("save succeeds");
7848
7849 let connect_json: serde_json::Value =
7850 serde_json::from_str(&std::fs::read_to_string(connect_json_path(&temp)).unwrap())
7851 .unwrap();
7852 let persisted_id = connect_json["platforms"][0]["id"]
7853 .as_str()
7854 .expect("save_to_dir must backfill a missing id onto the persisted entry");
7855 assert!(!persisted_id.is_empty());
7856
7857 let reloaded = Config::from_data_dir_without_publish(Some(temp.path.clone()));
7858 assert_eq!(
7859 reloaded.connect.platforms[0].id.as_deref(),
7860 Some(persisted_id),
7861 "the assigned id round-trips through a reload"
7862 );
7863 }
7864
7865 #[test]
7866 fn save_never_reassigns_an_existing_connect_platform_id() {
7867 let _key = crate::encryption::set_test_encryption_key([0x42; 32]);
7868 let temp = TempHome::new();
7869
7870 let mut config = Config::create_default();
7871 let mut platform = connect_platform_with_encrypted("telegram", "cipher");
7872 platform.id = Some("stable-id-123".to_string());
7873 config.connect.platforms = vec![platform];
7874
7875 config
7876 .save_to_dir(temp.path.clone())
7877 .expect("first save succeeds");
7878 config
7880 .save_to_dir(temp.path.clone())
7881 .expect("second save succeeds");
7882
7883 let connect_json: serde_json::Value =
7884 serde_json::from_str(&std::fs::read_to_string(connect_json_path(&temp)).unwrap())
7885 .unwrap();
7886 assert_eq!(connect_json["platforms"][0]["id"], "stable-id-123");
7887 }
7888
7889 #[test]
7890 fn save_assigns_distinct_ids_to_duplicate_platform_type_entries() {
7891 let _key = crate::encryption::set_test_encryption_key([0x42; 32]);
7892 let temp = TempHome::new();
7893
7894 let mut config = Config::create_default();
7895 config.connect.platforms = vec![
7896 connect_platform_with_encrypted("telegram", "cipher-a"),
7897 connect_platform_with_encrypted("telegram", "cipher-b"),
7898 ];
7899
7900 config
7901 .save_to_dir(temp.path.clone())
7902 .expect("save succeeds");
7903
7904 let connect_json: serde_json::Value =
7905 serde_json::from_str(&std::fs::read_to_string(connect_json_path(&temp)).unwrap())
7906 .unwrap();
7907 let id_a = connect_json["platforms"][0]["id"].as_str().unwrap();
7908 let id_b = connect_json["platforms"][1]["id"].as_str().unwrap();
7909 assert_ne!(
7910 id_a, id_b,
7911 "two entries sharing platform_type must still get distinct ids"
7912 );
7913 }
7914
7915 #[test]
7916 fn load_never_assigns_or_persists_an_id_by_itself() {
7917 let temp = TempHome::new();
7918 std::fs::write(
7919 connect_json_path(&temp),
7920 serde_json::json!({
7921 "platforms": [
7922 { "type": "telegram", "token_encrypted": "cipher-abc", "allow_from": ["u1"] }
7923 ]
7924 })
7925 .to_string(),
7926 )
7927 .unwrap();
7928 let connect_json_before = std::fs::read_to_string(connect_json_path(&temp)).unwrap();
7929
7930 let config = Config::from_data_dir_without_publish(Some(temp.path.clone()));
7931
7932 assert!(
7933 config.connect.platforms[0].id.is_none(),
7934 "load alone must not backfill an id in memory"
7935 );
7936 let connect_json_after = std::fs::read_to_string(connect_json_path(&temp)).unwrap();
7937 assert_eq!(
7938 connect_json_before, connect_json_after,
7939 "load must never rewrite connect.json on disk just to backfill an id (#493)"
7940 );
7941 }
7942
7943 #[test]
7944 fn load_merges_connect_json_into_config() {
7945 let _key = crate::encryption::set_test_encryption_key([0x71; 32]);
7946 let temp = TempHome::new();
7947 let ciphertext = crate::encryption::encrypt("connect-secret").unwrap();
7948 std::fs::write(
7949 connect_json_path(&temp),
7950 serde_json::json!({
7951 "platforms": [
7952 { "type": "telegram", "token_encrypted": ciphertext, "allow_from": ["u1"] }
7953 ]
7954 })
7955 .to_string(),
7956 )
7957 .unwrap();
7958
7959 let config = Config::from_data_dir_without_publish(Some(temp.path.clone()));
7960 assert_eq!(config.connect.platforms.len(), 1);
7961 assert_eq!(config.connect.platforms[0].platform_type, "telegram");
7962 assert_eq!(
7963 config.connect.platforms[0].token.as_deref(),
7964 Some("connect-secret")
7965 );
7966 assert!(config.connect.platforms[0].token_encrypted.is_none());
7967 }
7968
7969 #[test]
7970 fn load_without_connect_json_yields_empty_inert_connect_config() {
7971 let temp = TempHome::new();
7972 temp.set_config_json(r#"{"http_proxy":"http://x"}"#);
7973
7974 let config = Config::from_data_dir_without_publish(Some(temp.path.clone()));
7975 assert!(
7976 config.connect.platforms.is_empty(),
7977 "no connect.json and no legacy key -> empty/inert connect config"
7978 );
7979 assert!(
7980 !connect_json_path(&temp).exists(),
7981 "load must not create connect.json when there is nothing to migrate"
7982 );
7983 }
7984
7985 #[test]
7986 fn migration_adopts_legacy_connect_key_and_writes_both_files() {
7987 let _key = crate::encryption::set_test_encryption_key([0x72; 32]);
7988 let temp = TempHome::new();
7989 let legacy_cipher = crate::encryption::encrypt("legacy-connect-secret").unwrap();
7990 let legacy_cipher_for_assert = legacy_cipher.clone();
7991 temp.set_config_json(
7993 &serde_json::json!({
7994 "http_proxy": "http://keep-me",
7995 "connect": {
7996 "platforms": [
7997 { "type": "telegram", "token_encrypted": legacy_cipher, "allow_from": ["u1"] }
7998 ]
7999 }
8000 })
8001 .to_string(),
8002 );
8003
8004 let config = Config::from_data_dir_without_publish(Some(temp.path.clone()));
8005
8006 assert_eq!(config.connect.platforms.len(), 1);
8008 assert_eq!(
8009 config.connect.platforms[0].token.as_deref(),
8010 Some("legacy-connect-secret")
8011 );
8012 assert_eq!(config.http_proxy, "http://keep-me");
8014
8015 assert!(
8017 connect_json_path(&temp).exists(),
8018 "migration proactively creates connect.json"
8019 );
8020 let connect_json: serde_json::Value =
8021 serde_json::from_str(&std::fs::read_to_string(connect_json_path(&temp)).unwrap())
8022 .unwrap();
8023 assert_eq!(
8024 connect_json["platforms"][0]["token_encrypted"],
8025 legacy_cipher_for_assert
8026 );
8027
8028 let config_json: serde_json::Value =
8030 serde_json::from_str(&std::fs::read_to_string(temp.path.join("config.json")).unwrap())
8031 .unwrap();
8032 assert!(
8033 config_json.get("connect").is_none(),
8034 "config.json is rewritten without the legacy `connect` key"
8035 );
8036 }
8037
8038 #[test]
8046 fn migration_write_is_narrow_and_does_not_rewrite_unrelated_secrets_or_backups() {
8047 let _key = crate::encryption::set_test_encryption_key([0x77; 32]);
8048 let temp = TempHome::new();
8049
8050 let original_api_key_encrypted =
8051 crate::encryption::encrypt("sk-unrelated-secret").expect("encrypt succeeds");
8052 temp.set_config_json(
8053 &serde_json::json!({
8054 "providers": {
8055 "openai": {
8056 "api_key_encrypted": original_api_key_encrypted,
8057 }
8058 },
8059 "connect": {
8060 "platforms": [
8061 { "type": "telegram", "token_encrypted": "legacy-cipher", "allow_from": ["u1"] }
8062 ]
8063 }
8064 })
8065 .to_string(),
8066 );
8067
8068 let config = Config::from_data_dir_without_publish(Some(temp.path.clone()));
8069 assert_eq!(config.connect.platforms.len(), 1, "legacy key adopted");
8070
8071 assert!(
8074 !temp.path.join("config.json.bak").exists(),
8075 "a read-only load migrating a legacy `connect` key must not rotate \
8076 config.json backups"
8077 );
8078
8079 let config_json: serde_json::Value =
8084 serde_json::from_str(&std::fs::read_to_string(temp.path.join("config.json")).unwrap())
8085 .unwrap();
8086 assert_eq!(
8087 config_json["providers"]["openai"]["api_key_encrypted"], original_api_key_encrypted,
8088 "an unrelated secret's ciphertext must not be touched by the connect \
8089 migration's narrow write"
8090 );
8091 assert!(
8092 config_json.get("connect").is_none(),
8093 "config.json is still rewritten without the legacy `connect` key"
8094 );
8095 }
8096
8097 #[test]
8098 fn both_files_present_connect_json_wins() {
8099 let _key = crate::encryption::set_test_encryption_key([0x73; 32]);
8100 let temp = TempHome::new();
8101 let stale = crate::encryption::encrypt("stale-secret").unwrap();
8102 let authoritative = crate::encryption::encrypt("authoritative-secret").unwrap();
8103 temp.set_config_json(
8104 &serde_json::json!({
8105 "connect": {
8106 "platforms": [
8107 { "type": "telegram", "token_encrypted": stale }
8108 ]
8109 }
8110 })
8111 .to_string(),
8112 );
8113 std::fs::write(
8114 connect_json_path(&temp),
8115 serde_json::json!({
8116 "platforms": [
8117 { "type": "telegram", "token_encrypted": authoritative }
8118 ]
8119 })
8120 .to_string(),
8121 )
8122 .unwrap();
8123
8124 let config = Config::from_data_dir_without_publish(Some(temp.path.clone()));
8125 assert_eq!(
8126 config.connect.platforms[0].token.as_deref(),
8127 Some("authoritative-secret"),
8128 "connect.json wins over a stale legacy config.json key"
8129 );
8130 }
8131
8132 #[test]
8137 fn both_files_present_strips_stale_legacy_key_from_config_json_immediately() {
8138 let _key = crate::encryption::set_test_encryption_key([0x74; 32]);
8139 let temp = TempHome::new();
8140 let stale = crate::encryption::encrypt("stale-secret").unwrap();
8141 let authoritative = crate::encryption::encrypt("authoritative-secret").unwrap();
8142 temp.set_config_json(
8143 &serde_json::json!({
8144 "http_proxy": "http://keep-me",
8145 "connect": {
8146 "platforms": [
8147 { "type": "telegram", "token_encrypted": stale }
8148 ]
8149 }
8150 })
8151 .to_string(),
8152 );
8153 std::fs::write(
8154 connect_json_path(&temp),
8155 serde_json::json!({
8156 "platforms": [
8157 { "type": "telegram", "token_encrypted": authoritative }
8158 ]
8159 })
8160 .to_string(),
8161 )
8162 .unwrap();
8163
8164 let config = Config::from_data_dir_without_publish(Some(temp.path.clone()));
8165 assert_eq!(
8166 config.connect.platforms[0].token.as_deref(),
8167 Some("authoritative-secret")
8168 );
8169 assert_eq!(config.http_proxy, "http://keep-me");
8171
8172 let config_json: serde_json::Value =
8173 serde_json::from_str(&std::fs::read_to_string(temp.path.join("config.json")).unwrap())
8174 .unwrap();
8175 assert!(
8176 config_json.get("connect").is_none(),
8177 "the stale legacy `connect` key must be stripped from config.json \
8178 immediately on load, not left for the next natural save"
8179 );
8180 }
8181
8182 #[test]
8190 fn scrub_strips_legacy_connect_from_tainted_backup_generation() {
8191 let temp = TempHome::new();
8192 temp.set_config_json(&serde_json::json!({ "http_proxy": "http://current" }).to_string());
8193 std::fs::write(
8194 temp.path.join("config.json.bak"),
8195 serde_json::json!({
8196 "http_proxy": "http://old",
8197 "connect": {
8198 "platforms": [
8199 { "type": "telegram", "token_encrypted": "legacy-bak-cipher", "allow_from": ["u1"] }
8200 ]
8201 }
8202 })
8203 .to_string(),
8204 )
8205 .unwrap();
8206
8207 let config = Config::from_data_dir_without_publish(Some(temp.path.clone()));
8208 assert!(config.connect.platforms.is_empty());
8211
8212 let bak: serde_json::Value = serde_json::from_str(
8213 &std::fs::read_to_string(temp.path.join("config.json.bak")).unwrap(),
8214 )
8215 .unwrap();
8216 assert!(
8217 bak.get("connect").is_none(),
8218 "the legacy `connect` key must be stripped from the tainted .bak generation"
8219 );
8220 assert_eq!(
8221 bak["http_proxy"], "http://old",
8222 "every other key in the .bak generation survives the scrub byte-for-byte in content"
8223 );
8224 }
8225
8226 #[test]
8231 fn scrub_reaches_all_rotated_generations() {
8232 let temp = TempHome::new();
8233 temp.set_config_json(&serde_json::json!({}).to_string());
8234 for (gen_suffix, cipher) in [
8235 ("config.json.bak", "cipher-gen0"),
8236 ("config.json.bak.1", "cipher-gen1"),
8237 ("config.json.bak.2", "cipher-gen2"),
8238 ] {
8239 std::fs::write(
8240 temp.path.join(gen_suffix),
8241 serde_json::json!({
8242 "connect": {
8243 "platforms": [
8244 { "type": "telegram", "token_encrypted": cipher }
8245 ]
8246 }
8247 })
8248 .to_string(),
8249 )
8250 .unwrap();
8251 }
8252
8253 let _config = Config::from_data_dir_without_publish(Some(temp.path.clone()));
8254
8255 for gen_suffix in ["config.json.bak", "config.json.bak.1", "config.json.bak.2"] {
8256 let value: serde_json::Value =
8257 serde_json::from_str(&std::fs::read_to_string(temp.path.join(gen_suffix)).unwrap())
8258 .unwrap();
8259 assert!(
8260 value.get("connect").is_none(),
8261 "{gen_suffix} must have its legacy `connect` key stripped"
8262 );
8263 }
8264 }
8265
8266 #[test]
8274 fn scrub_leaves_untainted_backup_byte_and_mtime_identical() {
8275 let temp = TempHome::new();
8276 temp.set_config_json(&serde_json::json!({}).to_string());
8277 let bak_path = temp.path.join("config.json.bak");
8278 std::fs::write(
8279 &bak_path,
8280 serde_json::json!({ "http_proxy": "http://clean-backup" }).to_string(),
8281 )
8282 .unwrap();
8283
8284 let before_bytes = std::fs::read(&bak_path).unwrap();
8285 let before_mtime = std::fs::metadata(&bak_path).unwrap().modified().unwrap();
8286
8287 let _config = Config::from_data_dir_without_publish(Some(temp.path.clone()));
8291
8292 let after_bytes = std::fs::read(&bak_path).unwrap();
8293 let after_mtime = std::fs::metadata(&bak_path).unwrap().modified().unwrap();
8294 assert_eq!(
8295 before_bytes, after_bytes,
8296 "a .bak generation without a legacy `connect` key must not be rewritten at all"
8297 );
8298 assert_eq!(
8299 before_mtime, after_mtime,
8300 "no write means no mtime change either"
8301 );
8302 }
8303
8304 #[test]
8309 fn scrub_skips_unparsable_backup_without_deleting_it() {
8310 let temp = TempHome::new();
8311 temp.set_config_json(&serde_json::json!({}).to_string());
8312 std::fs::write(temp.path.join("config.json.bak"), "{ not valid json").unwrap();
8313
8314 let _config = Config::from_data_dir_without_publish(Some(temp.path.clone()));
8315
8316 let content = std::fs::read_to_string(temp.path.join("config.json.bak")).unwrap();
8317 assert_eq!(
8318 content, "{ not valid json",
8319 "an unparsable .bak generation must be left byte-for-byte untouched, never deleted"
8320 );
8321 }
8322
8323 #[test]
8327 fn scrub_tolerates_missing_generations() {
8328 let temp = TempHome::new();
8329 temp.set_config_json(&serde_json::json!({}).to_string());
8330 let config = Config::from_data_dir_without_publish(Some(temp.path.clone()));
8332 assert!(config.connect.platforms.is_empty());
8333 assert!(!temp.path.join("config.json.bak").exists());
8334 }
8335
8336 #[test]
8339 fn scrub_does_not_break_backup_rotation() {
8340 let temp = TempHome::new();
8341 std::fs::write(
8342 temp.path.join("config.json"),
8343 serde_json::json!({
8344 "http_proxy": "http://proxy-v1",
8345 "connect": {
8346 "platforms": [
8347 { "type": "telegram", "token_encrypted": "legacy-cipher" }
8348 ]
8349 }
8350 })
8351 .to_string(),
8352 )
8353 .unwrap();
8354 std::fs::write(
8355 temp.path.join("config.json.bak"),
8356 serde_json::json!({
8357 "http_proxy": "http://proxy-v0",
8358 "connect": {
8359 "platforms": [
8360 { "type": "telegram", "token_encrypted": "legacy-bak-cipher" }
8361 ]
8362 }
8363 })
8364 .to_string(),
8365 )
8366 .unwrap();
8367
8368 let mut config = Config::from_data_dir_without_publish(Some(temp.path.clone()));
8370 let bak: serde_json::Value = serde_json::from_str(
8371 &std::fs::read_to_string(temp.path.join("config.json.bak")).unwrap(),
8372 )
8373 .unwrap();
8374 assert!(bak.get("connect").is_none(), ".bak scrubbed on load");
8375
8376 config.http_proxy = "http://proxy-v2".to_string();
8379 config.save_to_dir(temp.path.clone()).unwrap();
8380
8381 let new_bak = std::fs::read_to_string(temp.path.join("config.json.bak")).unwrap();
8382 assert!(
8383 new_bak.contains("proxy-v1"),
8384 ".bak reflects the pre-save (migrated, scrub-clean) state after rotation"
8385 );
8386 let new_bak1 = std::fs::read_to_string(temp.path.join("config.json.bak.1")).unwrap();
8387 assert!(
8388 new_bak1.contains("proxy-v0"),
8389 ".bak.1 holds the scrubbed older generation after rotation"
8390 );
8391 assert!(
8392 !new_bak1.contains("legacy-bak-cipher"),
8393 "the rotated-down generation stays scrubbed — rotation doesn't resurrect the \
8394 stripped secret"
8395 );
8396 }
8397
8398 #[test]
8399 fn corrupt_connect_json_yields_empty_connect_and_is_quarantined() {
8400 let temp = TempHome::new();
8401 temp.set_config_json("{}");
8402 std::fs::write(connect_json_path(&temp), "{ not valid json").unwrap();
8403
8404 let config = Config::from_data_dir_without_publish(Some(temp.path.clone()));
8405 assert!(
8406 config.connect.platforms.is_empty(),
8407 "corrupt connect.json fails SAFE to an empty/inert connect config"
8408 );
8409
8410 let backup = connect_json_path(&temp).with_extension("json.bak");
8411 assert!(
8412 backup.exists(),
8413 "the corrupt connect.json is quarantined to connect.json.bak"
8414 );
8415 assert!(
8416 std::fs::read_to_string(backup)
8417 .unwrap()
8418 .contains("not valid json"),
8419 "the quarantined copy holds the bad content"
8420 );
8421 assert!(
8426 !connect_json_path(&temp).exists(),
8427 "quarantine must MOVE the corrupt connect.json (not copy it) — no \
8428 connect.json should remain after quarantine"
8429 );
8430 }
8431
8432 #[test]
8433 fn corrupt_connect_json_does_not_fall_back_to_legacy_config_json_copy() {
8434 let temp = TempHome::new();
8435 temp.set_config_json(
8438 &serde_json::json!({
8439 "connect": {
8440 "platforms": [
8441 { "type": "telegram", "token_encrypted": "legacy-should-not-be-used" }
8442 ]
8443 }
8444 })
8445 .to_string(),
8446 );
8447 std::fs::write(connect_json_path(&temp), "{ not valid json").unwrap();
8448
8449 let config = Config::from_data_dir_without_publish(Some(temp.path.clone()));
8450 assert!(
8451 config.connect.platforms.is_empty(),
8452 "corrupt connect.json must not fall back to the legacy config.json copy"
8453 );
8454 }
8455
8456 #[test]
8457 fn empty_connect_config_with_no_existing_file_creates_no_connect_json() {
8458 let temp = TempHome::new();
8459 let config = Config::create_default();
8460 assert!(config.connect.platforms.is_empty());
8461
8462 config
8463 .save_to_dir(temp.path.clone())
8464 .expect("save succeeds");
8465
8466 assert!(
8467 !connect_json_path(&temp).exists(),
8468 "an empty connect config with no pre-existing file must not create one"
8469 );
8470 }
8471
8472 #[test]
8473 fn connect_json_backed_up_before_overwrite() {
8474 let temp = TempHome::new();
8475 std::fs::write(
8476 connect_json_path(&temp),
8477 serde_json::json!({
8478 "platforms": [
8479 { "type": "telegram", "token_encrypted": "old-cipher" }
8480 ]
8481 })
8482 .to_string(),
8483 )
8484 .unwrap();
8485
8486 let mut config = Config::create_default();
8487 config.connect.platforms = vec![connect_platform_with_encrypted("telegram", "new-cipher")];
8488 config
8489 .save_to_dir(temp.path.clone())
8490 .expect("save succeeds");
8491
8492 let backup = connect_json_path(&temp).with_extension("json.bak");
8493 assert!(
8494 std::fs::read_to_string(backup)
8495 .unwrap()
8496 .contains("old-cipher"),
8497 "the previous connect.json is preserved as connect.json.bak before the overwrite"
8498 );
8499 let current = std::fs::read_to_string(connect_json_path(&temp)).unwrap();
8500 assert!(current.contains("new-cipher"));
8501 }
8502
8503 #[test]
8506 fn save_splits_feishu_app_secret_into_connect_json_encrypted_alongside_app_id_and_domain() {
8507 let _key = crate::encryption::set_test_encryption_key([0x42; 32]);
8508 let temp = TempHome::new();
8509
8510 let mut config = Config::create_default();
8511 config.connect.platforms = vec![ConnectPlatformConfig {
8512 id: None,
8513 project_id: None,
8514 platform_type: "feishu".to_string(),
8515 token: None,
8516 token_encrypted: None,
8517 token_credential_ref: None,
8518 token_configured: false,
8519 app_id: Some("cli_real_app_id".to_string()),
8520 app_secret: Some("plain-app-secret".to_string()),
8521 app_secret_encrypted: None,
8522 app_secret_credential_ref: None,
8523 app_secret_configured: false,
8524 domain: Some("lark".to_string()),
8525 allow_from: vec!["ou_1".to_string()],
8526 admin_from: Vec::new(),
8527 }];
8528
8529 config
8530 .save_to_dir(temp.path.clone())
8531 .expect("save succeeds");
8532
8533 let connect_json: serde_json::Value =
8534 serde_json::from_str(&std::fs::read_to_string(connect_json_path(&temp)).unwrap())
8535 .unwrap();
8536 assert_eq!(connect_json["platforms"][0]["type"], "feishu");
8537 assert_eq!(connect_json["platforms"][0]["app_id"], "cli_real_app_id");
8538 assert_eq!(connect_json["platforms"][0]["domain"], "lark");
8539 assert!(
8540 connect_json["platforms"][0]["app_secret_encrypted"]
8541 .as_str()
8542 .is_some_and(|v| !v.is_empty()),
8543 "app_secret is persisted in its encrypted form in connect.json"
8544 );
8545 assert!(
8546 connect_json["platforms"][0].get("app_secret").is_none(),
8547 "the plaintext app_secret is never persisted (skip_serializing)"
8548 );
8549 }
8550
8551 #[test]
8552 fn load_hydrates_feishu_app_secret_from_encrypted() {
8553 let _key = crate::encryption::set_test_encryption_key([0x42; 32]);
8554 let temp = TempHome::new();
8555
8556 let mut config = Config::create_default();
8557 config.connect.platforms = vec![ConnectPlatformConfig {
8558 id: None,
8559 project_id: None,
8560 platform_type: "feishu".to_string(),
8561 token: None,
8562 token_encrypted: None,
8563 token_credential_ref: None,
8564 token_configured: false,
8565 app_id: Some("cli_real_app_id".to_string()),
8566 app_secret: Some("plain-app-secret".to_string()),
8567 app_secret_encrypted: None,
8568 app_secret_credential_ref: None,
8569 app_secret_configured: false,
8570 domain: Some("lark".to_string()),
8571 allow_from: vec!["ou_1".to_string()],
8572 admin_from: Vec::new(),
8573 }];
8574 config
8575 .save_to_dir(temp.path.clone())
8576 .expect("save succeeds");
8577
8578 let reloaded = Config::from_data_dir_without_publish(Some(temp.path.clone()));
8579 assert_eq!(reloaded.connect.platforms.len(), 1);
8580 assert_eq!(
8581 reloaded.connect.platforms[0].app_secret.as_deref(),
8582 Some("plain-app-secret"),
8583 "reload hydrates app_secret from app_secret_encrypted"
8584 );
8585 assert_eq!(
8586 reloaded.connect.platforms[0].app_id.as_deref(),
8587 Some("cli_real_app_id")
8588 );
8589 assert_eq!(
8590 reloaded.connect.platforms[0].domain.as_deref(),
8591 Some("lark")
8592 );
8593 }
8594
8595 #[test]
8596 fn legacy_telegram_only_connect_entry_without_feishu_fields_still_deserializes() {
8597 let _key = crate::encryption::set_test_encryption_key([0x75; 32]);
8598 let temp = TempHome::new();
8599 let ciphertext = crate::encryption::encrypt("legacy-telegram-secret").unwrap();
8600 std::fs::write(
8601 connect_json_path(&temp),
8602 serde_json::json!({
8603 "platforms": [
8604 { "type": "telegram", "token_encrypted": ciphertext, "allow_from": ["u1"] }
8605 ]
8606 })
8607 .to_string(),
8608 )
8609 .unwrap();
8610
8611 let config = Config::from_data_dir_without_publish(Some(temp.path.clone()));
8612
8613 assert_eq!(config.connect.platforms.len(), 1);
8614 assert_eq!(config.connect.platforms[0].platform_type, "telegram");
8615 assert_eq!(
8616 config.connect.platforms[0].token.as_deref(),
8617 Some("legacy-telegram-secret")
8618 );
8619 assert_eq!(
8620 config.connect.platforms[0].app_id, None,
8621 "a legacy entry with no Feishu fields deserializes them as None"
8622 );
8623 assert_eq!(config.connect.platforms[0].app_secret, None);
8624 assert_eq!(config.connect.platforms[0].app_secret_encrypted, None);
8625 assert_eq!(config.connect.platforms[0].domain, None);
8626 }
8627
8628 #[test]
8629 fn config_new_ignores_proxy_env_vars_when_proxy_fields_omitted() {
8630 let _lock = env_lock_acquire();
8631 let temp_home = TempHome::new();
8632 temp_home.set_config_json(
8633 r#"{
8634 "provider": "openai",
8635 "providers": {
8636 "openai": {
8637 "api_key": "sk-test",
8638 "model": "gpt-4o"
8639 }
8640 }
8641}"#,
8642 );
8643
8644 let _http_proxy = EnvVarGuard::set("HTTP_PROXY", "http://env-proxy.example.com:8080");
8645 let _https_proxy = EnvVarGuard::set("HTTPS_PROXY", "http://env-proxy.example.com:8443");
8646
8647 let config = Config::from_data_dir(Some(temp_home.path.clone()));
8648
8649 assert_eq!(
8650 config
8651 .providers
8652 .openai
8653 .as_ref()
8654 .and_then(|c| c.model.as_deref()),
8655 Some("gpt-4o")
8656 );
8657 assert!(
8658 config.http_proxy.is_empty(),
8659 "config should keep http_proxy empty when field is omitted"
8660 );
8661 assert!(
8662 config.https_proxy.is_empty(),
8663 "config should keep https_proxy empty when field is omitted"
8664 );
8665 }
8666
8667 #[test]
8668 fn get_memory_background_model_prefers_memory_specific_override() {
8669 let mut config = Config::default();
8670 config.features.provider_model_ref = false;
8671 config.provider = "openai".to_string();
8672 config.providers.openai = Some(OpenAIConfig {
8673 api_key: "test".to_string(),
8674 api_key_encrypted: None,
8675 credential_ref: None,
8676 base_url: None,
8677 model: Some("gpt-main".to_string()),
8678 fast_model: Some("gpt-fast".to_string()),
8679 vision_model: None,
8680 reasoning_effort: None,
8681 responses_only_models: vec![],
8682 request_overrides: None,
8683 extra: BTreeMap::new(),
8684 api_key_from_env: false,
8685 });
8686 config.memory.0 = Some(MemoryConfig {
8687 background_model: Some("memory-fast".to_string()),
8688 ..MemoryConfig::default()
8689 });
8690
8691 assert_eq!(
8692 config.get_memory_background_model().as_deref(),
8693 Some("memory-fast")
8694 );
8695 }
8696
8697 #[test]
8698 fn preserve_env_sourced_provider_keys_restores_only_dropped_env_keys() {
8699 let openai = |api_key: &str, from_env: bool| OpenAIConfig {
8704 api_key: api_key.to_string(),
8705 api_key_encrypted: None,
8706 credential_ref: None,
8707 base_url: None,
8708 model: None,
8709 fast_model: None,
8710 vision_model: None,
8711 reasoning_effort: None,
8712 responses_only_models: vec![],
8713 request_overrides: None,
8714 extra: BTreeMap::new(),
8715 api_key_from_env: from_env,
8716 };
8717
8718 let mut current = Config::default();
8720 current.providers.openai = Some(openai("sk-env", true));
8721 let mut merged = Config::default();
8722 merged.providers.openai = Some(openai("", false)); merged.preserve_env_sourced_provider_keys(¤t);
8724 let got = merged.providers.openai.as_ref().unwrap();
8725 assert_eq!(got.api_key, "sk-env", "env-sourced key restored");
8726 assert!(got.api_key_from_env, "env flag restored");
8727
8728 let mut merged = Config::default();
8730 merged.providers.openai = Some(openai("sk-explicit", false));
8731 merged.preserve_env_sourced_provider_keys(¤t);
8732 assert_eq!(
8733 merged.providers.openai.as_ref().unwrap().api_key,
8734 "sk-explicit",
8735 "explicit patch key must win"
8736 );
8737
8738 let mut current_plain = Config::default();
8740 current_plain.providers.openai = Some(openai("sk-plain", false));
8741 let mut merged = Config::default();
8742 merged.providers.openai = Some(openai("", false));
8743 merged.preserve_env_sourced_provider_keys(¤t_plain);
8744 assert!(
8745 merged.providers.openai.as_ref().unwrap().api_key.is_empty(),
8746 "non-env key must not be restored by this path"
8747 );
8748 }
8749
8750 #[test]
8751 fn refresh_preserves_ciphertext_when_plaintext_empty() {
8752 let openai = |api_key: &str, enc: Option<&str>| OpenAIConfig {
8756 api_key: api_key.to_string(),
8757 api_key_encrypted: enc.map(str::to_string),
8758 credential_ref: None,
8759 base_url: None,
8760 model: None,
8761 fast_model: None,
8762 vision_model: None,
8763 reasoning_effort: None,
8764 responses_only_models: vec![],
8765 request_overrides: None,
8766 extra: BTreeMap::new(),
8767 api_key_from_env: false,
8768 };
8769
8770 let mut config = Config::default();
8772 config.providers.openai = Some(openai("", Some("preexisting-ciphertext")));
8773 config
8774 .refresh_provider_api_keys_encrypted()
8775 .expect("refresh");
8776 assert_eq!(
8777 config
8778 .providers
8779 .openai
8780 .as_ref()
8781 .unwrap()
8782 .api_key_encrypted
8783 .as_deref(),
8784 Some("preexisting-ciphertext"),
8785 "existing ciphertext must be preserved when plaintext is empty"
8786 );
8787
8788 let mut config = Config::default();
8790 config.providers.openai = Some(openai("", None));
8791 config
8792 .refresh_provider_api_keys_encrypted()
8793 .expect("refresh");
8794 assert!(
8795 config
8796 .providers
8797 .openai
8798 .as_ref()
8799 .unwrap()
8800 .api_key_encrypted
8801 .is_none(),
8802 "no key + no ciphertext should stay None"
8803 );
8804
8805 let mut config = Config::default();
8807 config.providers.openai = Some(openai("sk-live", Some("stale-ciphertext")));
8808 config
8809 .refresh_provider_api_keys_encrypted()
8810 .expect("refresh");
8811 let enc = config
8812 .providers
8813 .openai
8814 .as_ref()
8815 .unwrap()
8816 .api_key_encrypted
8817 .clone()
8818 .expect("ciphertext present");
8819 assert!(
8820 !enc.is_empty() && enc != "stale-ciphertext",
8821 "plaintext re-encrypted"
8822 );
8823 }
8824
8825 #[test]
8826 fn refresh_encrypted_secrets_makes_instance_key_survive_serde_roundtrip() {
8827 let mut config = Config::default();
8833 let instance: ProviderInstanceConfig = serde_json::from_value(serde_json::json!({
8834 "provider_type": "openai",
8835 "api_key": "sk-instance-live",
8836 }))
8837 .expect("valid instance");
8838 config
8839 .provider_instances
8840 .insert("work".to_string(), instance);
8841
8842 config.refresh_encrypted_secrets().expect("refresh");
8843 assert!(
8844 config.provider_instances["work"]
8845 .api_key_encrypted
8846 .is_some(),
8847 "live config must hold ciphertext after refresh"
8848 );
8849
8850 let value = serde_json::to_value(&config).expect("serialize");
8852 let mut back: Config = serde_json::from_value(value).expect("deserialize");
8853 assert!(
8854 back.provider_instances["work"].api_key.is_empty(),
8855 "plaintext is skip_serializing"
8856 );
8857 back.hydrate_provider_instance_api_keys_from_encrypted();
8858 assert_eq!(
8859 back.provider_instances["work"].api_key, "sk-instance-live",
8860 "key must be recoverable from the round-tripped ciphertext"
8861 );
8862 }
8863
8864 #[test]
8865 fn get_memory_background_model_falls_back_to_provider_fast_model() {
8866 let mut config = Config::default();
8867 config.features.provider_model_ref = false;
8868 config.provider = "openai".to_string();
8869 config.providers.openai = Some(OpenAIConfig {
8870 api_key: "test".to_string(),
8871 api_key_encrypted: None,
8872 credential_ref: None,
8873 base_url: None,
8874 model: Some("gpt-main".to_string()),
8875 fast_model: Some("gpt-fast".to_string()),
8876 vision_model: None,
8877 reasoning_effort: None,
8878 responses_only_models: vec![],
8879 request_overrides: None,
8880 extra: BTreeMap::new(),
8881 api_key_from_env: false,
8882 });
8883
8884 assert_eq!(
8885 config.get_memory_background_model().as_deref(),
8886 Some("gpt-fast")
8887 );
8888 }
8889
8890 #[test]
8891 fn effective_instance_models_and_reasoning_override_stale_legacy_provider() {
8892 let mut config = Config::default();
8893 config.features.provider_model_ref = false;
8894 config.provider = "openai".to_string();
8895 config.providers.openai = Some(OpenAIConfig {
8896 api_key: "sk-stale".to_string(),
8897 model: Some("legacy-main".to_string()),
8898 fast_model: Some("legacy-fast".to_string()),
8899 vision_model: Some("legacy-vision".to_string()),
8900 reasoning_effort: Some(ReasoningEffort::Low),
8901 ..OpenAIConfig::default()
8902 });
8903 let mut instance: ProviderInstanceConfig = serde_json::from_value(serde_json::json!({
8904 "provider_type": "openai",
8905 "model": "instance-main",
8906 "fast_model": "instance-fast",
8907 "vision_model": "instance-vision",
8908 "reasoning_effort": "high",
8909 "enabled": true
8910 }))
8911 .unwrap();
8912 instance.api_key = "sk-instance".to_string();
8913 config
8914 .provider_instances
8915 .insert("work".to_string(), instance);
8916 config.default_provider_instance = Some("work".to_string());
8917
8918 assert_eq!(config.get_model().as_deref(), Some("instance-main"));
8919 assert_eq!(config.get_fast_model().as_deref(), Some("instance-fast"));
8920 assert_eq!(
8921 config.get_memory_background_model().as_deref(),
8922 Some("instance-fast")
8923 );
8924 assert_eq!(
8925 config.get_task_summary_model().as_deref(),
8926 Some("instance-fast")
8927 );
8928 assert_eq!(
8929 config.get_vision_model().as_deref(),
8930 Some("instance-vision")
8931 );
8932 assert_eq!(config.get_reasoning_effort(), Some(ReasoningEffort::High));
8933 }
8934
8935 #[test]
8936 fn chat_role_reasoning_uses_its_provider_before_the_compatibility_default() {
8937 let mut config = Config::default();
8938 config.features.provider_model_ref = true;
8939 config.provider_instances.insert(
8940 "compatibility-default".to_string(),
8941 serde_json::from_value(serde_json::json!({
8942 "provider_type": "openai",
8943 "reasoning_effort": "low",
8944 "enabled": true
8945 }))
8946 .unwrap(),
8947 );
8948 config.provider_instances.insert(
8949 "chat-provider".to_string(),
8950 serde_json::from_value(serde_json::json!({
8951 "provider_type": "anthropic",
8952 "reasoning_effort": "high",
8953 "enabled": true
8954 }))
8955 .unwrap(),
8956 );
8957 config.default_provider_instance = Some("compatibility-default".to_string());
8958 config.defaults = Some(
8959 serde_json::from_value(serde_json::json!({
8960 "chat": { "provider": "chat-provider", "model": "claude" }
8961 }))
8962 .unwrap(),
8963 );
8964
8965 assert_eq!(config.get_reasoning_effort(), Some(ReasoningEffort::High));
8966
8967 config.defaults.as_mut().unwrap().chat.reasoning_effort = Some(ReasoningEffort::Max);
8968 assert_eq!(config.get_reasoning_effort(), Some(ReasoningEffort::Max));
8969 }
8970
8971 #[test]
8972 fn runtime_env_overrides_select_and_hydrate_only_marked_instances() {
8973 let _provider =
8974 crate::test_support::override_runtime_env_var("BAMBOO_PROVIDER", Some("openai"));
8975 let _openai_key = crate::test_support::override_runtime_env_var(
8976 "BAMBOO_OPENAI_API_KEY",
8977 Some("sk-runtime-env-instance"),
8978 );
8979 let _anthropic_key =
8980 crate::test_support::override_runtime_env_var("BAMBOO_ANTHROPIC_API_KEY", None);
8981 let _gemini_key =
8982 crate::test_support::override_runtime_env_var("BAMBOO_GEMINI_API_KEY", None);
8983
8984 let mut config = Config::default();
8985 *config.providers_mut() = ProviderConfigs::default();
8986 for (id, from_environment) in [("z-unmarked", false), ("a-marked", true)] {
8987 let mut extra = serde_json::Map::new();
8988 if from_environment {
8989 extra.insert("api_key_from_env".to_string(), serde_json::json!(true));
8990 }
8991 extra.insert("provider_type".to_string(), serde_json::json!("openai"));
8992 extra.insert("enabled".to_string(), serde_json::json!(true));
8993 config.provider_instances.insert(
8994 id.to_string(),
8995 serde_json::from_value(serde_json::Value::Object(extra)).unwrap(),
8996 );
8997 }
8998
8999 config.apply_runtime_env_overrides();
9000
9001 assert_eq!(
9002 config.default_provider_instance.as_deref(),
9003 Some("a-marked"),
9004 "type override selects the lexicographically first enabled instance"
9005 );
9006 assert_eq!(
9007 config.provider_instances["a-marked"].api_key,
9008 "sk-runtime-env-instance"
9009 );
9010 assert!(config.provider_instances["z-unmarked"].api_key.is_empty());
9011 assert!(
9012 config.providers().openai.is_none(),
9013 "instance-mode env hydration must not recreate a legacy alias"
9014 );
9015
9016 config.refresh_encrypted_secrets().unwrap();
9017 let serialized = serde_json::to_string(&config).unwrap();
9018 assert!(!serialized.contains("sk-runtime-env-instance"));
9019 assert!(
9020 config.provider_instances["a-marked"]
9021 .api_key_encrypted
9022 .is_none(),
9023 "runtime env plaintext must not be encrypted into ordinary config"
9024 );
9025 }
9026
9027 #[test]
9028 fn runtime_provider_override_prefers_exact_instance_id() {
9029 let _provider =
9030 crate::test_support::override_runtime_env_var("BAMBOO_PROVIDER", Some("personal"));
9031 let _openai_key =
9032 crate::test_support::override_runtime_env_var("BAMBOO_OPENAI_API_KEY", None);
9033 let _anthropic_key =
9034 crate::test_support::override_runtime_env_var("BAMBOO_ANTHROPIC_API_KEY", None);
9035 let _gemini_key =
9036 crate::test_support::override_runtime_env_var("BAMBOO_GEMINI_API_KEY", None);
9037 let mut config = Config::default();
9038 for id in ["work", "personal"] {
9039 config.provider_instances.insert(
9040 id.to_string(),
9041 serde_json::from_value(serde_json::json!({
9042 "provider_type": "openai",
9043 "enabled": true
9044 }))
9045 .unwrap(),
9046 );
9047 }
9048
9049 config.apply_runtime_env_overrides();
9050 assert_eq!(
9051 config.default_provider_instance.as_deref(),
9052 Some("personal")
9053 );
9054 }
9055
9056 #[test]
9057 fn get_memory_background_model_falls_back_to_chat_when_fast_is_unset() {
9058 let mut config = Config::default();
9059 config.features.provider_model_ref = false;
9060 config.provider = "openai".to_string();
9061 config.providers.openai = Some(OpenAIConfig {
9062 api_key: "test".to_string(),
9063 api_key_encrypted: None,
9064 credential_ref: None,
9065 base_url: None,
9066 model: Some("gpt-main".to_string()),
9067 fast_model: None,
9068 vision_model: None,
9069 reasoning_effort: None,
9070 responses_only_models: vec![],
9071 request_overrides: None,
9072 extra: BTreeMap::new(),
9073 api_key_from_env: false,
9074 });
9075
9076 assert_eq!(
9077 config.get_memory_background_model().as_deref(),
9078 Some("gpt-main")
9079 );
9080 }
9081
9082 #[test]
9083 fn memory_config_preserves_auto_dream_dream_refine_and_prompt_flags() {
9084 let legacy = serde_json::json!({
9085 "memory": MemoryConfig {
9086 background_model: Some("dream-fast".to_string()),
9087 summary_target_ratio: 0.20,
9088 summary_safe_window_percent: 80,
9089 auto_dream_enabled: true,
9090 auto_dream_interval_secs: 900,
9091 project_prompt_injection: false,
9092 relevant_recall: false,
9093 relevant_recall_rerank: true,
9094 project_first_dream: false,
9095 ledger_agenda_injection: false,
9096 ledger_gardener_enabled: false,
9097 ledger_gardener_interval_secs: 7_200,
9098 ledger_distillation_enabled: false,
9099 dream_refine_mode: true,
9100 gardener_enabled: true,
9101 gardener_interval_secs: 3_600,
9102 gardener_volume_trigger: 40,
9103 gardener_max_splits_per_run: 4,
9104 gardener_min_sections: 7,
9105 dedup_gardener_enabled: true,
9106 dedup_gardener_min_score: 0.7,
9107 dedup_gardener_max_merges_per_run: 3,
9108 memory_active_capacity: 500,
9109 capacity_max_archivals_per_run: 10,
9110 granularity_freshness_gardener_enabled: false,
9111 }
9112 });
9113 let config: Config = serde_json::from_value(legacy).unwrap();
9114
9115 let serialized = serde_json::to_value(&config).expect("config should serialize");
9116 assert!(serialized.get("memory").is_some());
9117 let round_tripped: Config = serde_json::from_value(serialized).unwrap();
9118 assert!(round_tripped
9119 .memory()
9120 .as_ref()
9121 .is_some_and(|memory| memory.dream_refine_mode));
9122 let memory = config.memory.as_ref().expect("memory config should exist");
9123 assert!(memory.auto_dream_enabled);
9124 assert!(!memory.project_prompt_injection);
9125 assert!(!memory.relevant_recall);
9126 assert!(memory.relevant_recall_rerank);
9127 assert!(!memory.project_first_dream);
9128 assert!(memory.dream_refine_mode);
9129 assert!(memory.gardener_enabled);
9130 assert_eq!(memory.gardener_interval_secs, 3_600);
9131 assert_eq!(memory.gardener_volume_trigger, 40);
9132 assert_eq!(memory.gardener_max_splits_per_run, 4);
9133 assert_eq!(memory.gardener_min_sections, 7);
9134 assert!(memory.dedup_gardener_enabled);
9135 assert_eq!(memory.dedup_gardener_min_score, 0.7);
9136 assert_eq!(memory.dedup_gardener_max_merges_per_run, 3);
9137 assert_eq!(memory.memory_active_capacity, 500);
9138 assert_eq!(memory.capacity_max_archivals_per_run, 10);
9139 assert!(!memory.granularity_freshness_gardener_enabled);
9140 }
9141
9142 #[test]
9144 fn memory_active_capacity_defaults_off() {
9145 assert_eq!(MemoryConfig::default().memory_active_capacity, 0);
9146 assert_eq!(MemoryConfig::default().capacity_max_archivals_per_run, 50);
9147 let parsed: Config = serde_json::from_str(r#"{"memory":{}}"#).expect("parse");
9148 let memory = parsed.memory.as_ref().unwrap();
9149 assert_eq!(memory.memory_active_capacity, 0);
9150 assert_eq!(
9151 memory.capacity_max_archivals_per_run, 50,
9152 "omitted field takes the serde default fn"
9153 );
9154 }
9155
9156 #[test]
9157 fn compression_summary_budget_defaults_to_twenty_percent_and_eighty_percent_window() {
9158 let defaults = MemoryConfig::default();
9159 assert_eq!(defaults.summary_target_ratio, 0.20);
9160 assert_eq!(defaults.summary_safe_window_percent, 80);
9161
9162 let parsed: Config = serde_json::from_str(r#"{"memory":{}}"#).expect("parse");
9163 let memory = parsed.memory.as_ref().expect("memory present");
9164 assert_eq!(memory.summary_target_ratio, 0.20);
9165 assert_eq!(memory.summary_safe_window_percent, 80);
9166 }
9167
9168 #[test]
9172 fn memory_maintenance_integrators_default_on() {
9173 let defaults = MemoryConfig::default();
9174 assert!(defaults.auto_dream_enabled);
9175 assert!(defaults.gardener_enabled);
9176 assert!(defaults.dedup_gardener_enabled);
9177 assert_eq!(defaults.gardener_volume_trigger, 25);
9178
9179 let parsed: Config = serde_json::from_str(r#"{"memory":{}}"#).expect("parse");
9181 let memory = parsed.memory.as_ref().expect("memory present");
9182 assert!(
9183 memory.auto_dream_enabled,
9184 "auto_dream on when field omitted"
9185 );
9186 assert!(memory.gardener_enabled, "gardener on when field omitted");
9187 assert!(
9188 memory.dedup_gardener_enabled,
9189 "dedup gardener on when field omitted"
9190 );
9191 let opted_out: Config =
9193 serde_json::from_str(r#"{"memory":{"gardener_enabled":false}}"#).expect("parse");
9194 assert!(!opted_out.memory.as_ref().unwrap().gardener_enabled);
9195 }
9196
9197 #[test]
9198 fn memory_config_env_overrides_prompt_flags() {
9199 let _lock = env_lock_acquire();
9200 let temp_home = TempHome::new();
9201 let _home = EnvVarGuard::set("HOME", temp_home.path.to_string_lossy().as_ref());
9202 let _project_prompt = EnvVarGuard::set("BAMBOO_MEMORY_PROJECT_PROMPT_INJECTION", "false");
9203 let _relevant_recall = EnvVarGuard::set("BAMBOO_MEMORY_RELEVANT_RECALL", "0");
9204 let _relevant_recall_rerank =
9205 EnvVarGuard::set("BAMBOO_MEMORY_RELEVANT_RECALL_RERANK", "yes");
9206 let _project_first_dream = EnvVarGuard::set("BAMBOO_MEMORY_PROJECT_FIRST_DREAM", "no");
9207
9208 let config = Config::from_data_dir(Some(temp_home.path.clone()));
9209 let memory = config
9210 .memory
9211 .as_ref()
9212 .expect("memory config should be created by env overrides");
9213 assert!(!memory.project_prompt_injection);
9214 assert!(!memory.relevant_recall);
9215 assert!(memory.relevant_recall_rerank);
9216 assert!(!memory.project_first_dream);
9217 }
9218
9219 #[test]
9220 fn provider_api_keys_injected_from_env_and_never_persisted() {
9221 let _lock = env_lock_acquire();
9222 let temp_home = TempHome::new();
9223 let _home = EnvVarGuard::set("HOME", temp_home.path.to_string_lossy().as_ref());
9224 let _anthropic = crate::test_support::override_runtime_env_var(
9225 "BAMBOO_ANTHROPIC_API_KEY",
9226 Some("sk-ant-from-env"),
9227 );
9228 let _openai = crate::test_support::override_runtime_env_var(
9229 "BAMBOO_OPENAI_API_KEY",
9230 Some("sk-oai-from-env"),
9231 );
9232 let _gemini = crate::test_support::override_runtime_env_var("BAMBOO_GEMINI_API_KEY", None);
9233
9234 let config = Config::from_data_dir(Some(temp_home.path.clone()));
9237 assert_eq!(
9238 config
9239 .providers
9240 .anthropic
9241 .as_ref()
9242 .expect("anthropic created from env")
9243 .api_key,
9244 "sk-ant-from-env"
9245 );
9246 assert_eq!(
9247 config
9248 .providers
9249 .openai
9250 .as_ref()
9251 .expect("openai created from env")
9252 .api_key,
9253 "sk-oai-from-env"
9254 );
9255 assert!(config.providers.gemini.is_none());
9257
9258 config
9264 .save_to_dir(temp_home.path.clone())
9265 .expect("save config");
9266 let on_disk = std::fs::read_to_string(temp_home.path.join("config.json"))
9267 .expect("read persisted config.json");
9268 assert!(
9269 !on_disk.contains("sk-ant-from-env") && !on_disk.contains("sk-oai-from-env"),
9270 "env key must not be persisted as plaintext"
9271 );
9272 let disk_json: serde_json::Value = serde_json::from_str(&on_disk).expect("parse");
9273 assert!(
9274 disk_json["providers"]["anthropic"]
9275 .get("api_key_encrypted")
9276 .is_none(),
9277 "env-sourced anthropic key must not be re-encrypted into config.json"
9278 );
9279 assert!(
9280 disk_json["providers"]["openai"]
9281 .get("api_key_encrypted")
9282 .is_none(),
9283 "env-sourced openai key must not be re-encrypted into config.json"
9284 );
9285
9286 drop(_anthropic);
9289 drop(_openai);
9290 let reloaded = Config::from_data_dir(Some(temp_home.path.clone()));
9291 assert!(reloaded
9292 .providers
9293 .anthropic
9294 .as_ref()
9295 .map(|a| a.api_key.is_empty())
9296 .unwrap_or(true));
9297 }
9298
9299 #[test]
9300 fn get_default_work_area_path_expands_tilde_and_requires_directory() {
9301 let _lock = env_lock_acquire();
9302 let temp_home = TempHome::new();
9303 let _home = EnvVarGuard::set("HOME", temp_home.path.to_string_lossy().as_ref());
9304 let target = temp_home.path.join("workspace-default");
9305 std::fs::create_dir_all(&target).expect("default work area dir should exist");
9306
9307 let mut config = Config::default();
9308 config.default_work_area.replace(DefaultWorkAreaConfig {
9309 path: Some("~/workspace-default".to_string()),
9310 });
9311
9312 assert_eq!(config.get_default_work_area_path(), Some(target));
9313 }
9314
9315 #[test]
9316 fn get_default_work_area_path_returns_none_for_missing_directory() {
9317 let _lock = env_lock_acquire();
9318 let temp_home = TempHome::new();
9319 let _home = EnvVarGuard::set("HOME", temp_home.path.to_string_lossy().as_ref());
9320
9321 let mut config = Config::default();
9322 config.default_work_area.replace(DefaultWorkAreaConfig {
9323 path: Some("~/missing-default-work-area".to_string()),
9324 });
9325
9326 assert!(config.get_default_work_area_path().is_none());
9327 }
9328
9329 #[test]
9330 fn normalize_tool_settings_trims_dedupes_and_sorts_raw_references() {
9331 let mut config = Config::default();
9332 config.tools.disabled = vec![
9333 " read_file ".to_string(),
9334 "".to_string(),
9335 "read_file".to_string(),
9336 "bash".to_string(),
9337 "default::getCurrentDir".to_string(),
9338 "default::applyPatch".to_string(),
9339 "default::custom_tool".to_string(),
9340 "mcp__alpha__inspect".to_string(),
9341 ];
9342
9343 config.normalize_tool_settings();
9344
9345 assert_eq!(
9346 config.tools.disabled,
9347 vec![
9348 "bash",
9349 "default::applyPatch",
9350 "default::custom_tool",
9351 "default::getCurrentDir",
9352 "mcp__alpha__inspect",
9353 "read_file"
9354 ]
9355 );
9356 }
9357
9358 #[test]
9359 fn config_load_preserves_disabled_references_for_catalog_resolution() {
9360 let _lock = env_lock_acquire();
9361 let temp_home = TempHome::new();
9362 temp_home.set_config_json(
9363 r#"{
9364 "tools": {
9365 "disabled": ["bash", " read_file ", "bash", "default::getCurrentDir"]
9366 }
9367}"#,
9368 );
9369
9370 let config = Config::from_data_dir(Some(temp_home.path.clone()));
9371 assert_eq!(
9372 config.tools.disabled,
9373 vec!["bash", "default::getCurrentDir", "read_file"]
9374 );
9375 assert!(config.disabled_tool_references().contains("bash"));
9376 assert!(config.disabled_tool_references().contains("read_file"));
9377 assert!(config
9378 .disabled_tool_references()
9379 .contains("default::getCurrentDir"));
9380 assert_eq!(
9381 config.disabled_tool_names(),
9382 BTreeSet::from([
9383 "Bash".to_string(),
9384 "GetCurrentDir".to_string(),
9385 "Read".to_string()
9386 ])
9387 );
9388 }
9389
9390 #[test]
9391 fn normalize_skill_settings_trims_dedupes_and_sorts() {
9392 let mut config = Config::default();
9393 config.skills.disabled = vec![
9394 " pdf ".to_string(),
9395 "".to_string(),
9396 "pdf".to_string(),
9397 "skill-creator".to_string(),
9398 ];
9399
9400 config.normalize_skill_settings();
9401
9402 assert_eq!(
9403 config.skills.disabled,
9404 vec!["pdf".to_string(), "skill-creator".to_string()]
9405 );
9406 }
9407
9408 #[test]
9409 fn config_load_reads_disabled_skills_as_normalized_ids() {
9410 let _lock = env_lock_acquire();
9411 let temp_home = TempHome::new();
9412 temp_home.set_config_json(
9413 r#"{
9414 "skills": {
9415 "disabled": [" pdf ", "skill-creator", "pdf", ""]
9416 }
9417}"#,
9418 );
9419
9420 let config = Config::from_data_dir(Some(temp_home.path.clone()));
9421 assert_eq!(
9422 config.skills.disabled,
9423 vec!["pdf".to_string(), "skill-creator".to_string()]
9424 );
9425 assert!(config.disabled_skill_ids().contains("pdf"));
9426 assert!(config.disabled_skill_ids().contains("skill-creator"));
9427 }
9428
9429 #[test]
9430 fn test_server_config_defaults() {
9431 let _lock = env_lock_acquire();
9432 let temp_home = TempHome::new();
9433
9434 let config = Config::from_data_dir(Some(temp_home.path.clone()));
9435 assert_eq!(config.server.port, 9562);
9436 assert_eq!(config.server.bind, "127.0.0.1");
9437 assert_eq!(config.server.workers, 10);
9438 assert!(config.server.static_dir.is_none());
9439 }
9440
9441 #[test]
9442 fn test_server_addr() {
9443 let mut config = Config::default();
9444 config.server.port = 9000;
9445 config.server.bind = "0.0.0.0".to_string();
9446 assert_eq!(config.server_addr(), "0.0.0.0:9000");
9447 }
9448
9449 #[test]
9450 fn test_env_var_overrides() {
9451 let _lock = env_lock_acquire();
9452 let temp_home = TempHome::new();
9453
9454 let _port = EnvVarGuard::set("BAMBOO_PORT", "9999");
9455 let _bind = EnvVarGuard::set("BAMBOO_BIND", "192.168.1.1");
9456 let _provider =
9457 crate::test_support::override_runtime_env_var("BAMBOO_PROVIDER", Some("openai"));
9458
9459 let config = Config::from_data_dir(Some(temp_home.path.clone()));
9460 assert_eq!(config.server.port, 9999);
9461 assert_eq!(config.server.bind, "192.168.1.1");
9462 assert_eq!(config.provider, "openai");
9463 }
9464
9465 #[test]
9466 fn test_config_save_and_load() {
9467 let _lock = env_lock_acquire();
9468 let temp_home = TempHome::new();
9469
9470 let mut config = Config::from_data_dir(Some(temp_home.path.clone()));
9471 config.server.port = 9000;
9472 config.server.bind = "0.0.0.0".to_string();
9473 config.provider = "anthropic".to_string();
9474
9475 config
9477 .save_to_dir(temp_home.path.clone())
9478 .expect("Failed to save config");
9479
9480 let loaded = Config::from_data_dir(Some(temp_home.path.clone()));
9482
9483 assert_eq!(loaded.server.port, 9000);
9485 assert_eq!(loaded.server.bind, "0.0.0.0");
9486 assert_eq!(loaded.provider, "anthropic");
9487 }
9488
9489 #[test]
9490 fn modular_root_and_public_serde_preserve_legacy_shape() {
9491 let _lock = env_lock_acquire();
9492 let temp_home = TempHome::new();
9493 let input = serde_json::json!({
9494 "http_proxy": "http://proxy.example",
9495 "provider": "openai",
9496 "mcp": { "servers": [] },
9497 "future_extension": { "enabled": true },
9498 "memory": { "background_model": "memory-model" },
9499 "subagents": { "max_concurrent": 7 },
9500 "providers": { "openai": { "model": "chat-model" } }
9501 });
9502
9503 let config: Config = serde_json::from_value(input).unwrap();
9504 let public = serde_json::to_value(&config).unwrap();
9505 assert_eq!(public["http_proxy"], "http://proxy.example");
9506 assert!(public.get("mcp").is_none());
9507 assert!(public.get("mcpServers").is_some());
9508 assert_eq!(public["future_extension"]["enabled"], true);
9509 assert_eq!(public["memory"]["background_model"], "memory-model");
9510 assert_eq!(public["subagents"]["max_concurrent"], 7);
9511 assert_eq!(public["providers"]["openai"]["model"], "chat-model");
9512
9513 let round_tripped: Config = serde_json::from_value(public).unwrap();
9514 assert_eq!(
9515 round_tripped
9516 .memory()
9517 .as_ref()
9518 .unwrap()
9519 .background_model
9520 .as_deref(),
9521 Some("memory-model")
9522 );
9523 assert_eq!(round_tripped.subagents().max_concurrent, Some(7));
9524 assert_eq!(
9525 round_tripped
9526 .providers()
9527 .openai
9528 .as_ref()
9529 .unwrap()
9530 .model
9531 .as_deref(),
9532 Some("chat-model")
9533 );
9534 assert_eq!(round_tripped.extra["future_extension"]["enabled"], true);
9535
9536 round_tripped.save_to_dir(temp_home.path.clone()).unwrap();
9537 let persisted: Value =
9538 serde_json::from_slice(&std::fs::read(temp_home.path.join("config.json")).unwrap())
9539 .unwrap();
9540 assert_eq!(persisted["http_proxy"], "http://proxy.example");
9541 assert!(persisted.get("mcpServers").is_some());
9542 assert_eq!(persisted["future_extension"]["enabled"], true);
9543 assert!(persisted.get("memory").is_none());
9544 assert!(persisted.get("subagents").is_none());
9545 assert!(persisted.get("providers").is_none());
9546 for internal_section_name in [
9547 "network",
9548 "provider_routing",
9549 "model_behavior",
9550 "tooling",
9551 "workspace",
9552 "execution",
9553 "integrations",
9554 "plugin_security",
9555 ] {
9556 assert!(persisted.get(internal_section_name).is_none());
9557 }
9558 }
9559
9560 #[test]
9561 fn config_decrypts_proxy_auth_from_encrypted_field() {
9562 let _lock = env_lock_acquire();
9563 let temp_home = TempHome::new();
9564
9565 let key_guard = crate::encryption::set_test_encryption_key([
9567 0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0a, 0x0b, 0x0c, 0x0d,
9568 0x0e, 0x0f, 0x10, 0x11, 0x12, 0x13, 0x14, 0x15, 0x16, 0x17, 0x18, 0x19, 0x1a, 0x1b,
9569 0x1c, 0x1d, 0x1e, 0x1f,
9570 ]);
9571
9572 let auth = ProxyAuth {
9573 username: "user".to_string(),
9574 password: "pass".to_string(),
9575 };
9576 let auth_str = serde_json::to_string(&auth).expect("serialize proxy auth");
9577 let encrypted = crate::encryption::encrypt(&auth_str).expect("encrypt proxy auth");
9578
9579 temp_home.set_config_json(&format!(
9580 r#"{{
9581 "http_proxy": "http://proxy.example.com:8080",
9582 "proxy_auth_encrypted": "{encrypted}"
9583}}"#
9584 ));
9585 let config = Config::from_data_dir(Some(temp_home.path.clone()));
9586 let loaded_auth = config
9587 .proxy_auth
9588 .as_ref()
9589 .expect("proxy auth should be hydrated");
9590 assert_eq!(loaded_auth.username, "user");
9591 assert_eq!(loaded_auth.password, "pass");
9592 drop(key_guard);
9593 }
9594
9595 #[test]
9596 fn config_decrypts_proxy_auth_from_legacy_scheme_encrypted_fields() {
9597 let _lock = env_lock_acquire();
9598 let temp_home = TempHome::new();
9599
9600 let key_guard = crate::encryption::set_test_encryption_key([
9602 0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0a, 0x0b, 0x0c, 0x0d,
9603 0x0e, 0x0f, 0x10, 0x11, 0x12, 0x13, 0x14, 0x15, 0x16, 0x17, 0x18, 0x19, 0x1a, 0x1b,
9604 0x1c, 0x1d, 0x1e, 0x1f,
9605 ]);
9606
9607 let auth = ProxyAuth {
9608 username: "user".to_string(),
9609 password: "pass".to_string(),
9610 };
9611 let auth_str = serde_json::to_string(&auth).expect("serialize proxy auth");
9612 let encrypted = crate::encryption::encrypt(&auth_str).expect("encrypt proxy auth");
9613
9614 temp_home.set_config_json(&format!(
9616 r#"{{
9617 "http_proxy": "http://proxy.example.com:8080",
9618 "http_proxy_auth_encrypted": "{encrypted}",
9619 "https_proxy_auth_encrypted": "{encrypted}"
9620}}"#
9621 ));
9622
9623 let config = Config::from_data_dir(Some(temp_home.path.clone()));
9624 let loaded_auth = config
9625 .proxy_auth
9626 .as_ref()
9627 .expect("proxy auth should be hydrated");
9628 assert_eq!(loaded_auth.username, "user");
9629 assert_eq!(loaded_auth.password, "pass");
9630 drop(key_guard);
9631 }
9632
9633 #[test]
9634 fn config_save_refuses_unisolated_proxy_auth_without_writing_ciphertext() {
9635 let _lock = env_lock_acquire();
9636 let temp_home = TempHome::new();
9637
9638 let key_guard = crate::encryption::set_test_encryption_key([
9640 0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0a, 0x0b, 0x0c, 0x0d,
9641 0x0e, 0x0f, 0x10, 0x11, 0x12, 0x13, 0x14, 0x15, 0x16, 0x17, 0x18, 0x19, 0x1a, 0x1b,
9642 0x1c, 0x1d, 0x1e, 0x1f,
9643 ]);
9644
9645 let mut config = Config::from_data_dir(Some(temp_home.path.clone()));
9646 config.proxy_auth = Some(ProxyAuth {
9647 username: "user".to_string(),
9648 password: "pass".to_string(),
9649 });
9650 let error = config.save_to_dir(temp_home.path.clone()).unwrap_err();
9651 assert!(error
9652 .to_string()
9653 .contains("isolated credential transaction"));
9654 let path = temp_home.path.join("config.json");
9655 assert!(
9656 !path.exists(),
9657 "a rejected unisolated secret must not create config.json"
9658 );
9659 drop(key_guard);
9660 }
9661
9662 #[test]
9663 fn config_save_refuses_configured_secret_env_without_ref_before_any_write() {
9664 let _lock = env_lock_acquire();
9665 let temp_home = TempHome::new();
9666 let mut config = Config::default();
9667 config.env_vars.push(EnvVarEntry {
9668 name: "TOKEN".to_string(),
9669 value: String::new(),
9670 secret: true,
9671 value_encrypted: None,
9672 credential_ref: None,
9673 configured: true,
9674 description: None,
9675 });
9676 let path = temp_home.path.join("config.json");
9677
9678 let error = config.save_to_dir(temp_home.path.clone()).unwrap_err();
9679 assert!(error
9680 .to_string()
9681 .contains("isolated credential transaction"));
9682 assert!(
9683 !path.exists(),
9684 "a rejected dangling ref must not create config.json"
9685 );
9686
9687 let original = br#"{"preserve":"original"}"#;
9688 std::fs::write(&path, original).unwrap();
9689 config.save_to_dir(temp_home.path.clone()).unwrap_err();
9690 assert_eq!(
9691 std::fs::read(&path).unwrap(),
9692 original,
9693 "a rejected dangling ref must not modify an existing config.json"
9694 );
9695 }
9696
9697 #[test]
9698 fn config_save_persists_provider_reference_and_isolates_plaintext() {
9699 let _lock = env_lock_acquire();
9700 let temp_home = TempHome::new();
9701
9702 let key_guard = crate::encryption::set_test_encryption_key([
9704 0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0a, 0x0b, 0x0c, 0x0d,
9705 0x0e, 0x0f, 0x10, 0x11, 0x12, 0x13, 0x14, 0x15, 0x16, 0x17, 0x18, 0x19, 0x1a, 0x1b,
9706 0x1c, 0x1d, 0x1e, 0x1f,
9707 ]);
9708
9709 let reference = crate::credential_ref("provider", "openai", "api_key").unwrap();
9710 crate::CredentialStore::open(&temp_home.path)
9711 .replace(
9712 reference.clone(),
9713 "sk-test-provider-key",
9714 crate::CredentialSource::User,
9715 0,
9716 )
9717 .unwrap();
9718 let mut config = Config::from_data_dir(Some(temp_home.path.clone()));
9719 config.provider = "openai".to_string();
9720 config.providers.openai = Some(OpenAIConfig {
9721 api_key: "sk-test-provider-key".to_string(),
9722 api_key_encrypted: None,
9723 credential_ref: Some(reference),
9724 base_url: None,
9725 model: None,
9726 fast_model: None,
9727 vision_model: None,
9728 reasoning_effort: None,
9729 responses_only_models: vec![],
9730 request_overrides: None,
9731 extra: Default::default(),
9732 api_key_from_env: false,
9733 });
9734
9735 config
9736 .save_to_dir(temp_home.path.clone())
9737 .expect("save should persist provider reference");
9738
9739 let content = std::fs::read_to_string(temp_home.path.join("providers.json"))
9740 .expect("read providers.json");
9741 assert!(
9742 !content.contains("api_key_encrypted"),
9743 "providers.json must not store provider ciphertext"
9744 );
9745 assert!(
9746 !content.contains("\"api_key\""),
9747 "providers.json should not store plaintext provider keys"
9748 );
9749
9750 let loaded = Config::from_data_dir(Some(temp_home.path.clone()));
9751 let openai = loaded
9752 .providers
9753 .openai
9754 .as_ref()
9755 .expect("openai config should be present");
9756 assert_eq!(openai.api_key, "sk-test-provider-key");
9757
9758 drop(key_guard);
9759 }
9760
9761 #[test]
9762 fn config_save_persists_mcp_servers_in_mainstream_format() {
9763 let _lock = env_lock_acquire();
9764 let temp_home = TempHome::new();
9765
9766 let mut config = Config::from_data_dir(Some(temp_home.path.clone()));
9767
9768 let mut env = std::collections::HashMap::new();
9769 env.insert("TOKEN".to_string(), "supersecret".to_string());
9770
9771 config.mcp.servers = vec![
9772 bamboo_domain::mcp_config::McpServerConfig {
9773 id: "stdio-secret".to_string(),
9774 name: None,
9775 enabled: true,
9776 transport: bamboo_domain::mcp_config::TransportConfig::Stdio(
9777 bamboo_domain::mcp_config::StdioConfig {
9778 command: "echo".to_string(),
9779 args: vec![],
9780 cwd: None,
9781 env,
9782 env_encrypted: std::collections::HashMap::new(),
9783 env_credential_refs: std::collections::HashMap::new(),
9784 startup_timeout_ms: 5000,
9785 },
9786 ),
9787 request_timeout_ms: 5000,
9788 healthcheck_interval_ms: 1000,
9789 reconnect: bamboo_domain::mcp_config::ReconnectConfig::default(),
9790 allowed_tools: vec![],
9791 denied_tools: vec![],
9792 },
9793 bamboo_domain::mcp_config::McpServerConfig {
9794 id: "http-secret".to_string(),
9795 name: None,
9796 enabled: true,
9797 transport: bamboo_domain::mcp_config::TransportConfig::StreamableHttp(
9798 bamboo_domain::mcp_config::StreamableHttpConfig {
9799 url: "http://localhost:8080/http".to_string(),
9800 headers: vec![bamboo_domain::mcp_config::HeaderConfig {
9801 name: "Authorization".to_string(),
9802 value: "Bearer token123".to_string(),
9803 value_encrypted: None,
9804 credential_ref: None,
9805 }],
9806 connect_timeout_ms: 5000,
9807 },
9808 ),
9809 request_timeout_ms: 5000,
9810 healthcheck_interval_ms: 1000,
9811 reconnect: bamboo_domain::mcp_config::ReconnectConfig::default(),
9812 allowed_tools: vec![],
9813 denied_tools: vec![],
9814 },
9815 ];
9816
9817 config
9818 .save_to_dir(temp_home.path.clone())
9819 .expect("save should persist MCP servers");
9820
9821 let content =
9822 std::fs::read_to_string(temp_home.path.join("config.json")).expect("read config.json");
9823 assert!(
9824 content.contains("\"mcpServers\""),
9825 "config.json should store MCP servers under the mainstream 'mcpServers' key"
9826 );
9827 assert!(
9828 content.contains("supersecret"),
9829 "config.json should persist MCP stdio env in mainstream format"
9830 );
9831 assert!(
9832 content.contains("Bearer token123"),
9833 "config.json should persist MCP Streamable HTTP headers in mainstream format"
9834 );
9835 assert!(
9836 !content.contains("\"env_encrypted\""),
9837 "config.json should not persist legacy env_encrypted fields"
9838 );
9839 assert!(
9840 !content.contains("\"value_encrypted\""),
9841 "config.json should not persist legacy value_encrypted fields"
9842 );
9843
9844 let loaded = Config::from_data_dir(Some(temp_home.path.clone()));
9845 let stdio = loaded
9846 .mcp
9847 .servers
9848 .iter()
9849 .find(|s| s.id == "stdio-secret")
9850 .expect("stdio server should exist");
9851 match &stdio.transport {
9852 bamboo_domain::mcp_config::TransportConfig::Stdio(stdio) => {
9853 assert_eq!(
9854 stdio.env.get("TOKEN").map(|s| s.as_str()),
9855 Some("supersecret")
9856 );
9857 }
9858 _ => panic!("Expected stdio transport"),
9859 }
9860
9861 let http = loaded
9862 .mcp
9863 .servers
9864 .iter()
9865 .find(|s| s.id == "http-secret")
9866 .expect("http server should exist");
9867 match &http.transport {
9868 bamboo_domain::mcp_config::TransportConfig::StreamableHttp(http) => {
9869 assert_eq!(http.headers[0].value, "Bearer token123");
9870 }
9871 _ => panic!("Expected Streamable HTTP transport"),
9872 }
9873 }
9874
9875 #[test]
9876 fn migrated_hydrated_mcp_save_never_duplicates_referenced_secrets_to_root_or_backups() {
9877 let _lock = env_lock_acquire();
9878 let _key = crate::encryption::set_test_encryption_key([0x6b; 32]);
9879 let temp_home = TempHome::new();
9880 let env_plaintext = "mcp-root-env-plaintext-597";
9881 let header_plaintext = "Bearer mcp-root-header-plaintext-597";
9882 let legacy_ciphertext = crate::encryption::encrypt(env_plaintext).unwrap();
9883 std::fs::write(
9884 temp_home.path.join("config.json"),
9885 serde_json::to_vec_pretty(&serde_json::json!({
9886 "features": {"provider_model_ref": true},
9887 "mcpServers": {
9888 "stdio-root": {
9889 "command": "unused-disabled-command",
9890 "env": {"TOKEN": env_plaintext},
9891 "env_encrypted": {"TOKEN": legacy_ciphertext.clone()},
9892 "env_credential_refs": {
9893 "TOKEN": "mcp.stdio-root.env_TOKEN"
9894 }
9895 },
9896 "http-root": {
9897 "url": "https://example.test/mcp",
9898 "transport_kind": "streamable_http",
9899 "headers": {"Authorization": header_plaintext},
9900 "header_credential_refs": {
9901 "Authorization": "mcp.http-root.header_Authorization"
9902 }
9903 }
9904 }
9905 }))
9906 .unwrap(),
9907 )
9908 .unwrap();
9909 std::fs::write(
9910 temp_home.path.join("mcp.json"),
9911 serde_json::to_vec_pretty(&serde_json::json!({
9912 "schema_version": 1,
9913 "revision": 7,
9914 "data": {
9915 "stdio-root": {
9916 "command": "unused-disabled-command",
9917 "enabled": false,
9918 "env_encrypted": {"TOKEN": legacy_ciphertext},
9919 "request_timeout_ms": 100,
9920 "healthcheck_interval_ms": 100
9921 },
9922 "http-root": {
9923 "url": "https://example.test/mcp",
9924 "transport_kind": "streamable_http",
9925 "enabled": false,
9926 "headers": {"Authorization": header_plaintext},
9927 "request_timeout_ms": 100,
9928 "healthcheck_interval_ms": 100
9929 }
9930 }
9931 }))
9932 .unwrap(),
9933 )
9934 .unwrap();
9935
9936 crate::migrate_provider_mcp_credentials(&temp_home.path).unwrap();
9937 let stored = crate::AtomicJsonStore::<bamboo_domain::mcp_config::McpConfig>::new(
9938 temp_home.path.join("mcp.json"),
9939 1,
9940 )
9941 .load()
9942 .unwrap()
9943 .unwrap();
9944 let mut config = Config::from_data_dir_without_env(Some(temp_home.path.clone()));
9945 config.mcp = stored.data;
9946 config
9947 .hydrate_mcp_credentials_from_store(&temp_home.path)
9948 .unwrap();
9949
9950 let public = serde_json::to_value(&config.mcp).unwrap();
9951 let rendered_public = public.to_string();
9952 assert!(rendered_public.contains(env_plaintext));
9953 assert!(rendered_public.contains(header_plaintext));
9954 let compatible: bamboo_domain::mcp_config::McpConfig =
9955 serde_json::from_value(public).unwrap();
9956 assert_eq!(compatible.servers.len(), 2);
9957
9958 config.features.provider_model_ref = !config.features.provider_model_ref;
9959 config.save_to_dir(temp_home.path.clone()).unwrap();
9960 config.features.provider_model_ref = !config.features.provider_model_ref;
9961 config.save_to_dir(temp_home.path.clone()).unwrap();
9962
9963 for entry in std::fs::read_dir(&temp_home.path)
9964 .unwrap()
9965 .filter_map(Result::ok)
9966 {
9967 let name = entry.file_name().to_string_lossy().to_string();
9968 if name == "credentials.json" || entry.path().is_dir() {
9969 continue;
9970 }
9971 if name == "config.json"
9972 || name == "mcp.json"
9973 || name.contains(".bak")
9974 || name.starts_with("config-credential-migration")
9975 {
9976 let bytes = std::fs::read(entry.path()).unwrap();
9977 let content = String::from_utf8_lossy(&bytes);
9978 assert!(!content.contains(env_plaintext), "secret leaked to {name}");
9979 assert!(
9980 !content.contains(header_plaintext),
9981 "secret leaked to {name}"
9982 );
9983 assert!(
9984 !content.contains(&legacy_ciphertext),
9985 "legacy ciphertext leaked to {name}"
9986 );
9987 }
9988 }
9989 }
9990
9991 #[test]
9994 fn env_vars_as_map_includes_only_non_empty_values() {
9995 let mut config = Config::default();
9996 config.env_vars.extend([
9997 EnvVarEntry {
9998 name: "A".to_string(),
9999 value: "val_a".to_string(),
10000 secret: false,
10001 value_encrypted: None,
10002 credential_ref: None,
10003 configured: true,
10004 description: None,
10005 },
10006 EnvVarEntry {
10007 name: "B".to_string(),
10008 value: "".to_string(), secret: true,
10010 value_encrypted: None,
10011 credential_ref: None,
10012 configured: false,
10013 description: None,
10014 },
10015 EnvVarEntry {
10016 name: "C".to_string(),
10017 value: " ".to_string(), secret: false,
10019 value_encrypted: None,
10020 credential_ref: None,
10021 configured: true,
10022 description: None,
10023 },
10024 EnvVarEntry {
10025 name: "D".to_string(),
10026 value: "val_d".to_string(),
10027 secret: true,
10028 value_encrypted: Some("enc".to_string()),
10029 credential_ref: None,
10030 configured: true,
10031 description: Some("desc".to_string()),
10032 },
10033 ]);
10034
10035 let map = config.env_vars_as_map();
10036 assert_eq!(map.len(), 2);
10037 assert_eq!(map.get("A"), Some(&"val_a".to_string()));
10038 assert_eq!(map.get("D"), Some(&"val_d".to_string()));
10039 assert!(!map.contains_key("B"));
10040 assert!(!map.contains_key("C"));
10041 }
10042
10043 #[test]
10044 fn sanitize_env_vars_for_disk_clears_secret_plaintext() {
10045 let mut config = Config::default();
10046 config.env_vars.extend([
10047 EnvVarEntry {
10048 name: "PLAIN".to_string(),
10049 value: "visible".to_string(),
10050 secret: false,
10051 value_encrypted: None,
10052 credential_ref: None,
10053 configured: true,
10054 description: None,
10055 },
10056 EnvVarEntry {
10057 name: "SECRET".to_string(),
10058 value: "hidden_value".to_string(),
10059 secret: true,
10060 value_encrypted: Some("enc_data".to_string()),
10061 credential_ref: None,
10062 configured: true,
10063 description: None,
10064 },
10065 ]);
10066
10067 config.sanitize_env_vars_for_disk();
10068
10069 assert_eq!(config.env_vars[0].value, "visible"); assert_eq!(config.env_vars[1].value, ""); }
10072
10073 #[test]
10074 fn sanitize_env_vars_for_disk_removes_legacy_encrypted() {
10075 let mut config = Config::default();
10076 config.env_vars.extend([
10077 EnvVarEntry {
10078 name: "OPEN".to_string(),
10079 value: "val".to_string(),
10080 secret: false,
10081 value_encrypted: None,
10082 credential_ref: None,
10083 configured: true,
10084 description: None,
10085 },
10086 EnvVarEntry {
10087 name: "HIDDEN".to_string(),
10088 value: "real_secret".to_string(),
10089 secret: true,
10090 value_encrypted: Some("enc".to_string()),
10091 credential_ref: None,
10092 configured: true,
10093 description: None,
10094 },
10095 ]);
10096
10097 config.sanitize_env_vars_for_disk();
10098
10099 assert_eq!(config.env_vars[0].value, "val");
10101 assert_eq!(config.env_vars[1].value, "");
10103 assert!(config.env_vars[1].value_encrypted.is_none());
10104 }
10105
10106 #[test]
10107 fn legacy_env_ciphertext_is_read_but_never_serialized() {
10108 let ciphertext = crate::encryption::encrypt("my-secret-token").unwrap();
10109 let mut config: Config = serde_json::from_value(serde_json::json!({
10110 "env_vars": [{
10111 "name": "TOKEN",
10112 "secret": true,
10113 "value_encrypted": ciphertext
10114 }]
10115 }))
10116 .unwrap();
10117 config.hydrate_env_vars_from_encrypted();
10118 assert_eq!(config.env_vars[0].value, "my-secret-token");
10119 let serialized = serde_json::to_value(&config).unwrap();
10120 assert!(serialized["env_vars"][0].get("value_encrypted").is_none());
10121 }
10122
10123 #[test]
10124 fn configured_env_ref_missing_from_store_fails_closed() {
10125 let dir = tempfile::tempdir().unwrap();
10126 let reference = crate::credential_ref("env", "TOKEN", "value").unwrap();
10127 let mut config = Config::default();
10128 config.env_vars.push(EnvVarEntry {
10129 name: "TOKEN".to_string(),
10130 value: String::new(),
10131 secret: true,
10132 value_encrypted: None,
10133 credential_ref: Some(reference),
10134 configured: true,
10135 description: None,
10136 });
10137 let error = config
10138 .hydrate_env_var_credentials_from_store(dir.path())
10139 .unwrap_err();
10140 assert!(error
10141 .to_string()
10142 .contains("referenced env credential is unavailable"));
10143 assert!(config.env_vars[0].configured);
10144 assert!(config.env_vars[0].value.is_empty());
10145 }
10146
10147 #[test]
10148 fn configured_notification_ref_missing_from_store_fails_closed() {
10149 let dir = tempfile::tempdir().unwrap();
10150 let reference = crate::credential_ref("notification", "ntfy", "token").unwrap();
10151 let mut config = Config::default();
10152 config.notifications.ntfy.credential_ref = Some(reference);
10153 config.notifications.ntfy.configured = true;
10154
10155 let error = config
10156 .hydrate_notification_credentials_from_store(dir.path())
10157 .unwrap_err();
10158
10159 assert!(error
10160 .to_string()
10161 .contains("referenced ntfy credential is unavailable"));
10162 assert!(config.notifications.ntfy.configured);
10163 assert!(config.notifications.ntfy.token.is_none());
10164 }
10165
10166 #[test]
10167 fn notification_hydration_rejects_shared_ref_but_accepts_exclusive_custom_ref() {
10168 let _key = crate::encryption::set_test_encryption_key([0xa5; 32]);
10169 let dir = tempfile::tempdir().unwrap();
10170 let reference = crate::CredentialRef::parse("custom.notification.secret").unwrap();
10171 crate::CredentialStore::open(dir.path())
10172 .replace(
10173 reference.clone(),
10174 "exclusive-notification-secret",
10175 crate::CredentialSource::User,
10176 0,
10177 )
10178 .unwrap();
10179
10180 let mut config = Config::default();
10181 config.notifications.ntfy.credential_ref = Some(reference.clone());
10182 config.notifications.ntfy.configured = true;
10183 config.proxy_auth_credential_ref = Some(reference.clone());
10184 let error = config
10185 .hydrate_notification_credentials_from_store(dir.path())
10186 .unwrap_err();
10187 let rendered = error.to_string();
10188 assert!(rendered
10189 .contains("notification credential reference is shared by another config consumer"));
10190 assert!(!rendered.contains(reference.as_str()));
10191 assert!(config.notifications.ntfy.token.is_none());
10192
10193 config.proxy_auth_credential_ref = None;
10194 config
10195 .hydrate_notification_credentials_from_store(dir.path())
10196 .unwrap();
10197 assert_eq!(
10198 config.notifications.ntfy.token.as_deref(),
10199 Some("exclusive-notification-secret")
10200 );
10201 }
10202
10203 #[test]
10204 fn pending_migration_keeps_legacy_notification_bytes_but_fails_runtime_closed() {
10205 let _key = crate::encryption::set_test_encryption_key([0xa6; 32]);
10206 let dir = tempfile::tempdir().unwrap();
10207 let ntfy = crate::encryption::encrypt("legacy-ntfy-secret").unwrap();
10208 let bark = crate::encryption::encrypt("legacy-bark-secret").unwrap();
10209 let bytes = serde_json::to_vec_pretty(&serde_json::json!({
10210 "notifications": {
10211 "ntfy": { "enabled": true, "token_encrypted": ntfy },
10212 "bark": { "enabled": true, "device_key_encrypted": bark }
10213 }
10214 }))
10215 .unwrap();
10216 std::fs::write(dir.path().join("config.json"), &bytes).unwrap();
10217 std::fs::create_dir(dir.path().join("config-credential-migration.json")).unwrap();
10218
10219 let loaded = Config::from_data_dir_without_env(Some(dir.path().to_path_buf()));
10220
10221 assert!(loaded.notifications.ntfy.token.is_none());
10222 assert!(loaded.notifications.bark.device_key.is_none());
10223 assert_eq!(
10224 std::fs::read(dir.path().join("config.json")).unwrap(),
10225 bytes
10226 );
10227 }
10228
10229 #[test]
10230 fn publish_and_current_env_vars_round_trip() {
10231 let _lock = crate::test_support::env_cache_lock_acquire();
10241 let mut config = Config::default();
10242 config.env_vars.extend([EnvVarEntry {
10243 name: "TEST_PUBLISH".to_string(),
10244 value: "pub_value".to_string(),
10245 secret: false,
10246 value_encrypted: None,
10247 credential_ref: None,
10248 configured: true,
10249 description: None,
10250 }]);
10251
10252 config.publish_env_vars();
10253 assert_eq!(
10254 Config::current_env_vars()
10255 .get("TEST_PUBLISH")
10256 .map(String::as_str),
10257 Some("pub_value")
10258 );
10259 }
10260
10261 #[test]
10262 fn broker_token_round_trips_encrypt_sanitize_hydrate() {
10263 let mut config = Config::default();
10264 config.subagents.broker = Some(BrokerClientConfig {
10265 endpoint: "ws://127.0.0.1:9600".to_string(),
10266 token: "super-secret-token".to_string(),
10267 token_encrypted: None,
10268 credential_ref: None,
10269 configured: false,
10270 });
10271
10272 config.refresh_broker_token_encrypted().unwrap();
10274 config.sanitize_broker_token_for_disk();
10275 let broker = config.subagents.broker.as_ref().unwrap();
10276 assert!(broker.token.is_empty(), "plaintext cleared for disk");
10277 assert!(broker.token_encrypted.is_some(), "ciphertext stored");
10278 assert_ne!(
10279 broker.token_encrypted.as_deref(),
10280 Some("super-secret-token")
10281 );
10282
10283 config.hydrate_broker_token_from_encrypted();
10285 assert_eq!(
10286 config.subagents.broker.as_ref().unwrap().token,
10287 "super-secret-token"
10288 );
10289 }
10290
10291 #[test]
10292 fn broker_token_empty_refresh_preserves_ciphertext() {
10293 let mut config = Config::default();
10295 config.subagents.broker = Some(BrokerClientConfig {
10296 endpoint: "ws://h:9600".to_string(),
10297 token: String::new(),
10298 token_encrypted: Some("existing-cipher".to_string()),
10299 credential_ref: None,
10300 configured: false,
10301 });
10302 config.refresh_broker_token_encrypted().unwrap();
10303 assert_eq!(
10304 config
10305 .subagents
10306 .broker
10307 .as_ref()
10308 .unwrap()
10309 .token_encrypted
10310 .as_deref(),
10311 Some("existing-cipher"),
10312 );
10313 }
10314
10315 #[test]
10316 fn notifications_config_defaults_when_key_missing() {
10317 let config: Config = serde_json::from_str("{}").expect("empty object parses");
10320 assert_eq!(config.notifications, NotificationsConfig::default());
10321 assert_eq!(config.notifications.desktop.enabled, None);
10322 assert!(!config.notifications.ntfy.enabled);
10323 assert_eq!(config.notifications.ntfy.base_url, "https://ntfy.sh");
10324 assert_eq!(config.notifications.ntfy.token, None);
10325 assert!(!config.notifications.bark.enabled);
10326 assert_eq!(config.notifications.bark.base_url, "https://api.day.app");
10327 assert_eq!(config.notifications.bark.device_key, None);
10328 }
10329
10330 #[test]
10331 fn ntfy_token_round_trips_encrypt_serialize_hydrate() {
10332 let mut config = Config::default();
10333 config.notifications.ntfy = NtfyChannelConfig {
10334 enabled: true,
10335 base_url: "https://ntfy.sh".to_string(),
10336 topic: "bamboo-alerts".to_string(),
10337 token: Some("tk_super_secret".to_string()),
10338 token_encrypted: None,
10339 credential_ref: None,
10340 configured: false,
10341 };
10342
10343 config.refresh_notifications_encrypted().unwrap();
10346 assert!(config.notifications.ntfy.token_encrypted.is_some());
10347 assert_ne!(
10348 config.notifications.ntfy.token_encrypted.as_deref(),
10349 Some("tk_super_secret")
10350 );
10351
10352 let json = serde_json::to_string(&config.notifications.ntfy).unwrap();
10354 assert!(
10355 !json.contains("tk_super_secret"),
10356 "plaintext token must never be serialized"
10357 );
10358 assert!(!json.contains("token_encrypted"));
10359
10360 config.notifications.ntfy.token = None;
10362 config.hydrate_notifications_from_encrypted();
10363 assert_eq!(
10364 config.notifications.ntfy.token.as_deref(),
10365 Some("tk_super_secret")
10366 );
10367 }
10368
10369 #[test]
10370 fn bark_device_key_round_trips_encrypt_serialize_hydrate() {
10371 let mut config = Config::default();
10372 config.notifications.bark = BarkChannelConfig {
10373 enabled: true,
10374 base_url: "https://api.day.app".to_string(),
10375 device_key: Some("dk_super_secret".to_string()),
10376 device_key_encrypted: None,
10377 credential_ref: None,
10378 configured: false,
10379 };
10380
10381 config.refresh_notifications_encrypted().unwrap();
10382 assert!(config.notifications.bark.device_key_encrypted.is_some());
10383 assert_ne!(
10384 config.notifications.bark.device_key_encrypted.as_deref(),
10385 Some("dk_super_secret")
10386 );
10387
10388 let json = serde_json::to_string(&config.notifications.bark).unwrap();
10389 assert!(
10390 !json.contains("dk_super_secret"),
10391 "plaintext device key must never be serialized"
10392 );
10393 assert!(!json.contains("device_key_encrypted"));
10394
10395 config.notifications.bark.device_key = None;
10396 config.hydrate_notifications_from_encrypted();
10397 assert_eq!(
10398 config.notifications.bark.device_key.as_deref(),
10399 Some("dk_super_secret")
10400 );
10401 }
10402
10403 #[test]
10404 fn notification_secrets_empty_refresh_preserves_ciphertext() {
10405 let mut config = Config::default();
10408 config.notifications.ntfy.token_encrypted = Some("existing-ntfy-cipher".to_string());
10409 config.notifications.bark.device_key_encrypted = Some("existing-bark-cipher".to_string());
10410
10411 config.refresh_notifications_encrypted().unwrap();
10412
10413 assert_eq!(
10414 config.notifications.ntfy.token_encrypted.as_deref(),
10415 Some("existing-ntfy-cipher")
10416 );
10417 assert_eq!(
10418 config.notifications.bark.device_key_encrypted.as_deref(),
10419 Some("existing-bark-cipher")
10420 );
10421 }
10422
10423 #[test]
10424 fn hydrate_skips_non_secret_entries() {
10425 let mut config = Config::default();
10426 config.env_vars.extend([EnvVarEntry {
10427 name: "PLAIN".to_string(),
10428 value: "original".to_string(),
10429 secret: false,
10430 value_encrypted: Some("should-be-ignored".to_string()),
10431 credential_ref: None,
10432 configured: true,
10433 description: None,
10434 }]);
10435
10436 config.hydrate_env_vars_from_encrypted();
10437 assert_eq!(config.env_vars[0].value, "original");
10439 }
10440
10441 #[test]
10442 fn default_config_has_empty_env_vars() {
10443 assert!(Config::default().env_vars.is_empty());
10448 }
10449
10450 #[test]
10451 fn serde_round_trip_with_env_vars() {
10452 let mut config = Config::default();
10453 config.env_vars.extend([
10454 EnvVarEntry {
10455 name: "KEY1".to_string(),
10456 value: "val1".to_string(),
10457 secret: false,
10458 value_encrypted: None,
10459 credential_ref: None,
10460 configured: true,
10461 description: Some("First key".to_string()),
10462 },
10463 EnvVarEntry {
10464 name: "KEY2".to_string(),
10465 value: "".to_string(), secret: true,
10467 value_encrypted: Some("enc123".to_string()),
10468 credential_ref: None,
10469 configured: true,
10470 description: None,
10471 },
10472 ]);
10473
10474 let json = serde_json::to_string(&config).unwrap();
10475 let restored: Config = serde_json::from_str(&json).unwrap();
10476
10477 assert_eq!(restored.env_vars.len(), 2);
10478 assert_eq!(restored.env_vars[0].name, "KEY1");
10479 assert_eq!(restored.env_vars[0].value, "val1");
10480 assert!(!restored.env_vars[0].secret);
10481 assert_eq!(restored.env_vars[1].name, "KEY2");
10482 assert!(restored.env_vars[1].secret);
10483 assert!(restored.env_vars[1].value_encrypted.is_none());
10484 }
10485
10486 #[test]
10489 #[allow(clippy::field_reassign_with_default)]
10491 fn get_model_prefers_defaults_chat_when_provider_model_ref_enabled() {
10492 let mut config = Config::default();
10493 config.provider = "openai".to_string();
10494 config.providers.openai = Some(OpenAIConfig {
10495 api_key: "test".to_string(),
10496 api_key_encrypted: None,
10497 credential_ref: None,
10498 base_url: None,
10499 model: Some("legacy-gpt-4o".to_string()),
10500 fast_model: None,
10501 vision_model: None,
10502 reasoning_effort: None,
10503 responses_only_models: vec![],
10504 request_overrides: None,
10505 extra: Default::default(),
10506 api_key_from_env: false,
10507 });
10508 config.features.provider_model_ref = true;
10509 config.defaults = Some(DefaultsConfig {
10510 chat: bamboo_domain::ProviderModelRef::new("anthropic", "claude-3-7-sonnet"),
10511 fast: None,
10512 task_summary: None,
10513 vision: None,
10514 memory_background: None,
10515 planning: None,
10516 search: None,
10517 code_review: None,
10518 sub_agent: None,
10519 subagent_models: Default::default(),
10520 });
10521
10522 assert_eq!(config.get_model(), Some("claude-3-7-sonnet".to_string()));
10523 }
10524
10525 #[test]
10526 #[allow(clippy::field_reassign_with_default)]
10528 fn get_model_ignores_defaults_chat_when_provider_model_ref_disabled() {
10529 let mut config = Config::default();
10530 config.provider = "openai".to_string();
10531 config.providers.openai = Some(OpenAIConfig {
10532 api_key: "test".to_string(),
10533 api_key_encrypted: None,
10534 credential_ref: None,
10535 base_url: None,
10536 model: Some("legacy-gpt-4o".to_string()),
10537 fast_model: None,
10538 vision_model: None,
10539 reasoning_effort: None,
10540 responses_only_models: vec![],
10541 request_overrides: None,
10542 extra: Default::default(),
10543 api_key_from_env: false,
10544 });
10545 config.features.provider_model_ref = false;
10546 config.defaults = Some(DefaultsConfig {
10547 chat: bamboo_domain::ProviderModelRef::new("anthropic", "claude-3-7-sonnet"),
10548 fast: None,
10549 task_summary: None,
10550 vision: None,
10551 memory_background: None,
10552 planning: None,
10553 search: None,
10554 code_review: None,
10555 sub_agent: None,
10556 subagent_models: Default::default(),
10557 });
10558
10559 assert_eq!(config.get_model(), Some("legacy-gpt-4o".to_string()));
10560 }
10561
10562 #[test]
10563 #[allow(clippy::field_reassign_with_default)]
10565 fn get_fast_model_prefers_defaults_fast_when_provider_model_ref_enabled() {
10566 let mut config = Config::default();
10567 config.provider = "openai".to_string();
10568 config.providers.openai = Some(OpenAIConfig {
10569 api_key: "test".to_string(),
10570 api_key_encrypted: None,
10571 credential_ref: None,
10572 base_url: None,
10573 model: Some("gpt-4o".to_string()),
10574 fast_model: Some("legacy-gpt-4o-mini".to_string()),
10575 vision_model: None,
10576 reasoning_effort: None,
10577 responses_only_models: vec![],
10578 request_overrides: None,
10579 extra: Default::default(),
10580 api_key_from_env: false,
10581 });
10582 config.features.provider_model_ref = true;
10583 config.defaults = Some(DefaultsConfig {
10584 chat: bamboo_domain::ProviderModelRef::new("openai", "gpt-4o"),
10585 fast: Some(bamboo_domain::ProviderModelRef::new(
10586 "anthropic",
10587 "claude-3-5-haiku",
10588 )),
10589 task_summary: None,
10590 vision: None,
10591 memory_background: None,
10592 planning: None,
10593 search: None,
10594 code_review: None,
10595 sub_agent: None,
10596 subagent_models: Default::default(),
10597 });
10598
10599 assert_eq!(
10600 config.get_fast_model(),
10601 Some("claude-3-5-haiku".to_string())
10602 );
10603 }
10604
10605 #[test]
10606 #[allow(clippy::field_reassign_with_default)]
10608 fn get_fast_model_ignores_defaults_fast_when_provider_model_ref_disabled() {
10609 let mut config = Config::default();
10610 config.provider = "openai".to_string();
10611 config.providers.openai = Some(OpenAIConfig {
10612 api_key: "test".to_string(),
10613 api_key_encrypted: None,
10614 credential_ref: None,
10615 base_url: None,
10616 model: Some("gpt-4o".to_string()),
10617 fast_model: Some("legacy-gpt-4o-mini".to_string()),
10618 vision_model: None,
10619 reasoning_effort: None,
10620 responses_only_models: vec![],
10621 request_overrides: None,
10622 extra: Default::default(),
10623 api_key_from_env: false,
10624 });
10625 config.features.provider_model_ref = false;
10626 config.defaults = Some(DefaultsConfig {
10627 chat: bamboo_domain::ProviderModelRef::new("openai", "gpt-4o"),
10628 fast: Some(bamboo_domain::ProviderModelRef::new(
10629 "anthropic",
10630 "claude-3-5-haiku",
10631 )),
10632 task_summary: None,
10633 vision: None,
10634 memory_background: None,
10635 planning: None,
10636 search: None,
10637 code_review: None,
10638 sub_agent: None,
10639 subagent_models: Default::default(),
10640 });
10641
10642 assert_eq!(
10643 config.get_fast_model(),
10644 Some("legacy-gpt-4o-mini".to_string())
10645 );
10646 }
10647
10648 #[test]
10649 #[allow(clippy::field_reassign_with_default)]
10651 fn get_fast_model_falls_back_to_defaults_chat_when_fast_unset() {
10652 let mut config = Config::default();
10653 config.provider = "openai".to_string();
10654 config.features.provider_model_ref = true;
10655 config.defaults = Some(DefaultsConfig {
10656 chat: bamboo_domain::ProviderModelRef::new("anthropic", "claude-3-7-sonnet"),
10657 fast: None,
10658 task_summary: None,
10659 vision: None,
10660 memory_background: None,
10661 planning: None,
10662 search: None,
10663 code_review: None,
10664 sub_agent: None,
10665 subagent_models: Default::default(),
10666 });
10667
10668 assert_eq!(
10669 config.get_fast_model(),
10670 Some("claude-3-7-sonnet".to_string())
10671 );
10672 }
10673
10674 #[test]
10675 #[allow(clippy::field_reassign_with_default)]
10677 fn get_memory_background_model_prefers_defaults_memory_background() {
10678 let mut config = Config::default();
10679 config.provider = "openai".to_string();
10680 config.providers.openai = Some(OpenAIConfig {
10681 api_key: "test".to_string(),
10682 api_key_encrypted: None,
10683 credential_ref: None,
10684 base_url: None,
10685 model: Some("gpt-4o".to_string()),
10686 fast_model: Some("gpt-4o-mini".to_string()),
10687 vision_model: None,
10688 reasoning_effort: None,
10689 responses_only_models: vec![],
10690 request_overrides: None,
10691 extra: Default::default(),
10692 api_key_from_env: false,
10693 });
10694 config.features.provider_model_ref = true;
10695 config.defaults = Some(DefaultsConfig {
10696 chat: bamboo_domain::ProviderModelRef::new("openai", "gpt-4o"),
10697 fast: Some(bamboo_domain::ProviderModelRef::new(
10698 "openai",
10699 "gpt-4o-mini",
10700 )),
10701 task_summary: None,
10702 vision: None,
10703 memory_background: Some(bamboo_domain::ProviderModelRef::new(
10704 "anthropic",
10705 "claude-3-5-haiku",
10706 )),
10707 planning: None,
10708 search: None,
10709 code_review: None,
10710 sub_agent: None,
10711 subagent_models: Default::default(),
10712 });
10713
10714 assert_eq!(
10715 config.get_memory_background_model(),
10716 Some("claude-3-5-haiku".to_string())
10717 );
10718 }
10719
10720 #[test]
10721 #[allow(clippy::field_reassign_with_default)]
10723 fn get_memory_background_model_falls_back_to_defaults_fast_when_memory_background_unset() {
10724 let mut config = Config::default();
10725 config.provider = "openai".to_string();
10726 config.features.provider_model_ref = true;
10727 config.defaults = Some(DefaultsConfig {
10728 chat: bamboo_domain::ProviderModelRef::new("openai", "gpt-4o"),
10729 fast: Some(bamboo_domain::ProviderModelRef::new(
10730 "anthropic",
10731 "claude-3-5-haiku",
10732 )),
10733 task_summary: None,
10734 vision: None,
10735 memory_background: None,
10736 planning: None,
10737 search: None,
10738 code_review: None,
10739 sub_agent: None,
10740 subagent_models: Default::default(),
10741 });
10742
10743 assert_eq!(
10744 config.get_memory_background_model(),
10745 Some("claude-3-5-haiku".to_string())
10746 );
10747 }
10748
10749 #[test]
10750 #[allow(clippy::field_reassign_with_default)]
10752 fn get_memory_background_model_ignores_defaults_when_provider_model_ref_disabled() {
10753 let mut config = Config::default();
10754 config.provider = "openai".to_string();
10755 config.providers.openai = Some(OpenAIConfig {
10756 api_key: "test".to_string(),
10757 api_key_encrypted: None,
10758 credential_ref: None,
10759 base_url: None,
10760 model: Some("gpt-4o".to_string()),
10761 fast_model: Some("legacy-gpt-4o-mini".to_string()),
10762 vision_model: None,
10763 reasoning_effort: None,
10764 responses_only_models: vec![],
10765 request_overrides: None,
10766 extra: Default::default(),
10767 api_key_from_env: false,
10768 });
10769 config.features.provider_model_ref = false;
10770 config.defaults = Some(DefaultsConfig {
10771 chat: bamboo_domain::ProviderModelRef::new("openai", "gpt-4o"),
10772 fast: Some(bamboo_domain::ProviderModelRef::new(
10773 "anthropic",
10774 "claude-3-5-haiku",
10775 )),
10776 task_summary: None,
10777 vision: None,
10778 memory_background: Some(bamboo_domain::ProviderModelRef::new(
10779 "anthropic",
10780 "claude-3-5-haiku",
10781 )),
10782 planning: None,
10783 search: None,
10784 code_review: None,
10785 sub_agent: None,
10786 subagent_models: Default::default(),
10787 });
10788
10789 assert_eq!(
10790 config.get_memory_background_model(),
10791 Some("legacy-gpt-4o-mini".to_string())
10792 );
10793 }
10794
10795 #[test]
10802 fn is_host_trusted_requires_https_scheme() {
10803 let hosts = vec!["github.com/bigduu/".to_string()];
10804 assert!(!is_host_trusted("http://github.com/bigduu/x", &hosts));
10805 assert!(is_host_trusted("https://github.com/bigduu/x", &hosts));
10806 }
10807
10808 #[test]
10809 fn is_host_trusted_is_case_insensitive_on_both_sides() {
10810 let hosts = vec!["GitHub.com/BigDuu/".to_string()];
10812 assert!(is_host_trusted("https://github.com/bigduu/x", &hosts));
10813 let hosts = vec!["github.com/bigduu/".to_string()];
10815 assert!(is_host_trusted("https://GitHub.Com/bigduu/x", &hosts));
10816 }
10817
10818 #[test]
10819 fn is_host_trusted_refuses_domain_gluing_bypass_of_a_bare_host_entry() {
10820 let hosts = vec!["trusted.example.com".to_string()];
10821 assert!(is_host_trusted("https://trusted.example.com/x", &hosts));
10822 assert!(!is_host_trusted(
10826 "https://trusted.example.com.evil.com/x",
10827 &hosts
10828 ));
10829 assert!(!is_host_trusted(
10830 "https://trusted.example.comevil.com/x",
10831 &hosts
10832 ));
10833 }
10834
10835 #[test]
10836 fn is_host_trusted_refuses_sibling_path_prefix_bypass() {
10837 let hosts = vec!["github.com/bigduu/".to_string()];
10839 assert!(is_host_trusted("https://github.com/bigduu/x", &hosts));
10840 assert!(!is_host_trusted("https://github.com/bigduu-evil/x", &hosts));
10841 }
10842
10843 #[test]
10844 fn is_host_trusted_bare_host_entry_matches_any_path_on_exactly_that_host() {
10845 let hosts = vec!["example.com".to_string()];
10846 assert!(is_host_trusted("https://example.com/", &hosts));
10847 assert!(is_host_trusted("https://example.com/any/deep/path", &hosts));
10848 assert!(!is_host_trusted("https://example.com.evil.com/", &hosts));
10851 assert!(!is_host_trusted("https://evil-example.com/", &hosts));
10852 }
10853
10854 #[test]
10855 fn is_host_trusted_uses_the_real_host_not_userinfo() {
10856 let hosts = vec!["github.com/bigduu/".to_string()];
10857 assert!(is_host_trusted(
10859 "https://someuser@github.com/bigduu/x",
10860 &hosts
10861 ));
10862 assert!(!is_host_trusted(
10865 "https://github.com@evil.com/bigduu/",
10866 &hosts
10867 ));
10868 }
10869
10870 #[test]
10871 fn is_host_trusted_ignores_an_explicit_port() {
10872 let hosts = vec!["github.com/bigduu/".to_string()];
10873 assert!(is_host_trusted("https://github.com:443/bigduu/x", &hosts));
10874 }
10875
10876 #[test]
10877 fn is_host_trusted_malformed_url_is_refused_without_panicking() {
10878 let hosts = vec!["github.com/bigduu/".to_string()];
10879 assert!(!is_host_trusted("not a url at all", &hosts));
10880 assert!(!is_host_trusted("", &hosts));
10881 assert!(!is_host_trusted("github.com/bigduu/x", &hosts)); }
10883
10884 #[test]
10885 fn is_host_trusted_normalizes_dot_segments_before_matching() {
10886 let hosts = vec!["github.com/bigduu/".to_string()];
10887 assert!(!is_host_trusted(
10890 "https://github.com/bigduu/../evil/x",
10891 &hosts
10892 ));
10893 assert!(is_host_trusted("https://github.com/bigduu/x/../y", &hosts));
10895 }
10896
10897 #[test]
10898 fn normalize_plugin_trust_settings_lowercases_and_trims_and_drops_empties() {
10899 let mut config = Config::default();
10900 config.plugin_trust.trusted_hosts = vec![
10901 " GitHub.com/BigDuu/ ".to_string(),
10902 "".to_string(),
10903 " ".to_string(),
10904 "Example.COM".to_string(),
10905 ];
10906 config.normalize_plugin_trust_settings();
10907 assert_eq!(
10908 config.plugin_trust.trusted_hosts,
10909 vec!["github.com/bigduu/".to_string(), "example.com".to_string()]
10910 );
10911 }
10912
10913 #[test]
10919 fn plugin_trust_enforcement_defaults_to_strict_when_absent() {
10920 let config = Config::default();
10922 assert_eq!(
10923 config.plugin_trust.enforcement,
10924 PluginTrustEnforcement::Strict
10925 );
10926 assert!(!config.plugin_trust.enforcement_is_off());
10927
10928 let json = serde_json::json!({
10934 "trusted_hosts": ["example.com"],
10935 "trusted_keys": [],
10936 });
10937 let trust: PluginTrustConfig = serde_json::from_value(json).expect("deserializes");
10938 assert_eq!(trust.enforcement, PluginTrustEnforcement::Strict);
10939 }
10940
10941 #[test]
10942 fn plugin_trust_enforcement_off_string_parses_case_insensitively() {
10943 for raw in ["off", "OFF", "Off", " off "] {
10944 let trust: PluginTrustConfig = serde_json::from_value(serde_json::json!({
10945 "enforcement": raw,
10946 }))
10947 .unwrap_or_else(|e| panic!("'{raw}' should parse as Off: {e}"));
10948 assert_eq!(trust.enforcement, PluginTrustEnforcement::Off, "{raw}");
10949 assert!(trust.enforcement_is_off());
10950 }
10951 for raw in ["strict", "STRICT", " Strict "] {
10952 let trust: PluginTrustConfig = serde_json::from_value(serde_json::json!({
10953 "enforcement": raw,
10954 }))
10955 .unwrap_or_else(|e| panic!("'{raw}' should parse as Strict: {e}"));
10956 assert_eq!(trust.enforcement, PluginTrustEnforcement::Strict, "{raw}");
10957 }
10958
10959 let err = serde_json::from_value::<PluginTrustConfig>(serde_json::json!({
10960 "enforcement": "nonsense",
10961 }))
10962 .expect_err("an unrecognized string must be rejected, not silently default");
10963 assert!(err.to_string().contains("nonsense"));
10964 }
10965
10966 #[test]
10967 fn plugin_trust_enforcement_accepts_a_bool_ish_alias() {
10968 let trust: PluginTrustConfig =
10971 serde_json::from_value(serde_json::json!({ "enforcement": false })).unwrap();
10972 assert_eq!(trust.enforcement, PluginTrustEnforcement::Off);
10973
10974 let trust: PluginTrustConfig =
10975 serde_json::from_value(serde_json::json!({ "enforcement": true })).unwrap();
10976 assert_eq!(trust.enforcement, PluginTrustEnforcement::Strict);
10977 }
10978
10979 #[test]
10980 fn plugin_trust_enforcement_always_serializes_as_the_canonical_string() {
10981 let trust = PluginTrustConfig {
10986 enforcement: PluginTrustEnforcement::Off,
10987 ..PluginTrustConfig::default()
10988 };
10989 let json = serde_json::to_value(&trust).unwrap();
10990 assert_eq!(json["enforcement"], "off");
10991
10992 let trust = PluginTrustConfig {
10993 enforcement: PluginTrustEnforcement::Strict,
10994 ..PluginTrustConfig::default()
10995 };
10996 let json = serde_json::to_value(&trust).unwrap();
10997 assert_eq!(json["enforcement"], "strict");
10998 }
10999
11000 #[test]
11001 fn normalize_plugin_trust_settings_does_not_disturb_enforcement() {
11002 let mut config = Config::default();
11005 config.plugin_trust.enforcement = PluginTrustEnforcement::Off;
11006 config.normalize_plugin_trust_settings();
11007 assert_eq!(config.plugin_trust.enforcement, PluginTrustEnforcement::Off);
11008 }
11009
11010 #[test]
11011 fn config_set_plugin_trust_enforcement_off_round_trips_through_the_dot_path_setter() {
11012 let config = Config::from_data_dir_without_env(Some(std::path::PathBuf::from(
11017 "/nonexistent-bamboo-plugin-trust-enforcement-test-dir",
11018 )));
11019 assert_eq!(
11020 config.plugin_trust.enforcement,
11021 PluginTrustEnforcement::Strict
11022 );
11023
11024 let outcome = crate::dot_path::apply_dot_path_set(
11025 &config,
11026 "plugin_trust.enforcement",
11027 crate::dot_path::parse_cli_value("off"),
11028 )
11029 .expect("plugin_trust.enforcement should be settable via the generic dot-path setter");
11030 assert_eq!(
11031 outcome.config.plugin_trust.enforcement,
11032 PluginTrustEnforcement::Off
11033 );
11034
11035 let outcome = crate::dot_path::apply_dot_path_set(
11037 &outcome.config,
11038 "plugin_trust.enforcement",
11039 crate::dot_path::parse_cli_value("strict"),
11040 )
11041 .expect("setting it back to strict should also round-trip");
11042 assert_eq!(
11043 outcome.config.plugin_trust.enforcement,
11044 PluginTrustEnforcement::Strict
11045 );
11046 }
11047}
11048
11049#[cfg(test)]
11050mod final_evidence_feature_tests {
11051 use super::FeatureFlags;
11052
11053 #[test]
11054 fn final_evidence_check_is_opt_in_and_survives_config_round_trip() {
11055 let existing: FeatureFlags =
11056 serde_json::from_str(r#"{"dynamic_model_routing":true}"#).unwrap();
11057 assert!(!existing.final_evidence_check);
11058 let enabled: FeatureFlags =
11059 serde_json::from_str(r#"{"final_evidence_check":true}"#).unwrap();
11060 let restored: FeatureFlags =
11061 serde_json::from_value(serde_json::to_value(enabled).unwrap()).unwrap();
11062 assert!(restored.final_evidence_check);
11063 assert!(!FeatureFlags::default().final_evidence_check);
11064 }
11065}