1use super::*;
2
3use std::collections::{BTreeMap, BTreeSet};
4use std::path::Path;
5use std::sync::atomic::{AtomicBool, Ordering};
6use std::time::Duration;
7
8use bamboo_config::{
9 ensure_provider_mcp_migration_ready, AtomicJsonStore, ConfigDirectoryWatcher,
10 ConfigSectionEvent, ConfigStoreError, McpSection, ProviderConfigs, SectionId,
11 SectionSourceKind, SectionStatus,
12};
13use bamboo_mcp::{McpConfig, McpServerManager, TransportConfig};
14use chrono::{DateTime, Utc};
15use serde::Serialize;
16use serde_json::Value;
17
18struct FacadeRuntimeMaterialization {
19 config: Config,
20 failures: BTreeSet<SectionId>,
21}
22
23fn materialize_facade_effective_config(
24 facade: &bamboo_config::ConfigFacade,
25 data_dir: &Path,
26) -> FacadeRuntimeMaterialization {
27 let mut config = facade.effective_config();
28 let mut failures = BTreeSet::new();
29 if let Err(error) = config.hydrate_proxy_auth_from_store(data_dir) {
30 tracing::warn!(error = %error, "proxy auth credential hydration unavailable");
31 config.proxy_auth = None;
32 failures.insert(SectionId::Core);
33 }
34 if let Err(error) = config.hydrate_provider_credentials_from_store(data_dir) {
35 tracing::warn!(error = %error, "provider credential hydration unavailable");
36 failures.insert(SectionId::Providers);
37 }
38 if let Err(error) = config.hydrate_mcp_credentials_from_store(data_dir) {
39 tracing::warn!(error = %error, "MCP credential hydration unavailable");
40 failures.insert(SectionId::Mcp);
41 }
42 if let Err(error) = config.hydrate_env_var_credentials_from_store(data_dir) {
43 tracing::warn!(error = %error, "env credential hydration unavailable");
44 for entry in &mut config.env_vars {
45 if entry.secret {
46 entry.value.clear();
47 }
48 }
49 failures.insert(SectionId::Env);
50 }
51 if let Err(error) = config.hydrate_cluster_credentials_from_store(data_dir) {
52 tracing::warn!(error = %error, "cluster credential hydration unavailable");
53 failures.insert(SectionId::ClusterFabric);
54 }
55 if let Err(error) = config.hydrate_notification_credentials_from_store(data_dir) {
56 tracing::warn!(error = %error, "notification credential hydration unavailable");
57 config.notifications.ntfy.token = None;
58 config.notifications.bark.device_key = None;
59 failures.insert(SectionId::Notifications);
60 }
61 if let Err(error) = config.hydrate_connect_credentials_from_store(data_dir) {
62 tracing::warn!(error = %error, "connect credential hydration unavailable");
63 for platform in &mut config.connect.platforms {
64 platform.token = None;
65 platform.app_secret = None;
66 }
67 failures.insert(SectionId::Connect);
68 }
69 if let Err(error) = config.hydrate_access_control_credentials_from_store(data_dir) {
70 tracing::warn!(error = %error, "access-control credential hydration unavailable");
71 config.clear_access_control_runtime_verifiers();
72 failures.insert(SectionId::AccessControl);
73 }
74 if let Some(broker) = config.subagents_mut().broker.as_mut() {
75 if let Err(error) = broker.hydrate_credential_from_store(data_dir) {
76 tracing::warn!(error = %error, "external broker credential hydration unavailable");
77 broker.token.clear();
78 failures.insert(SectionId::Subagents);
79 }
80 }
81 config.apply_runtime_env_overrides();
82 FacadeRuntimeMaterialization { config, failures }
83}
84
85pub(super) fn load_facade_effective_config(
86 facade: &bamboo_config::ConfigFacade,
87 data_dir: &Path,
88) -> Config {
89 materialize_facade_effective_config(facade, data_dir).config
90}
91
92fn load_committed_effective_config(data_dir: &Path) -> Result<Config, ConfigStoreError> {
93 if bamboo_config::section_layout_is_active(data_dir)? {
94 let facade = bamboo_config::ConfigFacade::open(data_dir)?;
95 let config = load_facade_effective_config(&facade, data_dir);
96 Ok(config)
97 } else {
98 Ok(Config::from_data_dir_without_publish(Some(
99 data_dir.to_path_buf(),
100 )))
101 }
102}
103
104#[derive(Debug, Clone, Serialize)]
106pub struct ConfigLiveHealth {
107 pub revision: u64,
108 pub loaded_at: DateTime<Utc>,
109 pub source_path: PathBuf,
110 pub source_kind: SectionSourceKind,
111 pub status: SectionStatus,
112 pub last_error: Option<String>,
113}
114
115pub struct ConfigWatcherRuntime {
117 stop: Arc<AtomicBool>,
118 watcher_task: Option<std::thread::JoinHandle<()>>,
119 apply_task: Option<tokio::task::JoinHandle<()>>,
120}
121
122struct ConfigPathChanges {
123 paths: Vec<PathBuf>,
124 initial_mcp: bool,
125}
126
127impl ConfigWatcherRuntime {
128 #[allow(clippy::too_many_arguments)]
129 pub fn start(
130 data_dir: PathBuf,
131 config: Arc<RwLock<Config>>,
132 config_facade: Option<Arc<bamboo_config::ConfigFacade>>,
133 config_io_lock: Arc<tokio::sync::Mutex<()>>,
134 provider_registry: Arc<bamboo_llm::ProviderRegistry>,
135 provider: Arc<RwLock<Arc<dyn LLMProvider>>>,
136 mcp_manager: Arc<McpServerManager>,
137 account_sink: Arc<bamboo_engine::events::AccountEventSink>,
138 ) -> (
139 Self,
140 Arc<std::sync::RwLock<ConfigLiveHealth>>,
141 Arc<std::sync::RwLock<ConfigLiveHealth>>,
142 ) {
143 let provider_store = AtomicJsonStore::new(data_dir.join("providers.json"), 1);
144 let provider_health = Arc::new(std::sync::RwLock::new(initial_provider_health(
145 &provider_store,
146 )));
147 let mcp_store = AtomicJsonStore::new(data_dir.join("mcp.json"), 1);
148 let mcp_health = Arc::new(std::sync::RwLock::new(initial_mcp_health(&mcp_store)));
149 let stop = Arc::new(AtomicBool::new(false));
150 let watcher = match ConfigDirectoryWatcher::watch(&data_dir, Duration::from_millis(120)) {
151 Ok(watcher) => watcher,
152 Err(error) => {
153 tracing::warn!(error = %error, "live configuration watcher could not start");
154 {
155 let mut value = provider_health
156 .write()
157 .unwrap_or_else(|poisoned| poisoned.into_inner());
158 value.status = SectionStatus::Degraded;
159 value.last_error = Some("configuration watcher unavailable".to_string());
160 }
161 {
162 let mut value = mcp_health
163 .write()
164 .unwrap_or_else(|poisoned| poisoned.into_inner());
165 value.status = SectionStatus::Degraded;
166 value.last_error = Some("configuration watcher unavailable".to_string());
167 }
168 return (
169 Self {
170 stop,
171 watcher_task: None,
172 apply_task: None,
173 },
174 provider_health,
175 mcp_health,
176 );
177 }
178 };
179
180 let self_write_marker = watcher.self_write_marker();
184 let (changes_tx, mut changes_rx) =
185 tokio::sync::mpsc::unbounded_channel::<ConfigPathChanges>();
186 let initial_changes = changes_tx.clone();
187 let worker_stop = stop.clone();
188 let watcher_task = std::thread::spawn(move || {
189 while !worker_stop.load(Ordering::Relaxed) {
190 match watcher.recv_timeout(Duration::from_millis(250)) {
191 Ok(paths) => {
192 if changes_tx
193 .send(ConfigPathChanges {
194 paths,
195 initial_mcp: false,
196 })
197 .is_err()
198 {
199 break;
200 }
201 }
202 Err(std::sync::mpsc::RecvTimeoutError::Timeout) => {}
203 Err(std::sync::mpsc::RecvTimeoutError::Disconnected) => break,
204 }
205 }
206 });
207
208 let initial_mcp_path = data_dir.join("mcp.json");
213 if initial_mcp_path.exists() {
214 let _ = initial_changes.send(ConfigPathChanges {
215 paths: vec![initial_mcp_path],
216 initial_mcp: true,
217 });
218 }
219
220 let apply_provider_health = provider_health.clone();
221 let apply_mcp_health = mcp_health.clone();
222 let apply_task = tokio::spawn(async move {
223 while let Some(changes) = changes_rx.recv().await {
224 let watched_sections = config_facade
225 .as_ref()
226 .map(|_| {
227 changes
228 .paths
229 .iter()
230 .filter_map(|path| {
231 path.file_name()
232 .and_then(|name| name.to_str())
233 .and_then(SectionId::from_file_name)
234 })
235 .collect::<BTreeSet<_>>()
236 })
237 .unwrap_or_default();
238 let provider_watched = changes.paths.iter().any(|path| {
239 path.file_name().and_then(|name| name.to_str()) == Some("providers.json")
240 });
241 let mcp_watched = changes.paths.iter().any(|path| {
242 path.file_name().and_then(|name| name.to_str()) == Some("mcp.json")
243 });
244 let ordinary_watched = watched_sections
245 .iter()
246 .copied()
247 .filter(|id| !matches!(id, SectionId::Providers | SectionId::Mcp))
248 .collect::<Vec<_>>();
249 if !provider_watched && !mcp_watched && ordinary_watched.is_empty() {
250 continue;
251 }
252
253 let _io = config_io_lock.lock().await;
257 if let Some(facade) = config_facade.as_ref() {
258 let mut publishable = Vec::new();
259 for id in ordinary_watched {
260 wait_for_section_file_settle(&data_dir, id).await;
261 let Some(event) = facade.registry().reload_if_changed(id) else {
262 continue;
263 };
264 if matches!(event, ConfigSectionEvent::Invalid { .. }) {
265 publish_registry_event(&account_sink, &event);
266 } else {
267 publishable.push((id, event));
268 }
269 }
270 if !publishable.is_empty() {
271 let materialized = materialize_facade_effective_config(facade, &data_dir);
272 let mut current = config.read().await.clone();
273 let mut applied = Vec::new();
274 for (id, event) in publishable {
275 if materialized.failures.contains(&id) {
276 if let Some(invalid) = facade.registry().mark_runtime_degraded(
277 id,
278 "configuration runtime hydration failed; retaining last-known-good runtime",
279 ) {
280 publish_registry_event(&account_sink, &invalid);
281 }
282 continue;
283 }
284 apply_runtime_section(id, &materialized.config, &mut current);
285 applied.push((id, event));
286 }
287 if !applied.is_empty() {
288 let publishes_env = applied.iter().any(|(id, _)| *id == SectionId::Env);
289 let enforcement_newly_off =
290 !config.read().await.plugin_trust.enforcement_is_off()
291 && current.plugin_trust.enforcement_is_off();
292 *config.write().await = current.clone();
293 if publishes_env {
294 current.publish_env_vars();
295 }
296 if enforcement_newly_off {
297 warn_plugin_trust_enforcement_off();
298 }
299 for (_, event) in applied {
300 publish_registry_event(&account_sink, &event);
301 }
302 }
303 }
304 }
305 if provider_watched {
306 if let Some(facade) = config_facade.as_ref() {
307 wait_for_section_file_settle(&data_dir, SectionId::Providers).await;
308 if let Some(event) =
309 facade.registry().reload_if_changed(SectionId::Providers)
310 {
311 if matches!(event, ConfigSectionEvent::Invalid { .. }) {
312 publish_section_failure(
313 &apply_provider_health,
314 &account_sink,
315 "providers",
316 facade.registry().providers.snapshot().status,
317 "provider section is invalid; retaining last-known-good runtime"
318 .to_string(),
319 );
320 } else {
321 self_write_marker.mark_self_write(provider_store.path());
322 let materialized =
323 materialize_facade_effective_config(facade, &data_dir);
324 if materialized.failures.contains(&SectionId::Providers) {
325 facade.registry().mark_runtime_degraded(
326 SectionId::Providers,
327 "provider credential hydration failed; retaining last-known-good runtime",
328 );
329 publish_section_failure(
330 &apply_provider_health,
331 &account_sink,
332 "providers",
333 SectionStatus::Degraded,
334 "provider credential hydration failed; retaining last-known-good runtime"
335 .to_string(),
336 );
337 } else {
338 let mut candidate = config.read().await.clone();
339 apply_runtime_section(
340 SectionId::Providers,
341 &materialized.config,
342 &mut candidate,
343 );
344 match prepare_provider_candidate(candidate, &data_dir).await {
345 Ok((candidate, registry, next_provider)) => {
346 let mut live_config = config.write().await;
347 let mut live_provider = provider.write().await;
348 let recovered =
349 section_is_unhealthy(&apply_provider_health);
350 candidate.publish_env_vars();
351 *live_config = candidate;
352 provider_registry.replace_with(registry);
353 *live_provider = next_provider;
354 drop(live_provider);
355 drop(live_config);
356 let revision =
357 facade.registry().providers.snapshot().revision;
358 publish_section_success(
359 &apply_provider_health,
360 &account_sink,
361 "providers",
362 data_dir.join("providers.json"),
363 recovered,
364 Some(revision),
365 );
366 }
367 Err(_) => {
368 facade.registry().mark_runtime_degraded(
369 SectionId::Providers,
370 "provider runtime initialization failed; retaining last-known-good runtime",
371 );
372 publish_section_failure(
373 &apply_provider_health,
374 &account_sink,
375 "providers",
376 SectionStatus::Degraded,
377 "provider runtime initialization failed; retaining last-known-good runtime"
378 .to_string(),
379 );
380 }
381 }
382 }
383 }
384 }
385 } else {
386 let current_config = config.read().await.clone();
387 let current_revision = apply_provider_health
388 .read()
389 .unwrap_or_else(|poisoned| poisoned.into_inner())
390 .revision;
391 let result = load_and_prepare_provider_candidate(
392 &provider_store,
393 current_revision,
394 current_config,
395 )
396 .await;
397 match result {
398 Ok(candidate) if candidate.unchanged => {}
399 Ok(candidate) => {
400 if candidate.normalized_external_revision {
401 self_write_marker.mark_self_write(provider_store.path());
402 }
403 let mut live_config = config.write().await;
404 let mut live_provider = provider.write().await;
405 let recovered = section_is_unhealthy(&apply_provider_health);
406 candidate.config.publish_env_vars();
407 *live_config = candidate.config;
408 provider_registry.replace_with(candidate.registry);
409 *live_provider = candidate.provider;
410 drop(live_provider);
411 drop(live_config);
412
413 publish_section_success(
414 &apply_provider_health,
415 &account_sink,
416 "providers",
417 data_dir.join("providers.json"),
418 recovered,
419 Some(candidate.revision),
420 );
421 }
422 Err(error) => publish_section_failure(
423 &apply_provider_health,
424 &account_sink,
425 "providers",
426 candidate_error_status(&error.kind),
427 error.message,
428 ),
429 }
430 }
431 }
432
433 if mcp_watched {
434 if let Some(facade) = config_facade.as_ref() {
435 wait_for_section_file_settle(&data_dir, SectionId::Mcp).await;
436 let event =
437 facade
438 .registry()
439 .reload_if_changed(SectionId::Mcp)
440 .or_else(|| {
441 changes.initial_mcp.then(|| ConfigSectionEvent::Changed {
442 section: "mcp".to_string(),
443 revision: facade.registry().mcp.snapshot().revision,
444 })
445 });
446 let Some(event) = event else {
447 continue;
448 };
449 if matches!(event, ConfigSectionEvent::Invalid { .. }) {
450 publish_section_failure(
451 &apply_mcp_health,
452 &account_sink,
453 "mcp",
454 facade.registry().mcp.snapshot().status,
455 "MCP section is invalid; retaining last-known-good runtime"
456 .to_string(),
457 );
458 } else {
459 self_write_marker.mark_self_write(mcp_store.path());
460 let materialized =
461 materialize_facade_effective_config(facade, &data_dir);
462 if materialized.failures.contains(&SectionId::Mcp) {
463 facade.registry().mark_runtime_degraded(
464 SectionId::Mcp,
465 "MCP credential hydration failed; retaining last-known-good runtime",
466 );
467 publish_section_failure(
468 &apply_mcp_health,
469 &account_sink,
470 "mcp",
471 SectionStatus::Degraded,
472 "MCP credential hydration failed; retaining last-known-good runtime"
473 .to_string(),
474 );
475 continue;
476 }
477 let next_mcp = materialized.config.mcp.clone();
478 let publish_config = config.clone();
479 match mcp_manager
480 .reconcile_from_config_transactional_after(
481 &materialized.config.mcp,
482 || async move {
483 publish_config.write().await.mcp = next_mcp;
484 Ok(())
485 },
486 )
487 .await
488 {
489 Ok(()) => {
490 let recovered = section_is_unhealthy(&apply_mcp_health);
491 let snapshot = facade.registry().mcp.snapshot();
492 let revision = snapshot.revision;
493 if changes.initial_mcp
494 && snapshot.status == SectionStatus::Healthy
495 {
496 set_live_health_revision(
502 &apply_mcp_health,
503 revision,
504 Some((
505 data_dir.join("mcp.json"),
506 SectionSourceKind::File,
507 )),
508 );
509 } else {
510 publish_section_success(
511 &apply_mcp_health,
512 &account_sink,
513 "mcp",
514 data_dir.join("mcp.json"),
515 recovered,
516 Some(revision),
517 );
518 }
519 }
520 Err(_) => {
521 facade.registry().mark_runtime_degraded(
522 SectionId::Mcp,
523 "MCP runtime initialization failed; retaining last-known-good runtime",
524 );
525 publish_section_failure(
526 &apply_mcp_health,
527 &account_sink,
528 "mcp",
529 SectionStatus::Degraded,
530 "MCP runtime initialization failed; retaining last-known-good runtime"
531 .to_string(),
532 )
533 }
534 }
535 }
536 } else {
537 let current_config = config.read().await.clone();
538 let current_revision = apply_mcp_health
539 .read()
540 .unwrap_or_else(|poisoned| poisoned.into_inner())
541 .revision;
542 let result = load_and_validate_mcp_candidate(
543 &mcp_store,
544 current_revision,
545 current_config,
546 changes.initial_mcp,
547 )
548 .await;
549 match result {
550 Ok(candidate) if candidate.unchanged => {}
551 Ok(candidate) => {
552 if candidate.normalized_external_revision
553 || candidate.source_kind == SectionSourceKind::Backup
554 {
555 self_write_marker.mark_self_write(mcp_store.path());
560 }
561 let next_mcp = candidate.config.mcp.clone();
562 let publish_config = config.clone();
563 match mcp_manager
564 .reconcile_from_config_transactional_after(
565 &candidate.config.mcp,
566 || async move {
567 publish_config.write().await.mcp = next_mcp;
568 Ok(())
569 },
570 )
571 .await
572 {
573 Ok(()) => {
574 let recovered = section_is_unhealthy(&apply_mcp_health);
575 if candidate.source_kind == SectionSourceKind::Backup {
576 publish_mcp_backup_lkg(
577 &apply_mcp_health,
578 &account_sink,
579 candidate.source_path,
580 candidate.revision,
581 );
582 } else {
583 publish_section_success(
584 &apply_mcp_health,
585 &account_sink,
586 "mcp",
587 data_dir.join("mcp.json"),
588 recovered,
589 Some(candidate.revision),
590 );
591 }
592 }
593 Err(_) => publish_section_failure(
594 &apply_mcp_health,
595 &account_sink,
596 "mcp",
597 SectionStatus::Degraded,
598 "MCP runtime initialization failed; retaining last-known-good runtime"
599 .to_string(),
600 ),
601 }
602 }
603 Err(error) => publish_section_failure(
604 &apply_mcp_health,
605 &account_sink,
606 "mcp",
607 candidate_error_status(&error.kind),
608 error.message,
609 ),
610 }
611 }
612 }
613 }
614 });
615
616 (
617 Self {
618 stop,
619 watcher_task: Some(watcher_task),
620 apply_task: Some(apply_task),
621 },
622 provider_health,
623 mcp_health,
624 )
625 }
626}
627
628async fn wait_for_section_file_settle(data_dir: &Path, id: SectionId) {
629 let path = data_dir.join(id.descriptor().file_name);
630 for _ in 0..3 {
631 if path.exists() {
632 return;
633 }
634 tokio::time::sleep(Duration::from_millis(50)).await;
635 }
636}
637
638fn publish_registry_event(
639 account_sink: &bamboo_engine::events::AccountEventSink,
640 event: &ConfigSectionEvent,
641) {
642 let event = match event {
643 ConfigSectionEvent::Changed { section, revision } => AgentEvent::ConfigChanged {
644 section: section.clone(),
645 revision: *revision,
646 },
647 ConfigSectionEvent::Invalid { section, revision } => AgentEvent::ConfigInvalid {
648 section: section.clone(),
649 revision: *revision,
650 },
651 ConfigSectionEvent::Recovered { section, revision } => AgentEvent::ConfigRecovered {
652 section: section.clone(),
653 revision: *revision,
654 },
655 };
656 account_sink.record(None, &event);
657}
658
659fn publish_exact_facade_commit(
660 facade: Option<&std::sync::Arc<bamboo_config::ConfigFacade>>,
661 account_sink: &bamboo_engine::events::AccountEventSink,
662 sections: &[SectionId],
663) -> Result<(), AppError> {
664 let Some(facade) = facade else {
665 return Ok(());
666 };
667 for section in sections {
668 let Some(event) = facade.registry().reload_if_changed(*section) else {
669 continue;
670 };
671 publish_registry_event(account_sink, &event);
672 if matches!(event, ConfigSectionEvent::Invalid { .. }) {
673 return Err(AppError::InternalError(anyhow::anyhow!(
674 "committed configuration section became invalid before publication"
675 )));
676 }
677 }
678 Ok(())
679}
680
681fn apply_runtime_section(id: SectionId, source: &Config, target: &mut Config) {
682 match id {
683 SectionId::Core => {
684 target.http_proxy = source.http_proxy.clone();
685 target.https_proxy = source.https_proxy.clone();
686 target.proxy_auth = source.proxy_auth.clone();
687 target.proxy_auth_encrypted = None;
688 target.proxy_auth_credential_ref = source.proxy_auth_credential_ref.clone();
689 target.headless_auth = source.headless_auth;
690 target.server = source.server.clone();
691 target.default_work_area = source.default_work_area.clone();
692 target.run_budget = source.run_budget;
693 target.stream_timeout = source.stream_timeout;
694 target.extra = source.extra.clone();
695 }
696 SectionId::Providers => {
697 target.provider = source.provider.clone();
698 target.defaults = source.defaults.clone();
699 target.provider_instances = source.provider_instances.clone();
700 target.default_provider_instance = source.default_provider_instance.clone();
701 target.features = source.features.clone();
702 *target.providers_mut() = source.providers().clone();
703 }
704 SectionId::Mcp => target.mcp = source.mcp.clone(),
705 SectionId::ToolsSkills => {
706 target.tools = source.tools.clone();
707 target.skills = source.skills.clone();
708 target.plugin_trust = source.plugin_trust.clone();
709 }
710 SectionId::Memory => *target.memory_mut() = source.memory().clone(),
711 SectionId::Subagents => *target.subagents_mut() = source.subagents().clone(),
712 SectionId::Notifications => target.notifications = source.notifications.clone(),
713 SectionId::Connect => target.connect = source.connect.clone(),
714 SectionId::ClusterFabric => target.cluster_fabric = source.cluster_fabric.clone(),
715 SectionId::Env => target.env_vars = source.env_vars.clone(),
716 SectionId::AccessControl => target.access_control = source.access_control.clone(),
717 SectionId::Hooks => target.hooks = source.hooks.clone(),
718 SectionId::ModelPolicy => {
719 target.keyword_masking = source.keyword_masking.clone();
720 target.anthropic_model_mapping = source.anthropic_model_mapping.clone();
721 target.gemini_model_mapping = source.gemini_model_mapping.clone();
722 }
723 SectionId::ModelLimits | SectionId::Credentials => {}
724 }
725}
726
727fn section_is_unhealthy(health: &std::sync::RwLock<ConfigLiveHealth>) -> bool {
728 health
729 .read()
730 .unwrap_or_else(|poisoned| poisoned.into_inner())
731 .status
732 != SectionStatus::Healthy
733}
734
735fn publish_section_success(
736 health: &std::sync::RwLock<ConfigLiveHealth>,
737 account_sink: &bamboo_engine::events::AccountEventSink,
738 section: &str,
739 source_path: PathBuf,
740 recovered: bool,
741 revision: Option<u64>,
742) {
743 let revision = match revision {
744 Some(revision) => set_live_health_revision(
745 health,
746 revision,
747 Some((source_path, SectionSourceKind::File)),
748 ),
749 None => update_live_health(
750 health,
751 SectionStatus::Healthy,
752 None,
753 true,
754 Some((source_path, SectionSourceKind::File)),
755 ),
756 };
757 let event = if recovered {
758 AgentEvent::ConfigRecovered {
759 section: section.to_string(),
760 revision,
761 }
762 } else {
763 AgentEvent::ConfigChanged {
764 section: section.to_string(),
765 revision,
766 }
767 };
768 account_sink.record(None, &event);
769}
770
771fn publish_section_failure(
772 health: &std::sync::RwLock<ConfigLiveHealth>,
773 account_sink: &bamboo_engine::events::AccountEventSink,
774 section: &str,
775 status: SectionStatus,
776 message: String,
777) {
778 let revision = update_live_health(health, status, Some(message), false, None);
779 account_sink.record(
780 None,
781 &AgentEvent::ConfigInvalid {
782 section: section.to_string(),
783 revision,
784 },
785 );
786}
787
788fn publish_mcp_backup_lkg(
789 health: &std::sync::RwLock<ConfigLiveHealth>,
790 account_sink: &bamboo_engine::events::AccountEventSink,
791 source_path: PathBuf,
792 revision: u64,
793) {
794 {
795 let mut health = health
796 .write()
797 .unwrap_or_else(|poisoned| poisoned.into_inner());
798 health.revision = revision;
799 health.loaded_at = Utc::now();
800 health.source_path = source_path;
801 health.source_kind = SectionSourceKind::Backup;
802 health.status = SectionStatus::Degraded;
803 health.last_error =
804 Some("primary MCP section invalid; running last-known-good backup runtime".to_string());
805 }
806 account_sink.record(
807 None,
808 &AgentEvent::ConfigInvalid {
809 section: "mcp".to_string(),
810 revision,
811 },
812 );
813}
814
815fn initial_provider_health(store: &AtomicJsonStore<ProviderConfigs>) -> ConfigLiveHealth {
816 if ensure_provider_mcp_migration_ready(store.path().parent().unwrap_or_else(|| Path::new(".")))
817 .is_err()
818 {
819 return ConfigLiveHealth {
820 revision: 0,
821 loaded_at: Utc::now(),
822 source_path: store.path().to_path_buf(),
823 source_kind: SectionSourceKind::File,
824 status: SectionStatus::Degraded,
825 last_error: Some("provider/MCP credential migration is pending".to_string()),
826 };
827 }
828 match store.load_validated_allowing_unversioned(|_| Ok(())) {
829 Ok(Some(stored)) => ConfigLiveHealth {
830 revision: stored.revision,
831 loaded_at: Utc::now(),
832 source_path: stored.source_path,
833 source_kind: if stored.recovered_from_backup {
834 SectionSourceKind::Backup
835 } else {
836 SectionSourceKind::File
837 },
838 status: if stored.recovered_from_backup {
839 SectionStatus::Degraded
840 } else {
841 SectionStatus::Healthy
842 },
843 last_error: stored.recovered_from_backup.then(|| {
844 "primary provider section invalid; using last-known-good backup".to_string()
845 }),
846 },
847 Ok(None) => ConfigLiveHealth {
848 revision: 0,
849 loaded_at: Utc::now(),
850 source_path: store.path().to_path_buf(),
851 source_kind: SectionSourceKind::Default,
852 status: SectionStatus::Missing,
853 last_error: None,
854 },
855 Err(_) => ConfigLiveHealth {
856 revision: 0,
857 loaded_at: Utc::now(),
858 source_path: store.path().to_path_buf(),
859 source_kind: SectionSourceKind::File,
860 status: SectionStatus::Invalid,
861 last_error: Some("provider section could not be parsed or read".to_string()),
862 },
863 }
864}
865
866fn initial_mcp_health(store: &AtomicJsonStore<McpConfig>) -> ConfigLiveHealth {
867 if ensure_provider_mcp_migration_ready(store.path().parent().unwrap_or_else(|| Path::new(".")))
868 .is_err()
869 {
870 return ConfigLiveHealth {
871 revision: 0,
872 loaded_at: Utc::now(),
873 source_path: store.path().to_path_buf(),
874 source_kind: SectionSourceKind::File,
875 status: SectionStatus::Degraded,
876 last_error: Some("provider/MCP credential migration is pending".to_string()),
877 };
878 }
879 match store.load_validated(validate_mcp_config) {
880 Ok(Some(stored)) => ConfigLiveHealth {
881 revision: 0,
884 loaded_at: Utc::now(),
885 source_path: store.path().to_path_buf(),
886 source_kind: if stored.recovered_from_backup {
887 SectionSourceKind::Backup
888 } else {
889 SectionSourceKind::File
890 },
891 status: SectionStatus::Degraded,
892 last_error: Some(if stored.recovered_from_backup {
893 "primary MCP section invalid; runtime initialization pending from backup"
894 .to_string()
895 } else {
896 "MCP runtime initialization pending".to_string()
897 }),
898 },
899 Ok(None) => ConfigLiveHealth {
900 revision: 0,
901 loaded_at: Utc::now(),
902 source_path: store.path().to_path_buf(),
903 source_kind: SectionSourceKind::Default,
904 status: SectionStatus::Missing,
905 last_error: None,
906 },
907 Err(_) => ConfigLiveHealth {
908 revision: 0,
909 loaded_at: Utc::now(),
910 source_path: store.path().to_path_buf(),
911 source_kind: SectionSourceKind::File,
912 status: SectionStatus::Invalid,
913 last_error: Some("MCP section could not be parsed or validated".to_string()),
914 },
915 }
916}
917
918impl Drop for ConfigWatcherRuntime {
919 fn drop(&mut self) {
920 self.stop.store(true, Ordering::Relaxed);
921 if let Some(task) = self.apply_task.take() {
922 task.abort();
923 }
924 if let Some(task) = self.watcher_task.take() {
925 let _ = task.join();
926 }
927 }
928}
929
930async fn load_and_prepare_provider_candidate(
931 store: &AtomicJsonStore<ProviderConfigs>,
932 current_revision: u64,
933 candidate_config: Config,
934) -> Result<ProviderCandidate, ProviderCandidateError> {
935 ensure_provider_mcp_migration_ready(store.path().parent().unwrap_or_else(|| Path::new(".")))
936 .map_err(|_| {
937 ProviderCandidateError::invalid(
938 "provider/MCP credential migration is pending; retaining last-known-good runtime",
939 )
940 })?;
941 for _ in 0..3 {
944 if store.path().exists() {
945 break;
946 }
947 tokio::time::sleep(Duration::from_millis(50)).await;
948 }
949 if !store.path().exists() {
950 return Err(ProviderCandidateError::missing());
951 }
952 let stored = store
953 .load_validated_for_reload_allowing_unversioned(
954 current_revision,
955 candidate_config.providers(),
956 validate_provider_config,
957 )
958 .map_err(|_| {
959 if store.path().exists() {
960 ProviderCandidateError::invalid("provider section is invalid")
961 } else {
962 ProviderCandidateError::missing()
963 }
964 })?
965 .ok_or_else(ProviderCandidateError::missing)?;
966 if stored.recovered_from_backup {
967 return Err(ProviderCandidateError::invalid(
968 "primary provider section is invalid; retaining last-known-good runtime",
969 ));
970 }
971 let unchanged = stored.revision == current_revision
972 && serde_json::to_value(&stored.data).ok()
973 == serde_json::to_value(candidate_config.providers()).ok();
974 let mut candidate_config = candidate_config;
975 *candidate_config.providers_mut() = stored.data;
976 let (candidate_config, registry, provider) = prepare_provider_candidate(
977 candidate_config,
978 store
979 .path()
980 .parent()
981 .unwrap_or_else(|| std::path::Path::new(".")),
982 )
983 .await?;
984 Ok(ProviderCandidate {
985 config: candidate_config,
986 registry,
987 provider,
988 revision: stored.revision,
989 normalized_external_revision: stored.normalized_external_revision,
990 unchanged,
991 })
992}
993
994async fn prepare_provider_candidate(
995 mut candidate_config: Config,
996 data_dir: &std::path::Path,
997) -> Result<(Config, bamboo_llm::ProviderRegistry, Arc<dyn LLMProvider>), ProviderCandidateError> {
998 candidate_config.hydrate_provider_api_keys_from_encrypted();
999 candidate_config
1000 .hydrate_provider_credentials_from_store(data_dir)
1001 .map_err(|_| ProviderCandidateError::invalid("provider credential is unavailable"))?;
1002 let candidate_registry =
1003 bamboo_llm::ProviderRegistry::from_config(&candidate_config, data_dir.to_path_buf())
1004 .await
1005 .map_err(|_| ProviderCandidateError::runtime())?;
1006 let candidate_provider = candidate_registry
1007 .get_default()
1008 .ok_or_else(ProviderCandidateError::runtime)?;
1009 Ok((candidate_config, candidate_registry, candidate_provider))
1010}
1011
1012struct ProviderCandidate {
1013 config: Config,
1014 registry: bamboo_llm::ProviderRegistry,
1015 provider: Arc<dyn LLMProvider>,
1016 revision: u64,
1017 normalized_external_revision: bool,
1018 unchanged: bool,
1019}
1020
1021async fn load_and_validate_mcp_candidate(
1022 store: &AtomicJsonStore<McpConfig>,
1023 current_revision: u64,
1024 mut candidate_config: Config,
1025 allow_startup_backup: bool,
1026) -> Result<McpCandidate, ProviderCandidateError> {
1027 ensure_provider_mcp_migration_ready(store.path().parent().unwrap_or_else(|| Path::new(".")))
1028 .map_err(|_| {
1029 ProviderCandidateError::invalid(
1030 "provider/MCP credential migration is pending; retaining last-known-good runtime",
1031 )
1032 })?;
1033 for _ in 0..3 {
1034 if store.path().exists() {
1035 break;
1036 }
1037 tokio::time::sleep(Duration::from_millis(50)).await;
1038 }
1039 if !store.path().exists() {
1040 return Err(ProviderCandidateError::missing_section(
1041 "MCP section is missing",
1042 ));
1043 }
1044 let current_document = mcp_durable_comparison_document(&candidate_config.mcp);
1045 let stored = store
1046 .load_validated_for_reload(current_revision, ¤t_document, validate_mcp_config)
1047 .map_err(|_| ProviderCandidateError::invalid("MCP section is invalid"))?
1048 .ok_or_else(|| ProviderCandidateError::missing_section("MCP section is missing"))?;
1049 if stored.recovered_from_backup && !allow_startup_backup {
1050 return Err(ProviderCandidateError::invalid(
1051 "primary MCP section is invalid; retaining last-known-good runtime",
1052 ));
1053 }
1054 let unchanged = !allow_startup_backup
1055 && stored.revision == current_revision
1056 && serde_json::to_value(&stored.data).ok() == serde_json::to_value(¤t_document).ok();
1057 candidate_config.mcp = stored.data;
1058 candidate_config.hydrate_mcp_secrets_from_encrypted();
1059 candidate_config
1060 .hydrate_mcp_credentials_from_store(
1061 store
1062 .path()
1063 .parent()
1064 .unwrap_or_else(|| std::path::Path::new(".")),
1065 )
1066 .map_err(|_| ProviderCandidateError::invalid("MCP credential is unavailable"))?;
1067 Ok(McpCandidate {
1068 config: candidate_config,
1069 revision: stored.revision,
1070 source_kind: if stored.recovered_from_backup {
1071 SectionSourceKind::Backup
1072 } else {
1073 SectionSourceKind::File
1074 },
1075 source_path: stored.source_path,
1076 normalized_external_revision: stored.normalized_external_revision,
1077 unchanged,
1078 })
1079}
1080
1081struct McpCandidate {
1082 config: Config,
1083 revision: u64,
1084 source_kind: SectionSourceKind,
1085 source_path: PathBuf,
1086 normalized_external_revision: bool,
1087 unchanged: bool,
1088}
1089
1090fn mcp_durable_comparison_document(config: &McpConfig) -> McpConfig {
1094 let mut document = config.clone();
1095 for server in &mut document.servers {
1096 match &mut server.transport {
1097 TransportConfig::Stdio(config) => {
1098 config.env.retain(|name, _| {
1099 !config.env_encrypted.contains_key(name)
1100 && !config.env_credential_refs.contains_key(name)
1101 });
1102 }
1103 TransportConfig::Sse(config) => clear_paired_header_plaintext(&mut config.headers),
1104 TransportConfig::StreamableHttp(config) => {
1105 clear_paired_header_plaintext(&mut config.headers)
1106 }
1107 }
1108 }
1109 document
1110}
1111
1112fn clear_paired_header_plaintext(headers: &mut [bamboo_mcp::HeaderConfig]) {
1113 for header in headers {
1114 if header.value_encrypted.is_some() || header.credential_ref.is_some() {
1115 header.value.clear();
1116 }
1117 }
1118}
1119
1120fn validate_mcp_config(config: &McpConfig) -> Result<(), String> {
1121 let mut ids = std::collections::HashSet::new();
1122 for server in &config.servers {
1123 if server.id.trim().is_empty() {
1124 return Err("MCP server id cannot be empty".to_string());
1125 }
1126 if !ids.insert(server.id.as_str()) {
1127 return Err(format!("duplicate MCP server id '{}'", server.id));
1128 }
1129 if server.request_timeout_ms == 0 || server.healthcheck_interval_ms == 0 {
1130 return Err(format!(
1131 "MCP server '{}' timeouts must be non-zero",
1132 server.id
1133 ));
1134 }
1135 match &server.transport {
1136 TransportConfig::Stdio(stdio) if stdio.command.trim().is_empty() => {
1137 return Err(format!(
1138 "MCP stdio server '{}' command cannot be empty",
1139 server.id
1140 ));
1141 }
1142 TransportConfig::Sse(sse) if sse.url.trim().is_empty() => {
1143 return Err(format!(
1144 "MCP SSE server '{}' URL cannot be empty",
1145 server.id
1146 ));
1147 }
1148 TransportConfig::StreamableHttp(http) if http.url.trim().is_empty() => {
1149 return Err(format!(
1150 "MCP HTTP server '{}' URL cannot be empty",
1151 server.id
1152 ));
1153 }
1154 _ => {}
1155 }
1156 match &server.transport {
1157 TransportConfig::Stdio(stdio) => {
1158 if !stdio.env_encrypted.is_empty()
1159 || stdio.env.iter().any(|(name, value)| {
1160 !value.is_empty() && !stdio.env_credential_refs.contains_key(name)
1161 })
1162 {
1163 return Err(format!(
1164 "MCP server '{}' contains a secret outside the credential store",
1165 server.id
1166 ));
1167 }
1168 for raw in stdio.env_credential_refs.values() {
1169 bamboo_config::CredentialRef::parse(raw.clone())
1170 .map_err(|_| "MCP credential reference is invalid".to_string())?;
1171 }
1172 }
1173 TransportConfig::Sse(config) => validate_header_refs(&server.id, &config.headers)?,
1174 TransportConfig::StreamableHttp(config) => {
1175 validate_header_refs(&server.id, &config.headers)?
1176 }
1177 }
1178 }
1179 Ok(())
1180}
1181
1182fn validate_provider_config(providers: &ProviderConfigs) -> Result<(), String> {
1183 macro_rules! validate {
1184 ($field:ident) => {
1185 if let Some(provider) = &providers.$field {
1186 if provider.api_key_encrypted.is_some()
1187 || (!provider.api_key.trim().is_empty()
1188 && !provider.api_key_from_env
1189 && provider.credential_ref.is_none())
1190 {
1191 return Err("provider secret is outside the credential store".to_string());
1192 }
1193 }
1194 };
1195 }
1196 validate!(openai);
1197 validate!(anthropic);
1198 validate!(gemini);
1199 if let Some(provider) = &providers.bodhi {
1200 if provider.api_key_encrypted.is_some()
1201 || (!provider.api_key.trim().is_empty() && provider.credential_ref.is_none())
1202 {
1203 return Err("provider secret is outside the credential store".to_string());
1204 }
1205 }
1206 Ok(())
1207}
1208
1209fn validate_header_refs(
1210 server_id: &str,
1211 headers: &[bamboo_mcp::HeaderConfig],
1212) -> Result<(), String> {
1213 for header in headers {
1214 if header.value_encrypted.is_some()
1215 || (!header.value.is_empty() && header.credential_ref.is_none())
1216 {
1217 return Err(format!(
1218 "MCP server '{server_id}' contains a secret outside the credential store"
1219 ));
1220 }
1221 if let Some(raw) = &header.credential_ref {
1222 bamboo_config::CredentialRef::parse(raw.clone())
1223 .map_err(|_| "MCP credential reference is invalid".to_string())?;
1224 }
1225 }
1226 Ok(())
1227}
1228
1229enum ProviderCandidateErrorKind {
1230 Missing,
1231 InvalidDocument,
1232 Runtime,
1233}
1234
1235struct ProviderCandidateError {
1236 kind: ProviderCandidateErrorKind,
1237 message: String,
1238}
1239
1240impl ProviderCandidateError {
1241 fn missing() -> Self {
1242 Self {
1243 kind: ProviderCandidateErrorKind::Missing,
1244 message: "provider section is missing".to_string(),
1245 }
1246 }
1247
1248 fn invalid(message: &str) -> Self {
1249 Self {
1250 kind: ProviderCandidateErrorKind::InvalidDocument,
1251 message: message.to_string(),
1252 }
1253 }
1254
1255 fn missing_section(message: &str) -> Self {
1256 Self {
1257 kind: ProviderCandidateErrorKind::Missing,
1258 message: message.to_string(),
1259 }
1260 }
1261
1262 fn runtime() -> Self {
1263 Self {
1264 kind: ProviderCandidateErrorKind::Runtime,
1265 message: "provider runtime initialization failed".to_string(),
1266 }
1267 }
1268}
1269
1270fn candidate_error_status(kind: &ProviderCandidateErrorKind) -> SectionStatus {
1271 match kind {
1272 ProviderCandidateErrorKind::Missing => SectionStatus::Missing,
1273 ProviderCandidateErrorKind::InvalidDocument => SectionStatus::Invalid,
1274 ProviderCandidateErrorKind::Runtime => SectionStatus::Degraded,
1275 }
1276}
1277
1278fn update_live_health(
1279 health: &std::sync::RwLock<ConfigLiveHealth>,
1280 status: SectionStatus,
1281 last_error: Option<String>,
1282 advance_revision: bool,
1283 source: Option<(PathBuf, SectionSourceKind)>,
1284) -> u64 {
1285 let mut health = health
1286 .write()
1287 .unwrap_or_else(|poisoned| poisoned.into_inner());
1288 if advance_revision {
1289 health.revision = health.revision.saturating_add(1);
1290 }
1291 health.loaded_at = Utc::now();
1292 health.status = status;
1293 health.last_error = last_error;
1294 if let Some((source_path, source_kind)) = source {
1295 health.source_path = source_path;
1296 health.source_kind = source_kind;
1297 }
1298 health.revision
1299}
1300
1301fn set_live_health_revision(
1302 health: &std::sync::RwLock<ConfigLiveHealth>,
1303 revision: u64,
1304 source: Option<(PathBuf, SectionSourceKind)>,
1305) -> u64 {
1306 let mut health = health
1307 .write()
1308 .unwrap_or_else(|poisoned| poisoned.into_inner());
1309 health.revision = revision;
1310 health.loaded_at = Utc::now();
1311 health.status = SectionStatus::Healthy;
1312 health.last_error = None;
1313 if let Some((source_path, source_kind)) = source {
1314 health.source_path = source_path;
1315 health.source_kind = source_kind;
1316 }
1317 revision
1318}
1319
1320#[derive(Debug)]
1321pub(crate) enum ConfigSectionMutationError {
1322 Store(ConfigStoreError),
1323 Invalid(String),
1324 Runtime(String),
1325}
1326
1327impl AppState {
1328 pub(crate) async fn put_ordinary_section(
1332 &self,
1333 id: SectionId,
1334 expected_revision: u64,
1335 candidate: Value,
1336 ) -> Result<u64, ConfigSectionMutationError> {
1337 if matches!(
1338 id,
1339 SectionId::Providers | SectionId::Mcp | SectionId::Credentials
1340 ) {
1341 return Err(ConfigSectionMutationError::Invalid(
1342 "this section requires its dedicated endpoint".to_string(),
1343 ));
1344 }
1345 let _io = self.config_io_lock.lock().await;
1346 ensure_provider_mcp_migration_ready(&self.app_data_dir)
1347 .map_err(ConfigSectionMutationError::Store)?;
1348 let facade = self.config_facade.as_ref().ok_or_else(|| {
1349 ConfigSectionMutationError::Invalid(
1350 "typed section writes require the modular configuration facade".to_string(),
1351 )
1352 })?;
1353 let current = facade
1354 .registry()
1355 .envelope_value(id)
1356 .map_err(ConfigSectionMutationError::Store)?;
1357 if credential_reference_inventory(¤t.data)
1358 != credential_reference_inventory(&candidate)
1359 {
1360 return Err(ConfigSectionMutationError::Invalid(
1361 "credential references are server-managed; use the credential or domain API"
1362 .to_string(),
1363 ));
1364 }
1365
1366 let event = facade
1367 .registry()
1368 .commit_value(id, expected_revision, candidate)
1369 .map_err(ConfigSectionMutationError::Store)?;
1370 let revision = match &event {
1371 ConfigSectionEvent::Changed { revision, .. }
1372 | ConfigSectionEvent::Recovered { revision, .. }
1373 | ConfigSectionEvent::Invalid { revision, .. } => *revision,
1374 };
1375 let materialized = materialize_facade_effective_config(facade, &self.app_data_dir);
1376 if materialized.failures.contains(&id) {
1377 let message =
1378 "configuration runtime hydration failed; retaining last-known-good runtime"
1379 .to_string();
1380 if let Some(invalid) = facade.registry().mark_runtime_degraded(id, message.clone()) {
1381 publish_registry_event(&self.account_sink, &invalid);
1382 }
1383 return Err(ConfigSectionMutationError::Runtime(message));
1384 }
1385
1386 let mut live = self.config.read().await.clone();
1387 let enforcement_newly_off = id == SectionId::ToolsSkills
1388 && !live.plugin_trust.enforcement_is_off()
1389 && materialized.config.plugin_trust.enforcement_is_off();
1390 apply_runtime_section(id, &materialized.config, &mut live);
1391 if id == SectionId::Env {
1392 live.publish_env_vars();
1393 }
1394 *self.config.write().await = live;
1395 if enforcement_newly_off {
1396 warn_plugin_trust_enforcement_off();
1397 }
1398 publish_registry_event(&self.account_sink, &event);
1399 Ok(revision)
1400 }
1401
1402 pub(crate) async fn put_provider_section(
1405 &self,
1406 expected_revision: u64,
1407 mut providers: ProviderConfigs,
1408 ) -> Result<u64, ConfigSectionMutationError> {
1409 let _io = self.config_io_lock.lock().await;
1410 ensure_provider_mcp_migration_ready(&self.app_data_dir)
1411 .map_err(ConfigSectionMutationError::Store)?;
1412 let current = self.config.read().await.clone();
1413 retain_provider_credentials(current.providers(), &mut providers);
1414 let mut candidate = current;
1415 *candidate.providers_mut() = providers.clone();
1416 let (candidate, registry, provider) =
1417 match prepare_provider_candidate(candidate, &self.app_data_dir).await {
1418 Ok(prepared) => prepared,
1419 Err(_) => {
1420 let message =
1421 "provider runtime initialization failed; retaining last-known-good runtime"
1422 .to_string();
1423 publish_section_failure(
1424 &self.config_live_health,
1425 &self.account_sink,
1426 "providers",
1427 SectionStatus::Degraded,
1428 message.clone(),
1429 );
1430 return Err(ConfigSectionMutationError::Runtime(message));
1431 }
1432 };
1433
1434 let durable_providers = provider_durable_document(&providers)?;
1435 let mut live_config = self.config.write().await;
1439 let mut live_provider = self.provider.write().await;
1440 let (revision, source_path) = if let Some(facade) = self.config_facade.as_ref() {
1441 let mut section = facade.registry().providers.snapshot().data.as_ref().clone();
1442 section.providers = durable_providers;
1443 let event = facade
1444 .registry()
1445 .providers
1446 .commit(expected_revision, section)
1447 .map_err(ConfigSectionMutationError::Store)?;
1448 let ConfigSectionEvent::Changed { revision, .. } = event else {
1449 unreachable!("a successful section commit is changed")
1450 };
1451 (
1452 revision,
1453 facade.registry().providers.snapshot().source_path.clone(),
1454 )
1455 } else {
1456 let store = AtomicJsonStore::new(self.app_data_dir.join("providers.json"), 1);
1457 let revision = store
1458 .commit_allowing_unversioned(
1459 expected_revision,
1460 durable_providers,
1461 validate_provider_config,
1462 )
1463 .map_err(ConfigSectionMutationError::Store)?;
1464 (revision, store.path().to_path_buf())
1465 };
1466
1467 candidate.publish_env_vars();
1468 *live_config = candidate;
1469 self.provider_registry.replace_with(registry);
1470 *live_provider = provider;
1471 publish_section_success(
1472 &self.config_live_health,
1473 &self.account_sink,
1474 "providers",
1475 source_path,
1476 section_is_unhealthy(&self.config_live_health),
1477 Some(revision),
1478 );
1479 Ok(revision)
1480 }
1481
1482 pub(crate) async fn put_provider_settings<F>(
1487 &self,
1488 expected_revision: u64,
1489 update: F,
1490 ) -> Result<u64, ConfigSectionMutationError>
1491 where
1492 F: FnOnce(
1493 &Config,
1494 &mut Config,
1495 )
1496 -> Result<(BTreeSet<String>, BTreeSet<String>), ConfigSectionMutationError>
1497 + Send
1498 + 'static,
1499 {
1500 let config_io_lock = self.config_io_lock.clone();
1501 let config = self.config.clone();
1502 let app_data_dir = self.app_data_dir.clone();
1503 let config_facade = self.config_facade.clone();
1504 let account_sink = self.account_sink.clone();
1505 let provider_registry = self.provider_registry.clone();
1506 let provider = self.provider.clone();
1507 let config_live_health = self.config_live_health.clone();
1508 let transaction = tokio::spawn(async move {
1509 let _io = config_io_lock.lock().await;
1510 ensure_provider_mcp_migration_ready(&app_data_dir)
1511 .map_err(ConfigSectionMutationError::Store)?;
1512 let facade = config_facade.as_ref().ok_or_else(|| {
1513 ConfigSectionMutationError::Invalid(
1514 "provider settings require the modular configuration facade".to_string(),
1515 )
1516 })?;
1517 let current = config.read().await.clone();
1518 let mut candidate = current.clone();
1519 let (provider_intents, provider_instance_intents) = update(¤t, &mut candidate)?;
1520
1521 let (candidate, registry, candidate_provider) =
1522 match prepare_provider_candidate(candidate, &app_data_dir).await {
1523 Ok(prepared) => prepared,
1524 Err(_) => {
1525 let message =
1526 "provider runtime initialization failed; retaining last-known-good runtime"
1527 .to_string();
1528 publish_section_failure(
1529 &config_live_health,
1530 &account_sink,
1531 "providers",
1532 SectionStatus::Degraded,
1533 message.clone(),
1534 );
1535 return Err(ConfigSectionMutationError::Runtime(message));
1536 }
1537 };
1538
1539 let mut live_config = config.write().await;
1544 let mut live_provider = provider.write().await;
1545 let transaction_dir = app_data_dir.clone();
1546 let committed = tokio::task::spawn_blocking(move || {
1547 let mut durable_candidate = candidate;
1548 bamboo_config::persist_provider_credential_transaction_at_revision(
1549 &transaction_dir,
1550 &mut durable_candidate,
1551 &provider_intents,
1552 &provider_instance_intents,
1553 expected_revision,
1554 )?;
1555 load_committed_effective_config(&transaction_dir)
1556 })
1557 .await
1558 .map_err(|error| {
1559 ConfigSectionMutationError::Runtime(format!(
1560 "provider settings transaction task failed: {error}"
1561 ))
1562 })?
1563 .map_err(ConfigSectionMutationError::Store)?;
1564
1565 committed.publish_env_vars();
1566 *live_config = committed;
1567 provider_registry.replace_with(registry);
1568 *live_provider = candidate_provider;
1569 publish_exact_facade_commit(
1570 Some(facade),
1571 &account_sink,
1572 &[SectionId::Credentials, SectionId::Providers],
1573 )
1574 .map_err(|error| ConfigSectionMutationError::Runtime(error.to_string()))?;
1575 let provider_snapshot = facade.registry().providers.snapshot();
1576 set_live_health_revision(
1577 &config_live_health,
1578 provider_snapshot.revision,
1579 Some((
1580 provider_snapshot.source_path.clone(),
1581 SectionSourceKind::File,
1582 )),
1583 );
1584 Ok(provider_snapshot.revision)
1585 });
1586 transaction.await.map_err(|error| {
1587 ConfigSectionMutationError::Runtime(format!(
1588 "provider settings transaction task failed: {error}"
1589 ))
1590 })?
1591 }
1592
1593 pub(crate) async fn reset_provider_section(
1598 &self,
1599 expected_revision: u64,
1600 ) -> Result<u64, ConfigSectionMutationError> {
1601 let config_io_lock = self.config_io_lock.clone();
1602 let config = self.config.clone();
1603 let app_data_dir = self.app_data_dir.clone();
1604 let config_facade = self.config_facade.clone();
1605 let account_sink = self.account_sink.clone();
1606 let provider_registry = self.provider_registry.clone();
1607 let provider = self.provider.clone();
1608 let config_live_health = self.config_live_health.clone();
1609 let transaction = tokio::spawn(async move {
1610 let _io = config_io_lock.lock().await;
1611 ensure_provider_mcp_migration_ready(&app_data_dir)
1612 .map_err(ConfigSectionMutationError::Store)?;
1613 let facade = config_facade.as_ref().ok_or_else(|| {
1614 ConfigSectionMutationError::Invalid(
1615 "provider reset requires the modular configuration facade".to_string(),
1616 )
1617 })?;
1618 let current = config.read().await.clone();
1619 let provider_intents = BTreeSet::from([
1620 "openai".to_string(),
1621 "anthropic".to_string(),
1622 "gemini".to_string(),
1623 "bodhi".to_string(),
1624 ]);
1625 let provider_instance_intents = current.provider_instances.keys().cloned().collect();
1626 let mut candidate = current;
1627 apply_runtime_section(SectionId::Providers, &Config::default(), &mut candidate);
1628
1629 let transaction_dir = app_data_dir.clone();
1630 let candidate = tokio::task::spawn_blocking(move || {
1631 bamboo_config::persist_provider_reset_credential_transaction_at_revision(
1632 &transaction_dir,
1633 &mut candidate,
1634 &provider_intents,
1635 &provider_instance_intents,
1636 expected_revision,
1637 )?;
1638 load_committed_effective_config(&transaction_dir)
1639 })
1640 .await
1641 .map_err(|error| {
1642 ConfigSectionMutationError::Runtime(format!(
1643 "provider reset transaction task failed: {error}"
1644 ))
1645 })?
1646 .map_err(ConfigSectionMutationError::Store)?;
1647
1648 *config.write().await = candidate.clone();
1649 publish_exact_facade_commit(
1650 Some(facade),
1651 &account_sink,
1652 &[SectionId::Credentials, SectionId::Providers],
1653 )
1654 .map_err(|error| ConfigSectionMutationError::Runtime(error.to_string()))?;
1655 let provider_snapshot = facade.registry().providers.snapshot();
1656 set_live_health_revision(
1657 &config_live_health,
1658 provider_snapshot.revision,
1659 Some((
1660 provider_snapshot.source_path.clone(),
1661 SectionSourceKind::File,
1662 )),
1663 );
1664
1665 match bamboo_llm::ProviderRegistry::from_config(&candidate, app_data_dir).await {
1666 Ok(registry) => {
1667 if let Some(candidate_provider) = registry.get_default() {
1668 provider_registry.replace_with(registry);
1669 *provider.write().await = candidate_provider;
1670 } else {
1671 publish_section_failure(
1672 &config_live_health,
1673 &account_sink,
1674 "providers",
1675 SectionStatus::Degraded,
1676 "provider reset committed; default provider is not initialized"
1677 .to_string(),
1678 );
1679 }
1680 }
1681 Err(error) => {
1682 tracing::warn!(error = %error, "provider reset committed but runtime initialization failed");
1683 publish_section_failure(
1684 &config_live_health,
1685 &account_sink,
1686 "providers",
1687 SectionStatus::Degraded,
1688 "provider reset committed; retaining last-known-good runtime".to_string(),
1689 );
1690 }
1691 }
1692 Ok(provider_snapshot.revision)
1693 });
1694 transaction.await.map_err(|error| {
1695 ConfigSectionMutationError::Runtime(format!(
1696 "provider reset transaction task failed: {error}"
1697 ))
1698 })?
1699 }
1700
1701 pub(crate) async fn put_mcp_section(
1705 &self,
1706 expected_revision: u64,
1707 mut candidate: McpConfig,
1708 ) -> Result<u64, ConfigSectionMutationError> {
1709 let _io = self.config_io_lock.lock().await;
1710 ensure_provider_mcp_migration_ready(&self.app_data_dir)
1711 .map_err(ConfigSectionMutationError::Store)?;
1712 retain_mcp_credentials(
1713 &self.config.read().await.mcp,
1714 &mut candidate,
1715 &BTreeSet::new(),
1716 );
1717 validate_mcp_config(&candidate).map_err(ConfigSectionMutationError::Invalid)?;
1718 let mut hydration_config = Config::default();
1719 hydration_config.mcp = candidate;
1720 hydration_config
1721 .hydrate_mcp_credentials_from_store(&self.app_data_dir)
1722 .map_err(|_| {
1723 ConfigSectionMutationError::Invalid(
1724 "referenced MCP credential is unavailable".to_string(),
1725 )
1726 })?;
1727 let candidate = hydration_config.mcp.clone();
1728 let mut revision = None;
1729 let mut store_error = None;
1730 let durable_candidate = credential_ref_mcp_document(&candidate)?;
1731 let mut next_config = candidate.clone();
1732 retain_mcp_credential_refs(&durable_candidate, &mut next_config);
1733 let result = self
1734 .mcp_manager
1735 .reconcile_from_config_transactional_after(&candidate, || async {
1736 let mut live_config = self.config.write().await;
1740 let commit = if let Some(facade) = self.config_facade.as_ref() {
1741 facade
1742 .registry()
1743 .mcp
1744 .commit(expected_revision, McpSection(durable_candidate))
1745 .map(|event| match event {
1746 ConfigSectionEvent::Changed { revision, .. } => revision,
1747 _ => unreachable!("a successful section commit is changed"),
1748 })
1749 } else {
1750 AtomicJsonStore::new(self.app_data_dir.join("mcp.json"), 1).commit(
1751 expected_revision,
1752 durable_candidate,
1753 validate_mcp_config,
1754 )
1755 };
1756 match commit {
1757 Ok(committed) => {
1758 live_config.mcp = next_config;
1759 revision = Some(committed);
1760 Ok(())
1761 }
1762 Err(error) => {
1763 store_error = Some(error);
1764 Err(bamboo_mcp::McpError::InvalidConfig(
1765 "MCP section durable commit failed".to_string(),
1766 ))
1767 }
1768 }
1769 })
1770 .await;
1771 if let Some(error) = store_error {
1772 return Err(ConfigSectionMutationError::Store(error));
1773 }
1774 if result.is_err() {
1775 let message =
1776 "MCP runtime initialization failed; retaining last-known-good runtime".to_string();
1777 publish_section_failure(
1778 &self.mcp_config_live_health,
1779 &self.account_sink,
1780 "mcp",
1781 SectionStatus::Degraded,
1782 message.clone(),
1783 );
1784 return Err(ConfigSectionMutationError::Runtime(message));
1785 }
1786 let revision = revision.expect("successful MCP reconcile commits a revision");
1787 publish_section_success(
1788 &self.mcp_config_live_health,
1789 &self.account_sink,
1790 "mcp",
1791 self.app_data_dir.join("mcp.json"),
1792 section_is_unhealthy(&self.mcp_config_live_health),
1793 Some(revision),
1794 );
1795 Ok(revision)
1796 }
1797
1798 pub(crate) async fn put_mcp_settings(
1802 &self,
1803 expected_revision: u64,
1804 candidate: McpConfig,
1805 credential_intents: BTreeSet<bamboo_config::CredentialRef>,
1806 ) -> Result<u64, ConfigSectionMutationError> {
1807 if credential_intents.is_empty() {
1808 return self.put_mcp_section(expected_revision, candidate).await;
1809 }
1810
1811 let config_io_lock = self.config_io_lock.clone();
1812 let config = self.config.clone();
1813 let app_data_dir = self.app_data_dir.clone();
1814 let config_facade = self.config_facade.clone();
1815 let account_sink = self.account_sink.clone();
1816 let mcp_manager = self.mcp_manager.clone();
1817 let mcp_config_live_health = self.mcp_config_live_health.clone();
1818 let transaction = tokio::spawn(async move {
1819 let _io = config_io_lock.lock().await;
1820 ensure_provider_mcp_migration_ready(&app_data_dir)
1821 .map_err(ConfigSectionMutationError::Store)?;
1822 let facade = config_facade.as_ref().ok_or_else(|| {
1823 ConfigSectionMutationError::Invalid(
1824 "MCP settings require the modular configuration facade".to_string(),
1825 )
1826 })?;
1827 let current = config.read().await.clone();
1828 let mut runtime_candidate = candidate;
1829 materialize_mcp_touched_replacements(&mut runtime_candidate, &credential_intents)
1830 .map_err(ConfigSectionMutationError::Invalid)?;
1831 retain_mcp_credentials(¤t.mcp, &mut runtime_candidate, &credential_intents);
1832 validate_mcp_config(&runtime_candidate).map_err(ConfigSectionMutationError::Invalid)?;
1833
1834 let mut transaction_error = None;
1835 let transaction_dir = app_data_dir.clone();
1836 let mut durable_candidate = current;
1837 durable_candidate.mcp = runtime_candidate.clone();
1838 let result = mcp_manager
1839 .reconcile_from_config_transactional_after(&runtime_candidate, || async {
1840 let mut live_config = config.write().await;
1841 let commit = tokio::task::spawn_blocking(move || {
1842 bamboo_config::persist_mcp_credential_transaction_at_revision(
1843 &transaction_dir,
1844 &mut durable_candidate,
1845 &credential_intents,
1846 expected_revision,
1847 )?;
1848 load_committed_effective_config(&transaction_dir)
1849 })
1850 .await;
1851 match commit {
1852 Ok(Ok(committed)) => {
1853 *live_config = committed;
1854 Ok(())
1855 }
1856 Ok(Err(error)) => {
1857 transaction_error = Some(ConfigSectionMutationError::Store(error));
1858 Err(bamboo_mcp::McpError::InvalidConfig(
1859 "MCP settings durable transaction failed".to_string(),
1860 ))
1861 }
1862 Err(error) => {
1863 transaction_error = Some(ConfigSectionMutationError::Runtime(format!(
1864 "MCP settings transaction task failed: {error}"
1865 )));
1866 Err(bamboo_mcp::McpError::InvalidConfig(
1867 "MCP settings durable transaction failed".to_string(),
1868 ))
1869 }
1870 }
1871 })
1872 .await;
1873 if let Some(error) = transaction_error {
1874 return Err(error);
1875 }
1876 if result.is_err() {
1877 let message =
1878 "MCP runtime initialization failed; retaining last-known-good runtime"
1879 .to_string();
1880 publish_section_failure(
1881 &mcp_config_live_health,
1882 &account_sink,
1883 "mcp",
1884 SectionStatus::Degraded,
1885 message.clone(),
1886 );
1887 return Err(ConfigSectionMutationError::Runtime(message));
1888 }
1889
1890 publish_exact_facade_commit(
1891 Some(facade),
1892 &account_sink,
1893 &[SectionId::Credentials, SectionId::Mcp],
1894 )
1895 .map_err(|error| ConfigSectionMutationError::Runtime(error.to_string()))?;
1896 let snapshot = facade.registry().mcp.snapshot();
1897 set_live_health_revision(
1898 &mcp_config_live_health,
1899 snapshot.revision,
1900 Some((snapshot.source_path.clone(), SectionSourceKind::File)),
1901 );
1902 Ok(snapshot.revision)
1903 });
1904 transaction.await.map_err(|error| {
1905 ConfigSectionMutationError::Runtime(format!(
1906 "MCP settings transaction task failed: {error}"
1907 ))
1908 })?
1909 }
1910
1911 pub(crate) async fn reset_mcp_section(
1915 &self,
1916 expected_revision: u64,
1917 ) -> Result<u64, ConfigSectionMutationError> {
1918 let _io = self.config_io_lock.lock().await;
1919 ensure_provider_mcp_migration_ready(&self.app_data_dir)
1920 .map_err(ConfigSectionMutationError::Store)?;
1921 let facade = self.config_facade.as_ref().ok_or_else(|| {
1922 ConfigSectionMutationError::Invalid(
1923 "MCP reset requires the modular configuration facade".to_string(),
1924 )
1925 })?;
1926 let candidate_mcp = McpConfig::default();
1927 let mut candidate_config = self.config.read().await.clone();
1928 candidate_config.mcp = candidate_mcp.clone();
1929 let mut committed_config = None;
1930 let mut store_error = None;
1931 let data_dir = self.app_data_dir.clone();
1932 let result = self
1933 .mcp_manager
1934 .reconcile_from_config_transactional_after(&candidate_mcp, || async {
1935 let mut live_config = self.config.write().await;
1936 match bamboo_config::persist_mcp_reset_credential_transaction_at_revision(
1937 &data_dir,
1938 &mut candidate_config,
1939 expected_revision,
1940 )
1941 .and_then(|_| load_committed_effective_config(&data_dir))
1942 {
1943 Ok(committed) => {
1944 *live_config = committed.clone();
1945 committed_config = Some(committed);
1946 Ok(())
1947 }
1948 Err(error) => {
1949 store_error = Some(error);
1950 Err(bamboo_mcp::McpError::InvalidConfig(
1951 "MCP reset durable commit failed".to_string(),
1952 ))
1953 }
1954 }
1955 })
1956 .await;
1957 if let Some(error) = store_error {
1958 return Err(ConfigSectionMutationError::Store(error));
1959 }
1960 if result.is_err() || committed_config.is_none() {
1961 let message =
1962 "MCP reset runtime initialization failed; retaining last-known-good runtime"
1963 .to_string();
1964 publish_section_failure(
1965 &self.mcp_config_live_health,
1966 &self.account_sink,
1967 "mcp",
1968 SectionStatus::Degraded,
1969 message.clone(),
1970 );
1971 return Err(ConfigSectionMutationError::Runtime(message));
1972 }
1973 publish_exact_facade_commit(
1974 Some(facade),
1975 &self.account_sink,
1976 &[SectionId::Credentials, SectionId::Mcp],
1977 )
1978 .map_err(|error| ConfigSectionMutationError::Runtime(error.to_string()))?;
1979 let revision = facade.registry().mcp.snapshot().revision;
1980 publish_section_success(
1981 &self.mcp_config_live_health,
1982 &self.account_sink,
1983 "mcp",
1984 self.app_data_dir.join("mcp.json"),
1985 section_is_unhealthy(&self.mcp_config_live_health),
1986 Some(revision),
1987 );
1988 Ok(revision)
1989 }
1990
1991 pub(crate) async fn reset_credential_backed_section(
1996 &self,
1997 id: SectionId,
1998 expected_revision: u64,
1999 ) -> Result<u64, ConfigSectionMutationError> {
2000 if !matches!(
2001 id,
2002 SectionId::Core
2003 | SectionId::Notifications
2004 | SectionId::Connect
2005 | SectionId::Env
2006 | SectionId::ClusterFabric
2007 | SectionId::AccessControl
2008 ) {
2009 return Err(ConfigSectionMutationError::Invalid(
2010 "section is not a credential-backed reset domain".to_string(),
2011 ));
2012 }
2013 let config_io_lock = self.config_io_lock.clone();
2014 let config = self.config.clone();
2015 let app_data_dir = self.app_data_dir.clone();
2016 let config_facade = self.config_facade.clone();
2017 let account_sink = self.account_sink.clone();
2018 let provider_registry = self.provider_registry.clone();
2019 let provider = self.provider.clone();
2020 let mcp_manager = self.mcp_manager.clone();
2021 let transaction = tokio::spawn(async move {
2022 let _io = config_io_lock.lock().await;
2023 ensure_provider_mcp_migration_ready(&app_data_dir)
2024 .map_err(ConfigSectionMutationError::Store)?;
2025 let facade = config_facade.as_ref().ok_or_else(|| {
2026 ConfigSectionMutationError::Invalid(
2027 "section reset requires the modular configuration facade".to_string(),
2028 )
2029 })?;
2030 let mut candidate = config.read().await.clone();
2031 apply_runtime_section(id, &Config::default(), &mut candidate);
2032 let transaction_dir = app_data_dir.clone();
2033 let candidate = tokio::task::spawn_blocking(move || {
2034 bamboo_config::persist_credential_backed_section_reset_at_revision(
2035 &transaction_dir,
2036 &mut candidate,
2037 id,
2038 expected_revision,
2039 )?;
2040 load_committed_effective_config(&transaction_dir)
2041 })
2042 .await
2043 .map_err(|error| {
2044 ConfigSectionMutationError::Runtime(format!(
2045 "section reset transaction task failed: {error}"
2046 ))
2047 })?
2048 .map_err(ConfigSectionMutationError::Store)?;
2049
2050 if id == SectionId::Env {
2051 candidate.publish_env_vars();
2052 }
2053 *config.write().await = candidate.clone();
2054 publish_exact_facade_commit(Some(facade), &account_sink, &[SectionId::Credentials, id])
2055 .map_err(|error| ConfigSectionMutationError::Runtime(error.to_string()))?;
2056
2057 if id == SectionId::Core {
2058 match bamboo_llm::ProviderRegistry::from_config(&candidate, app_data_dir.clone())
2059 .await
2060 {
2061 Ok(registry) => {
2062 if let Some(candidate_provider) = registry.get_default() {
2063 provider_registry.replace_with(registry);
2064 *provider.write().await = candidate_provider;
2065 }
2066 }
2067 Err(error) => {
2068 tracing::warn!(error = %error, "core reset committed but provider reload failed");
2069 }
2070 }
2071 mcp_manager.reconcile_from_config(&candidate.mcp).await;
2072 }
2073
2074 facade
2075 .registry()
2076 .envelope_value(id)
2077 .map(|envelope| envelope.revision)
2078 .map_err(ConfigSectionMutationError::Store)
2079 });
2080 transaction.await.map_err(|error| {
2081 ConfigSectionMutationError::Runtime(format!(
2082 "section reset transaction task failed: {error}"
2083 ))
2084 })?
2085 }
2086}
2087
2088fn credential_reference_inventory(value: &Value) -> std::collections::BTreeMap<String, Value> {
2089 fn collect(value: &Value, path: &str, output: &mut std::collections::BTreeMap<String, Value>) {
2090 match value {
2091 Value::Object(object) => {
2092 for (key, value) in object {
2093 let child_path =
2094 format!("{path}/{}", key.replace('~', "~0").replace('/', "~1"));
2095 let normalized = key
2096 .chars()
2097 .filter(|ch| ch.is_ascii_alphanumeric())
2098 .flat_map(char::to_lowercase)
2099 .collect::<String>();
2100 if normalized == "credentialref"
2101 || normalized.ends_with("credentialref")
2102 || normalized.ends_with("credentialrefs")
2103 {
2104 output.insert(child_path, value.clone());
2105 } else {
2106 collect(value, &child_path, output);
2107 }
2108 }
2109 }
2110 Value::Array(values) => {
2111 for (index, value) in values.iter().enumerate() {
2112 collect(value, &format!("{path}/{index}"), output);
2113 }
2114 }
2115 _ => {}
2116 }
2117 }
2118
2119 let mut output = std::collections::BTreeMap::new();
2120 collect(value, "", &mut output);
2121 output
2122}
2123
2124fn provider_durable_document(
2125 providers: &ProviderConfigs,
2126) -> Result<ProviderConfigs, ConfigSectionMutationError> {
2127 let mut document = providers.clone();
2128 macro_rules! sanitize {
2129 ($field:ident) => {
2130 if let Some(provider) = document.$field.as_mut() {
2131 provider.api_key.clear();
2132 provider.api_key_encrypted = None;
2133 }
2134 };
2135 }
2136 sanitize!(openai);
2137 sanitize!(anthropic);
2138 sanitize!(gemini);
2139 if let Some(provider) = document.bodhi.as_mut() {
2140 provider.api_key.clear();
2141 provider.api_key_encrypted = None;
2142 }
2143 validate_provider_config(&document).map_err(ConfigSectionMutationError::Invalid)?;
2144 Ok(document)
2145}
2146
2147fn retain_provider_credentials(current: &ProviderConfigs, candidate: &mut ProviderConfigs) {
2148 candidate.extra = current.extra.clone();
2149 macro_rules! retain {
2150 ($field:ident) => {
2151 if let (Some(current), Some(candidate)) = (¤t.$field, &mut candidate.$field) {
2152 candidate.api_key = current.api_key.clone();
2153 candidate.api_key_encrypted = current.api_key_encrypted.clone();
2154 if candidate.credential_ref.is_none() {
2155 candidate.credential_ref = current.credential_ref.clone();
2156 }
2157 if candidate.credential_ref != current.credential_ref {
2158 candidate.api_key.clear();
2159 candidate.api_key_encrypted = None;
2160 }
2161 candidate.api_key_from_env = current.api_key_from_env;
2162 candidate.request_overrides = current.request_overrides.clone();
2163 candidate.extra = current.extra.clone();
2164 }
2165 };
2166 }
2167 retain!(openai);
2168 retain!(anthropic);
2169 retain!(gemini);
2170 if let (Some(current), Some(candidate)) = (¤t.bodhi, &mut candidate.bodhi) {
2171 candidate.api_key = current.api_key.clone();
2172 candidate.api_key_encrypted = current.api_key_encrypted.clone();
2173 if candidate.credential_ref.is_none() {
2174 candidate.credential_ref = current.credential_ref.clone();
2175 }
2176 if candidate.credential_ref != current.credential_ref {
2177 candidate.api_key.clear();
2178 candidate.api_key_encrypted = None;
2179 }
2180 candidate.extra = current.extra.clone();
2181 }
2182 if let (Some(current), Some(candidate)) = (¤t.copilot, &mut candidate.copilot) {
2183 candidate.request_overrides = current.request_overrides.clone();
2184 candidate.extra = current.extra.clone();
2185 }
2186}
2187
2188fn retain_mcp_credentials(
2189 current: &McpConfig,
2190 candidate: &mut McpConfig,
2191 touched: &BTreeSet<bamboo_config::CredentialRef>,
2192) {
2193 for candidate_server in &mut candidate.servers {
2194 let Some(current_server) = current
2195 .servers
2196 .iter()
2197 .find(|server| server.id == candidate_server.id)
2198 else {
2199 continue;
2200 };
2201 if let (TransportConfig::Stdio(current), TransportConfig::Stdio(candidate)) =
2202 (¤t_server.transport, &mut candidate_server.transport)
2203 {
2204 if candidate.env.is_empty()
2205 && candidate.env_encrypted.is_empty()
2206 && candidate.env_credential_refs.is_empty()
2207 {
2208 for (name, reference) in ¤t.env_credential_refs {
2209 if mcp_credential_ref_is_touched(Some(reference), touched) {
2210 continue;
2211 }
2212 candidate
2213 .env_credential_refs
2214 .insert(name.clone(), reference.clone());
2215 if let Some(value) = current.env.get(name) {
2216 candidate.env.insert(name.clone(), value.clone());
2217 }
2218 if let Some(value) = current.env_encrypted.get(name) {
2219 candidate.env_encrypted.insert(name.clone(), value.clone());
2220 }
2221 }
2222 } else {
2223 for (name, reference) in ¤t.env_credential_refs {
2224 if candidate.env_credential_refs.get(name) != Some(reference) {
2225 continue;
2226 }
2227 if candidate.env.get(name).is_none_or(|value| value.is_empty()) {
2228 if let Some(value) = current.env.get(name) {
2229 candidate.env.insert(name.clone(), value.clone());
2230 }
2231 }
2232 }
2233 }
2234 }
2235 match (¤t_server.transport, &mut candidate_server.transport) {
2236 (TransportConfig::Sse(current), TransportConfig::Sse(candidate)) => {
2237 retain_mcp_header_credentials(¤t.headers, &mut candidate.headers)
2238 }
2239 (
2240 TransportConfig::StreamableHttp(current),
2241 TransportConfig::StreamableHttp(candidate),
2242 ) => retain_mcp_header_credentials(¤t.headers, &mut candidate.headers),
2243 _ => {}
2244 }
2245 }
2246}
2247
2248fn materialize_mcp_touched_replacements(
2249 candidate: &mut McpConfig,
2250 touched: &BTreeSet<bamboo_config::CredentialRef>,
2251) -> Result<(), String> {
2252 let mut replacements = BTreeMap::<bamboo_config::CredentialRef, String>::new();
2253 for server in &candidate.servers {
2254 match &server.transport {
2255 TransportConfig::Stdio(stdio) => {
2256 for (name, raw_reference) in &stdio.env_credential_refs {
2257 let reference = bamboo_config::CredentialRef::parse(raw_reference.clone())
2258 .map_err(|_| "MCP credential reference is invalid".to_string())?;
2259 if let Some(value) = stdio
2260 .env
2261 .get(name)
2262 .filter(|value| touched.contains(&reference) && !value.is_empty())
2263 {
2264 insert_mcp_replacement(&mut replacements, reference, value)?;
2265 }
2266 }
2267 }
2268 TransportConfig::Sse(http) => {
2269 collect_mcp_header_replacements(&http.headers, touched, &mut replacements)?
2270 }
2271 TransportConfig::StreamableHttp(http) => {
2272 collect_mcp_header_replacements(&http.headers, touched, &mut replacements)?
2273 }
2274 }
2275 }
2276 if replacements.is_empty() {
2277 return Ok(());
2278 }
2279 for server in &mut candidate.servers {
2280 match &mut server.transport {
2281 TransportConfig::Stdio(stdio) => {
2282 for (name, raw_reference) in &stdio.env_credential_refs {
2283 let reference = bamboo_config::CredentialRef::parse(raw_reference.clone())
2284 .map_err(|_| "MCP credential reference is invalid".to_string())?;
2285 if let Some(value) = replacements.get(&reference) {
2286 stdio.env.insert(name.clone(), value.clone());
2287 }
2288 }
2289 }
2290 TransportConfig::Sse(http) => {
2291 apply_mcp_header_replacements(&mut http.headers, &replacements)?
2292 }
2293 TransportConfig::StreamableHttp(http) => {
2294 apply_mcp_header_replacements(&mut http.headers, &replacements)?
2295 }
2296 }
2297 }
2298 Ok(())
2299}
2300
2301fn collect_mcp_header_replacements(
2302 headers: &[bamboo_mcp::HeaderConfig],
2303 touched: &BTreeSet<bamboo_config::CredentialRef>,
2304 replacements: &mut BTreeMap<bamboo_config::CredentialRef, String>,
2305) -> Result<(), String> {
2306 for header in headers {
2307 let Some(raw_reference) = header.credential_ref.as_ref() else {
2308 continue;
2309 };
2310 let reference = bamboo_config::CredentialRef::parse(raw_reference.clone())
2311 .map_err(|_| "MCP credential reference is invalid".to_string())?;
2312 if touched.contains(&reference) && !header.value.is_empty() {
2313 insert_mcp_replacement(replacements, reference, &header.value)?;
2314 }
2315 }
2316 Ok(())
2317}
2318
2319fn insert_mcp_replacement(
2320 replacements: &mut BTreeMap<bamboo_config::CredentialRef, String>,
2321 reference: bamboo_config::CredentialRef,
2322 value: &str,
2323) -> Result<(), String> {
2324 match replacements.get(&reference) {
2325 Some(existing) if existing != value => {
2326 Err("MCP updates assign conflicting values to one credential reference".to_string())
2327 }
2328 Some(_) => Ok(()),
2329 None => {
2330 replacements.insert(reference, value.to_string());
2331 Ok(())
2332 }
2333 }
2334}
2335
2336fn apply_mcp_header_replacements(
2337 headers: &mut [bamboo_mcp::HeaderConfig],
2338 replacements: &BTreeMap<bamboo_config::CredentialRef, String>,
2339) -> Result<(), String> {
2340 for header in headers {
2341 let Some(raw_reference) = header.credential_ref.as_ref() else {
2342 continue;
2343 };
2344 let reference = bamboo_config::CredentialRef::parse(raw_reference.clone())
2345 .map_err(|_| "MCP credential reference is invalid".to_string())?;
2346 if let Some(value) = replacements.get(&reference) {
2347 header.value = value.clone();
2348 }
2349 }
2350 Ok(())
2351}
2352
2353fn mcp_credential_ref_is_touched(
2354 raw_reference: Option<&String>,
2355 touched: &BTreeSet<bamboo_config::CredentialRef>,
2356) -> bool {
2357 raw_reference
2358 .and_then(|raw| bamboo_config::CredentialRef::parse(raw.clone()).ok())
2359 .is_some_and(|reference| touched.contains(&reference))
2360}
2361
2362fn retain_mcp_header_credentials(
2363 current: &[bamboo_mcp::HeaderConfig],
2364 candidate: &mut [bamboo_mcp::HeaderConfig],
2365) {
2366 for candidate_header in candidate {
2367 let Some(current_header) = current
2368 .iter()
2369 .find(|header| header.name == candidate_header.name)
2370 else {
2371 continue;
2372 };
2373 if candidate_header.credential_ref == current_header.credential_ref
2374 && candidate_header.value.is_empty()
2375 {
2376 candidate_header.value = current_header.value.clone();
2377 candidate_header.value_encrypted = current_header.value_encrypted.clone();
2378 }
2379 }
2380}
2381
2382fn credential_ref_mcp_document(
2383 runtime: &McpConfig,
2384) -> Result<McpConfig, ConfigSectionMutationError> {
2385 let mut document = runtime.clone();
2386 for server in &mut document.servers {
2387 match &mut server.transport {
2388 TransportConfig::Stdio(config) => {
2389 config.env_encrypted.clear();
2390 config.env.retain(|name, value| {
2391 !(value.is_empty() || config.env_credential_refs.contains_key(name))
2392 });
2393 if !config.env.is_empty() {
2394 return Err(ConfigSectionMutationError::Invalid(
2395 "MCP secret requires a credential reference".to_string(),
2396 ));
2397 }
2398 }
2399 TransportConfig::Sse(config) => reference_headers(&mut config.headers)?,
2400 TransportConfig::StreamableHttp(config) => reference_headers(&mut config.headers)?,
2401 }
2402 }
2403 Ok(document)
2404}
2405
2406fn retain_mcp_credential_refs(document: &McpConfig, runtime: &mut McpConfig) {
2407 for runtime_server in &mut runtime.servers {
2408 let Some(document_server) = document
2409 .servers
2410 .iter()
2411 .find(|server| server.id == runtime_server.id)
2412 else {
2413 continue;
2414 };
2415 match (&document_server.transport, &mut runtime_server.transport) {
2416 (TransportConfig::Stdio(document), TransportConfig::Stdio(runtime)) => {
2417 runtime.env_encrypted.clear();
2418 runtime.env_credential_refs = document.env_credential_refs.clone();
2419 }
2420 (TransportConfig::Sse(document), TransportConfig::Sse(runtime)) => {
2421 copy_header_ciphertext(&document.headers, &mut runtime.headers);
2422 }
2423 (
2424 TransportConfig::StreamableHttp(document),
2425 TransportConfig::StreamableHttp(runtime),
2426 ) => copy_header_ciphertext(&document.headers, &mut runtime.headers),
2427 _ => {}
2428 }
2429 }
2430}
2431
2432fn copy_header_ciphertext(
2433 document: &[bamboo_mcp::HeaderConfig],
2434 runtime: &mut [bamboo_mcp::HeaderConfig],
2435) {
2436 for runtime_header in runtime {
2437 if let Some(document_header) = document
2438 .iter()
2439 .find(|header| header.name == runtime_header.name)
2440 {
2441 runtime_header.value_encrypted = None;
2442 runtime_header.credential_ref = document_header.credential_ref.clone();
2443 }
2444 }
2445}
2446
2447fn reference_headers(
2448 headers: &mut [bamboo_mcp::HeaderConfig],
2449) -> Result<(), ConfigSectionMutationError> {
2450 for header in headers {
2451 if !header.value.is_empty() && header.credential_ref.is_none() {
2452 return Err(ConfigSectionMutationError::Invalid(
2453 "MCP secret requires a credential reference".to_string(),
2454 ));
2455 }
2456 header.value.clear();
2457 header.value_encrypted = None;
2458 }
2459 Ok(())
2460}
2461
2462impl AppState {
2463 pub async fn reload_provider(&self) -> Result<(), bamboo_llm::LLMError> {
2495 let config = self.config.read().await.clone();
2496 let candidate_registry =
2497 bamboo_llm::ProviderRegistry::from_config(&config, self.app_data_dir.clone()).await?;
2498 let default_provider_name = candidate_registry.default_provider_name();
2499 tracing::info!(
2500 default_provider = %default_provider_name,
2501 legacy_provider = %config.provider,
2502 has_provider_instances = config.has_provider_instances(),
2503 "Reloading provider runtime from current config"
2504 );
2505
2506 let new_provider = candidate_registry.get_default().ok_or_else(|| {
2507 let message = if config.has_provider_instances() {
2508 format!(
2509 "Default provider instance '{}' is not available or failed to initialize",
2510 default_provider_name
2511 )
2512 } else {
2513 format!(
2514 "Provider '{}' is not available or failed to initialize",
2515 config.provider
2516 )
2517 };
2518 bamboo_llm::LLMError::Auth(message)
2519 })?;
2520
2521 let mut provider = self.provider.write().await;
2522 self.provider_registry.replace_with(candidate_registry);
2523 *provider = new_provider;
2524
2525 tracing::info!(
2526 default_provider = %default_provider_name,
2527 "Provider reloaded successfully"
2528 );
2529 Ok(())
2530 }
2531
2532 pub async fn reload_config(&self) -> Config {
2562 let _io = self.config_io_lock.lock().await;
2567 let mut config = self.config.write().await;
2568 let new_config = self
2569 .config_facade
2570 .as_ref()
2571 .map(|facade| load_facade_effective_config(facade, &self.app_data_dir))
2572 .unwrap_or_else(|| {
2573 Config::from_data_dir_without_publish(Some(self.app_data_dir.clone()))
2574 });
2575 new_config.publish_env_vars();
2576 *config = new_config.clone();
2577 new_config
2578 }
2579
2580 async fn persist_config_snapshot(&self, config: Config) -> anyhow::Result<()> {
2581 let data_dir = self.app_data_dir.clone();
2582 if self.config_facade.is_some() {
2583 tokio::task::spawn_blocking(move || {
2584 bamboo_config::persist_facade_effective_config(data_dir, &config)
2585 })
2586 .await
2587 .map_err(|e| anyhow::anyhow!("Config save task failed: {e}"))??;
2588 let sections = bamboo_config::SECTION_DESCRIPTORS
2589 .iter()
2590 .map(|descriptor| descriptor.id)
2591 .collect::<Vec<_>>();
2592 publish_exact_facade_commit(self.config_facade.as_ref(), &self.account_sink, §ions)
2593 .map_err(|error| anyhow::anyhow!(error.to_string()))?;
2594 } else {
2595 tokio::task::spawn_blocking(move || config.save_to_dir(data_dir))
2596 .await
2597 .map_err(|e| anyhow::anyhow!("Config save task failed: {e}"))??;
2598 }
2599 Ok(())
2600 }
2601
2602 pub async fn update_config<F>(
2609 &self,
2610 update: F,
2611 effects: ConfigUpdateEffects,
2612 ) -> Result<Config, AppError>
2613 where
2614 F: FnOnce(&mut Config) -> Result<(), AppError>,
2615 {
2616 let snapshot = {
2622 let _io = self.config_io_lock.lock().await;
2623 let (snapshot, enforcement_newly_off) = {
2624 let cfg = self.config.read().await;
2625 reject_if_recovery_pending(&cfg)?;
2632 let was_off = cfg.plugin_trust.enforcement_is_off();
2633 let mut candidate = cfg.clone();
2634 update(&mut candidate)?;
2635 candidate.assign_connect_platform_ids();
2641 candidate.refresh_encrypted_secrets().map_err(|e| {
2649 AppError::InternalError(anyhow::anyhow!(
2650 "Failed to refresh encrypted secrets: {e}"
2651 ))
2652 })?;
2653 let newly_off = !was_off && candidate.plugin_trust.enforcement_is_off();
2654 (candidate, newly_off)
2655 };
2656 if enforcement_newly_off {
2663 warn_plugin_trust_enforcement_off();
2664 }
2665 self.persist_config_snapshot(snapshot.clone())
2666 .await
2667 .map_err(|e| {
2668 AppError::InternalError(anyhow::anyhow!("Failed to save config: {e}"))
2669 })?;
2670 let snapshot = self
2671 .config_facade
2672 .as_ref()
2673 .map(|facade| load_facade_effective_config(facade, &self.app_data_dir))
2674 .unwrap_or(snapshot);
2675 {
2676 let mut cfg = self.config.write().await;
2677 snapshot.publish_env_vars();
2678 *cfg = snapshot.clone();
2679 }
2680 snapshot
2681 };
2682
2683 self.apply_config_effects(snapshot.clone(), effects).await?;
2684 Ok(snapshot)
2685 }
2686
2687 pub async fn update_config_with_provider_credentials<F>(
2690 &self,
2691 update: F,
2692 provider_intents: std::collections::BTreeSet<String>,
2693 provider_instance_intents: std::collections::BTreeSet<String>,
2694 effects: ConfigUpdateEffects,
2695 ) -> Result<Config, AppError>
2696 where
2697 F: FnOnce(&mut Config) -> Result<(), AppError>,
2698 {
2699 if provider_intents.is_empty() && provider_instance_intents.is_empty() {
2700 return self.update_config(update, effects).await;
2701 }
2702 let config_facade = self.config_facade.clone();
2703 let account_sink = self.account_sink.clone();
2704 let (snapshot, enforcement_newly_off) = {
2705 let _io = self.config_io_lock.lock().await;
2706 let (mut candidate, enforcement_newly_off) = {
2707 let cfg = self.config.read().await;
2708 reject_if_recovery_pending(&cfg)?;
2709 let was_off = cfg.plugin_trust.enforcement_is_off();
2710 let mut candidate = cfg.clone();
2711 update(&mut candidate)?;
2712 let mut non_provider_candidate = candidate.clone();
2718 apply_runtime_section(SectionId::Providers, &cfg, &mut non_provider_candidate);
2719 let mut changed =
2720 bamboo_config::changed_facade_sections(&cfg, &non_provider_candidate).map_err(
2721 |_| {
2722 AppError::InternalError(anyhow::anyhow!(
2723 "failed to compare modular configuration sections"
2724 ))
2725 },
2726 )?;
2727 if serde_json::to_value(cfg.subagents()).ok()
2732 == serde_json::to_value(candidate.subagents()).ok()
2733 {
2734 changed.retain(|section| *section != SectionId::Subagents);
2735 }
2736 if let Some(other) = changed
2737 .into_iter()
2738 .find(|section| *section != SectionId::Providers)
2739 {
2740 return Err(AppError::BadRequest(format!(
2741 "provider credential updates cannot be combined with {} changes; split the request",
2742 other.descriptor().name
2743 )));
2744 }
2745 candidate.assign_connect_platform_ids();
2746 candidate.refresh_encrypted_secrets().map_err(|error| {
2747 AppError::InternalError(anyhow::anyhow!(
2748 "Failed to refresh encrypted secrets: {error}"
2749 ))
2750 })?;
2751 let newly_off = !was_off && candidate.plugin_trust.enforcement_is_off();
2752 (candidate, newly_off)
2753 };
2754 let data_dir = self.app_data_dir.clone();
2755 let candidate = tokio::task::spawn_blocking(move || {
2756 bamboo_config::persist_provider_instance_credential_transaction(
2757 &data_dir,
2758 &mut candidate,
2759 &provider_intents,
2760 &provider_instance_intents,
2761 )?;
2762 load_committed_effective_config(&data_dir)
2763 })
2764 .await
2765 .map_err(|error| {
2766 AppError::InternalError(anyhow::anyhow!(
2767 "provider credential transaction task failed: {error}"
2768 ))
2769 })?
2770 .map_err(|error| match error {
2771 ConfigStoreError::Conflict { expected, actual } => {
2772 AppError::ConfigConflict { expected, actual }
2773 }
2774 ConfigStoreError::Validation(message) => AppError::BadRequest(message),
2775 ConfigStoreError::Io(error) => AppError::StorageError(error),
2776 ConfigStoreError::Json(_) => {
2777 AppError::BadRequest("configuration document is invalid".to_string())
2778 }
2779 ConfigStoreError::Watch(error) => {
2780 AppError::InternalError(anyhow::anyhow!("configuration watch failed: {error}"))
2781 }
2782 })?;
2783 publish_exact_facade_commit(
2784 config_facade.as_ref(),
2785 &account_sink,
2786 &[SectionId::Credentials, SectionId::Providers],
2787 )?;
2788 {
2789 let mut cfg = self.config.write().await;
2790 candidate.publish_env_vars();
2791 *cfg = candidate.clone();
2792 }
2793 (candidate, enforcement_newly_off)
2794 };
2795 if enforcement_newly_off {
2796 warn_plugin_trust_enforcement_off();
2797 }
2798 self.apply_config_effects(snapshot.clone(), effects).await?;
2799 Ok(snapshot)
2800 }
2801
2802 pub async fn update_env_var_credentials<F>(
2806 &self,
2807 expected_revision: u64,
2808 env_intents: std::collections::BTreeSet<String>,
2809 update: F,
2810 ) -> Result<(Config, u64), AppError>
2811 where
2812 F: FnOnce(&mut Config) -> Result<(), AppError> + Send + 'static,
2813 {
2814 let config_io_lock = self.config_io_lock.clone();
2815 let config = self.config.clone();
2816 let app_data_dir = self.app_data_dir.clone();
2817 let account_sink = self.account_sink.clone();
2818 let config_facade = self.config_facade.clone();
2819 let transaction = tokio::spawn(async move {
2820 let _io = config_io_lock.lock().await;
2821 let mut candidate = {
2822 let current = config.read().await;
2823 reject_if_recovery_pending(¤t)?;
2824 let mut candidate = current.clone();
2825 update(&mut candidate)?;
2826 candidate.assign_connect_platform_ids();
2827 candidate
2828 };
2829 let transaction_dir = app_data_dir.clone();
2830 let (candidate, revision) = tokio::task::spawn_blocking(move || {
2831 let revision = bamboo_config::persist_env_var_credential_transaction_at_revision(
2832 &transaction_dir,
2833 &mut candidate,
2834 &env_intents,
2835 expected_revision,
2836 )?;
2837 let committed = load_committed_effective_config(&transaction_dir)?;
2840 Ok::<_, ConfigStoreError>((committed, revision))
2841 })
2842 .await
2843 .map_err(|error| {
2844 AppError::InternalError(anyhow::anyhow!(
2845 "env credential transaction task failed: {error}"
2846 ))
2847 })?
2848 .map_err(|error| match error {
2849 ConfigStoreError::Conflict { expected, actual } => {
2850 AppError::ConfigConflict { expected, actual }
2851 }
2852 ConfigStoreError::Validation(message) => AppError::BadRequest(message),
2853 ConfigStoreError::Io(error) => AppError::StorageError(error),
2854 ConfigStoreError::Json(_) => {
2855 AppError::BadRequest("configuration document is invalid".to_string())
2856 }
2857 ConfigStoreError::Watch(error) => {
2858 AppError::InternalError(anyhow::anyhow!("configuration watch failed: {error}"))
2859 }
2860 })?;
2861 publish_exact_facade_commit(
2862 config_facade.as_ref(),
2863 &account_sink,
2864 &[SectionId::Credentials, SectionId::Env],
2865 )?;
2866 candidate.publish_env_vars();
2867 *config.write().await = candidate.clone();
2868 Ok::<_, AppError>((candidate, revision))
2869 });
2870 transaction.await.map_err(|error| {
2871 AppError::InternalError(anyhow::anyhow!(
2872 "env credential transaction task failed: {error}"
2873 ))
2874 })?
2875 }
2876
2877 pub async fn update_notification_credentials<F>(
2882 &self,
2883 expected_revision: u64,
2884 secret_intents: std::collections::BTreeSet<String>,
2885 reset_domain: bool,
2886 update: F,
2887 ) -> Result<(Config, u64), AppError>
2888 where
2889 F: FnOnce(&mut Config) -> Result<(), AppError> + Send + 'static,
2890 {
2891 let config_io_lock = self.config_io_lock.clone();
2892 let config = self.config.clone();
2893 let app_data_dir = self.app_data_dir.clone();
2894 let account_sink = self.account_sink.clone();
2895 let config_facade = self.config_facade.clone();
2896 let transaction = tokio::spawn(async move {
2897 let _io = config_io_lock.lock().await;
2898 let mut candidate = {
2899 let current = config.read().await;
2900 reject_if_recovery_pending(¤t)?;
2901 let mut candidate = current.clone();
2902 update(&mut candidate)?;
2903 candidate
2904 };
2905 let transaction_dir = app_data_dir.clone();
2906 let (candidate, revision) = tokio::task::spawn_blocking(move || {
2907 let revision =
2908 bamboo_config::persist_notification_credential_transaction_at_revision_with_reset(
2909 &transaction_dir,
2910 &mut candidate,
2911 &secret_intents,
2912 reset_domain,
2913 expected_revision,
2914 )?;
2915 let committed = load_committed_effective_config(&transaction_dir)?;
2916 Ok::<_, ConfigStoreError>((committed, revision))
2917 })
2918 .await
2919 .map_err(|error| {
2920 AppError::InternalError(anyhow::anyhow!(
2921 "notification credential transaction task failed: {error}"
2922 ))
2923 })?
2924 .map_err(|error| match error {
2925 ConfigStoreError::Conflict { expected, actual } => {
2926 AppError::ConfigConflict { expected, actual }
2927 }
2928 ConfigStoreError::Validation(message) => AppError::BadRequest(message),
2929 ConfigStoreError::Io(error) => AppError::StorageError(error),
2930 ConfigStoreError::Json(_) => {
2931 AppError::BadRequest("configuration document is invalid".to_string())
2932 }
2933 ConfigStoreError::Watch(error) => {
2934 AppError::InternalError(anyhow::anyhow!("configuration watch failed: {error}"))
2935 }
2936 })?;
2937 publish_exact_facade_commit(
2938 config_facade.as_ref(),
2939 &account_sink,
2940 &[SectionId::Credentials, SectionId::Notifications],
2941 )?;
2942 *config.write().await = candidate.clone();
2943 Ok::<_, AppError>((candidate, revision))
2944 });
2945 transaction.await.map_err(|error| {
2946 AppError::InternalError(anyhow::anyhow!(
2947 "notification credential transaction task failed: {error}"
2948 ))
2949 })?
2950 }
2951
2952 pub async fn update_connect_credentials<F>(
2957 &self,
2958 expected_revision: u64,
2959 secret_intents: bamboo_config::patch::ConnectSecretIntents,
2960 update: F,
2961 ) -> Result<(Config, u64), AppError>
2962 where
2963 F: FnOnce(&mut Config) -> Result<(), AppError> + Send + 'static,
2964 {
2965 let config_io_lock = self.config_io_lock.clone();
2966 let config = self.config.clone();
2967 let app_data_dir = self.app_data_dir.clone();
2968 let account_sink = self.account_sink.clone();
2969 let config_facade = self.config_facade.clone();
2970 let transaction = tokio::spawn(async move {
2971 let _io = config_io_lock.lock().await;
2972 let mut candidate = {
2973 let current = config.read().await;
2974 reject_if_recovery_pending(¤t)?;
2975 let mut candidate = current.clone();
2976 update(&mut candidate)?;
2977 candidate.assign_connect_platform_ids();
2978 candidate
2979 };
2980 let transaction_dir = app_data_dir.clone();
2981 let (candidate, revision) = tokio::task::spawn_blocking(move || {
2982 let revision = bamboo_config::persist_connect_credential_transaction_at_revision(
2983 &transaction_dir,
2984 &mut candidate,
2985 &secret_intents,
2986 expected_revision,
2987 )?;
2988 let committed = load_committed_effective_config(&transaction_dir)?;
2989 Ok::<_, ConfigStoreError>((committed, revision))
2990 })
2991 .await
2992 .map_err(|error| {
2993 AppError::InternalError(anyhow::anyhow!(
2994 "connect credential transaction task failed: {error}"
2995 ))
2996 })?
2997 .map_err(|error| match error {
2998 ConfigStoreError::Conflict { expected, actual } => {
2999 AppError::ConfigConflict { expected, actual }
3000 }
3001 ConfigStoreError::Validation(message) => AppError::BadRequest(message),
3002 ConfigStoreError::Io(error) => AppError::StorageError(error),
3003 ConfigStoreError::Json(_) => {
3004 AppError::BadRequest("configuration document is invalid".to_string())
3005 }
3006 ConfigStoreError::Watch(error) => {
3007 AppError::InternalError(anyhow::anyhow!("configuration watch failed: {error}"))
3008 }
3009 })?;
3010 publish_exact_facade_commit(
3011 config_facade.as_ref(),
3012 &account_sink,
3013 &[SectionId::Credentials, SectionId::Connect],
3014 )?;
3015 *config.write().await = candidate.clone();
3016 Ok::<_, AppError>((candidate, revision))
3017 });
3018 transaction.await.map_err(|error| {
3019 AppError::InternalError(anyhow::anyhow!(
3020 "connect credential transaction task failed: {error}"
3021 ))
3022 })?
3023 }
3024
3025 pub async fn update_access_control_credentials<F>(
3028 &self,
3029 expected_revision: u64,
3030 password_intent: bool,
3031 device_intents: std::collections::BTreeSet<String>,
3032 update: F,
3033 ) -> Result<(Config, u64), AppError>
3034 where
3035 F: FnOnce(&mut Config) -> Result<(), AppError> + Send + 'static,
3036 {
3037 let config_io_lock = self.config_io_lock.clone();
3038 let config = self.config.clone();
3039 let app_data_dir = self.app_data_dir.clone();
3040 let account_sink = self.account_sink.clone();
3041 let config_facade = self.config_facade.clone();
3042 let transaction = tokio::spawn(async move {
3043 let _io = config_io_lock.lock().await;
3044 let mut candidate = {
3045 let current = config.read().await;
3046 reject_if_recovery_pending(¤t)?;
3047 let mut candidate = current.clone();
3048 update(&mut candidate)?;
3049 candidate
3050 };
3051 let transaction_dir = app_data_dir.clone();
3052 let (candidate, revision) = tokio::task::spawn_blocking(move || {
3053 let revision =
3054 bamboo_config::persist_access_control_credential_transaction_at_revision(
3055 &transaction_dir,
3056 &mut candidate,
3057 password_intent,
3058 &device_intents,
3059 expected_revision,
3060 )?;
3061 let committed = load_committed_effective_config(&transaction_dir)?;
3062 Ok::<_, ConfigStoreError>((committed, revision))
3063 })
3064 .await
3065 .map_err(|error| {
3066 AppError::InternalError(anyhow::anyhow!(
3067 "access-control credential transaction task failed: {error}"
3068 ))
3069 })?
3070 .map_err(|error| match error {
3071 ConfigStoreError::Conflict { expected, actual } => {
3072 AppError::ConfigConflict { expected, actual }
3073 }
3074 ConfigStoreError::Validation(message) => AppError::BadRequest(message),
3075 ConfigStoreError::Io(error) => AppError::StorageError(error),
3076 ConfigStoreError::Json(_) => {
3077 AppError::BadRequest("configuration document is invalid".to_string())
3078 }
3079 ConfigStoreError::Watch(error) => {
3080 AppError::InternalError(anyhow::anyhow!("configuration watch failed: {error}"))
3081 }
3082 })?;
3083 publish_exact_facade_commit(
3084 config_facade.as_ref(),
3085 &account_sink,
3086 &[SectionId::Credentials, SectionId::AccessControl],
3087 )?;
3088 *config.write().await = candidate.clone();
3089 Ok::<_, AppError>((candidate, revision))
3090 });
3091 transaction.await.map_err(|error| {
3092 AppError::InternalError(anyhow::anyhow!(
3093 "access-control credential transaction task failed: {error}"
3094 ))
3095 })?
3096 }
3097
3098 pub async fn update_cluster_fabric_credentials<F>(
3103 &self,
3104 expected_revision: u64,
3105 node_intents: std::collections::BTreeSet<String>,
3106 update: F,
3107 ) -> Result<(Config, u64), AppError>
3108 where
3109 F: FnOnce(&mut Config) -> Result<(), AppError> + Send + 'static,
3110 {
3111 let config_io_lock = self.config_io_lock.clone();
3112 let config = self.config.clone();
3113 let app_data_dir = self.app_data_dir.clone();
3114 let account_sink = self.account_sink.clone();
3115 let config_facade = self.config_facade.clone();
3116 let transaction = tokio::spawn(async move {
3117 let _io = config_io_lock.lock().await;
3118 let mut candidate = {
3119 let current = config.read().await;
3120 reject_if_recovery_pending(¤t)?;
3121 let mut candidate = current.clone();
3122 update(&mut candidate)?;
3123 candidate
3124 };
3125 let transaction_dir = app_data_dir.clone();
3126 let (candidate, revision) = tokio::task::spawn_blocking(move || {
3127 let revision =
3128 bamboo_config::persist_cluster_fabric_credential_transaction_at_revision(
3129 &transaction_dir,
3130 &mut candidate,
3131 &node_intents,
3132 expected_revision,
3133 )?;
3134 let committed = load_committed_effective_config(&transaction_dir)?;
3135 Ok::<_, ConfigStoreError>((committed, revision))
3136 })
3137 .await
3138 .map_err(|error| {
3139 AppError::InternalError(anyhow::anyhow!(
3140 "cluster credential transaction task failed: {error}"
3141 ))
3142 })?
3143 .map_err(|error| match error {
3144 ConfigStoreError::Conflict { expected, actual } => {
3145 AppError::ConfigConflict { expected, actual }
3146 }
3147 ConfigStoreError::Validation(message) => AppError::BadRequest(message),
3148 ConfigStoreError::Io(error) => AppError::StorageError(error),
3149 ConfigStoreError::Json(_) => {
3150 AppError::BadRequest("configuration document is invalid".to_string())
3151 }
3152 ConfigStoreError::Watch(error) => {
3153 AppError::InternalError(anyhow::anyhow!("configuration watch failed: {error}"))
3154 }
3155 })?;
3156 publish_exact_facade_commit(
3157 config_facade.as_ref(),
3158 &account_sink,
3159 &[SectionId::Credentials, SectionId::ClusterFabric],
3160 )?;
3161 *config.write().await = candidate.clone();
3162 Ok::<_, AppError>((candidate, revision))
3163 });
3164 transaction.await.map_err(|error| {
3165 AppError::InternalError(anyhow::anyhow!(
3166 "cluster credential transaction task failed: {error}"
3167 ))
3168 })?
3169 }
3170
3171 pub async fn update_proxy_auth_credential(
3175 &self,
3176 auth: Option<bamboo_config::ProxyAuth>,
3177 expected_revision: u64,
3178 effects: ConfigUpdateEffects,
3179 ) -> Result<
3180 (
3181 Config,
3182 bamboo_config::CredentialStatus,
3183 bamboo_config::CredentialStoreHealth,
3184 ),
3185 AppError,
3186 > {
3187 self.update_core_with_proxy_credential(expected_revision, effects, move |candidate| {
3188 candidate.proxy_auth = auth;
3189 })
3190 .await
3191 }
3192
3193 async fn update_core_with_proxy_credential<F>(
3194 &self,
3195 expected_revision: u64,
3196 effects: ConfigUpdateEffects,
3197 update: F,
3198 ) -> Result<
3199 (
3200 Config,
3201 bamboo_config::CredentialStatus,
3202 bamboo_config::CredentialStoreHealth,
3203 ),
3204 AppError,
3205 >
3206 where
3207 F: FnOnce(&mut Config) + Send + 'static,
3208 {
3209 let config_io_lock = self.config_io_lock.clone();
3210 let config = self.config.clone();
3211 let app_data_dir = self.app_data_dir.clone();
3212 let credential_store = self.credential_store.clone();
3213 let provider_registry = self.provider_registry.clone();
3214 let provider = self.provider.clone();
3215 let mcp_manager = self.mcp_manager.clone();
3216 let config_facade = self.config_facade.clone();
3217 let account_sink = self.account_sink.clone();
3218
3219 let transaction = tokio::spawn(async move {
3224 let _io = config_io_lock.lock().await;
3225 let mut candidate = {
3226 let cfg = config.read().await;
3227 reject_if_recovery_pending(&cfg)?;
3228 let mut candidate = cfg.clone();
3229 update(&mut candidate);
3230 candidate.assign_connect_platform_ids();
3231 candidate.refresh_encrypted_secrets().map_err(|error| {
3232 AppError::InternalError(anyhow::anyhow!(
3233 "Failed to refresh encrypted secrets: {error}"
3234 ))
3235 })?;
3236 candidate
3237 };
3238 let transaction_dir = app_data_dir.clone();
3239 let status_reference =
3240 candidate
3241 .proxy_auth_credential_ref
3242 .clone()
3243 .unwrap_or_else(|| {
3244 bamboo_config::CredentialRef::parse("proxy.default.auth")
3245 .expect("canonical proxy credential reference is valid")
3246 });
3247 let (candidate, reference) = tokio::task::spawn_blocking(move || {
3248 bamboo_config::persist_proxy_auth_credential_transaction_at_revision(
3249 &transaction_dir,
3250 &mut candidate,
3251 expected_revision,
3252 )?;
3253 Ok::<_, ConfigStoreError>((
3254 load_committed_effective_config(&transaction_dir)?,
3255 status_reference,
3256 ))
3257 })
3258 .await
3259 .map_err(|error| {
3260 AppError::InternalError(anyhow::anyhow!(
3261 "proxy credential transaction task failed: {error}"
3262 ))
3263 })?
3264 .map_err(|error| match error {
3265 ConfigStoreError::Conflict { expected, actual } => {
3266 AppError::ConfigConflict { expected, actual }
3267 }
3268 ConfigStoreError::Validation(message) => AppError::BadRequest(message),
3269 ConfigStoreError::Io(error) => AppError::StorageError(error),
3270 ConfigStoreError::Json(_) => {
3271 AppError::BadRequest("configuration document is invalid".to_string())
3272 }
3273 ConfigStoreError::Watch(error) => {
3274 AppError::InternalError(anyhow::anyhow!("configuration watch failed: {error}"))
3275 }
3276 })?;
3277
3278 publish_exact_facade_commit(
3279 config_facade.as_ref(),
3280 &account_sink,
3281 &[SectionId::Credentials, SectionId::Core],
3282 )?;
3283
3284 candidate.publish_env_vars();
3288 *config.write().await = candidate.clone();
3289
3290 if effects.reload_provider {
3291 match bamboo_llm::ProviderRegistry::from_config(&candidate, app_data_dir.clone())
3292 .await
3293 {
3294 Ok(candidate_registry) => {
3295 if let Some(candidate_provider) = candidate_registry.get_default() {
3296 let mut live_provider = provider.write().await;
3297 provider_registry.replace_with(candidate_registry);
3298 *live_provider = candidate_provider;
3299 } else {
3300 tracing::warn!(
3301 "proxy auth committed but provider reload had no default provider"
3302 );
3303 }
3304 }
3305 Err(error) => {
3306 tracing::warn!(
3307 error = %error,
3308 "proxy auth committed but provider reload failed"
3309 );
3310 }
3311 }
3312 }
3313
3314 if effects.reconcile_mcp {
3315 mcp_manager.reconcile_from_config(&candidate.mcp).await;
3316 }
3317
3318 let (status, health) =
3319 credential_store
3320 .status_with_health(&reference)
3321 .map_err(|error| match error {
3322 ConfigStoreError::Conflict { expected, actual } => {
3323 AppError::ConfigConflict { expected, actual }
3324 }
3325 ConfigStoreError::Validation(_) | ConfigStoreError::Json(_) => {
3326 AppError::InternalError(anyhow::anyhow!(
3327 "credential store validation failed"
3328 ))
3329 }
3330 ConfigStoreError::Io(error) => AppError::StorageError(error),
3331 ConfigStoreError::Watch(error) => AppError::InternalError(anyhow::anyhow!(
3332 "configuration watch failed: {error}"
3333 )),
3334 })?;
3335 Ok::<_, AppError>((candidate, status, health))
3336 });
3337 transaction.await.map_err(|error| {
3338 AppError::InternalError(anyhow::anyhow!(
3339 "proxy credential mutation task failed: {error}"
3340 ))
3341 })?
3342 }
3343
3344 pub async fn replace_config(
3346 &self,
3347 mut new_config: Config,
3348 effects: ConfigUpdateEffects,
3349 ) -> Result<Config, AppError> {
3350 new_config.assign_connect_platform_ids();
3356 new_config.refresh_encrypted_secrets().map_err(|e| {
3359 AppError::InternalError(anyhow::anyhow!("Failed to refresh encrypted secrets: {e}"))
3360 })?;
3361
3362 let new_config = {
3365 let _io = self.config_io_lock.lock().await;
3366 let was_off = {
3367 let cfg = self.config.read().await;
3368 reject_if_recovery_pending(&cfg)?;
3371 cfg.plugin_trust.enforcement_is_off()
3372 };
3373 self.persist_config_snapshot(new_config.clone())
3374 .await
3375 .map_err(|e| {
3376 AppError::InternalError(anyhow::anyhow!("Failed to save config: {e}"))
3377 })?;
3378 let published = self
3379 .config_facade
3380 .as_ref()
3381 .map(|facade| load_facade_effective_config(facade, &self.app_data_dir))
3382 .unwrap_or(new_config);
3383 let enforcement_newly_off = !was_off && published.plugin_trust.enforcement_is_off();
3384 published.publish_env_vars();
3385 *self.config.write().await = published.clone();
3386 if enforcement_newly_off {
3390 warn_plugin_trust_enforcement_off();
3391 }
3392 published
3393 };
3394
3395 self.apply_config_effects(new_config.clone(), effects)
3396 .await?;
3397 Ok(new_config)
3398 }
3399
3400 async fn apply_config_effects(
3401 &self,
3402 new_config: Config,
3403 effects: ConfigUpdateEffects,
3404 ) -> Result<(), AppError> {
3405 if effects.reload_provider {
3406 self.reload_provider().await.map_err(|e| {
3407 AppError::InternalError(anyhow::anyhow!(
3408 "Failed to reload provider after updating config: {e}"
3409 ))
3410 })?;
3411 }
3412
3413 if effects.reconcile_mcp {
3414 self.mcp_manager
3415 .reconcile_from_config(&new_config.mcp)
3416 .await;
3417 }
3418
3419 Ok(())
3420 }
3421
3422 pub async fn confirm_config_recovery(&self, accept: bool) -> Result<Config, AppError> {
3438 let _io = self.config_io_lock.lock().await;
3439
3440 if !accept {
3441 let cfg = self.config.read().await;
3442 return match cfg.recovery_status() {
3443 Some(_) => Ok(cfg.clone()),
3444 None => Err(AppError::BadRequest(
3445 "No pending config-corruption recovery to resolve".to_string(),
3446 )),
3447 };
3448 }
3449
3450 let mut candidate = {
3451 let cfg = self.config.read().await;
3452 match cfg.recovery_status() {
3453 Some(_) => cfg.clone(),
3454 None => {
3455 return Err(AppError::BadRequest(
3456 "No pending config-corruption recovery to resolve".to_string(),
3457 ))
3458 }
3459 }
3460 };
3461
3462 let data_dir = self.app_data_dir.clone();
3463 candidate = tokio::task::spawn_blocking(move || {
3464 candidate
3465 .confirm_recovery_and_save_to_dir(data_dir)
3466 .map(|_| candidate)
3467 })
3468 .await
3469 .map_err(|e| {
3470 AppError::InternalError(anyhow::anyhow!("Config recovery-confirm task failed: {e}"))
3471 })?
3472 .map_err(|e| {
3473 AppError::InternalError(anyhow::anyhow!("Failed to save recovered config: {e}"))
3474 })?;
3475
3476 {
3477 let mut cfg = self.config.write().await;
3478 *cfg = candidate.clone();
3479 cfg.publish_env_vars();
3480 }
3481
3482 Ok(candidate)
3483 }
3484}
3485
3486fn reject_if_recovery_pending(cfg: &Config) -> Result<(), AppError> {
3494 if let Some(status) = cfg.recovery_status() {
3495 if !status.confirmed {
3496 return Err(AppError::ConfigRecoveryPending(format!(
3497 "config.json was recovered from corruption ({:?}) and is awaiting \
3498 confirmation; confirm or reject the recovery (see /bamboo/config/recovery-status \
3499 and /bamboo/config/recovery/confirm) before changing settings",
3500 status.source
3501 )));
3502 }
3503 }
3504 Ok(())
3505}
3506
3507pub(crate) fn warn_plugin_trust_enforcement_off() {
3517 tracing::warn!(
3518 "plugin_trust.enforcement is OFF — plugin installs from ANY URL are accepted \
3519 without host/signature/checksum verification (config.json plugin_trust.enforcement)"
3520 );
3521}
3522
3523#[cfg(test)]
3524mod live_reload_tests {
3525 use super::*;
3526 use bamboo_agent_core::{Message, ToolSchema};
3527 use bamboo_llm::{LLMError, LLMStream};
3528 use bamboo_mcp::{McpServerConfig, ReconnectConfig, StdioConfig};
3529
3530 struct WorkingProvider;
3531
3532 fn disabled_mcp_config(id: &str) -> McpConfig {
3533 McpConfig {
3534 version: 1,
3535 servers: vec![McpServerConfig {
3536 id: id.to_string(),
3537 name: None,
3538 enabled: false,
3539 transport: TransportConfig::Stdio(StdioConfig {
3540 command: "unused-disabled-command".to_string(),
3541 args: vec![],
3542 cwd: None,
3543 env: std::collections::HashMap::new(),
3544 env_encrypted: std::collections::HashMap::new(),
3545 env_credential_refs: std::collections::HashMap::new(),
3546 startup_timeout_ms: 100,
3547 }),
3548 request_timeout_ms: 100,
3549 healthcheck_interval_ms: 100,
3550 reconnect: ReconnectConfig::default(),
3551 allowed_tools: vec![],
3552 denied_tools: vec![],
3553 }],
3554 }
3555 }
3556
3557 fn mcp_document_bytes(revision: u64, config: &McpConfig) -> Vec<u8> {
3558 serde_json::to_vec_pretty(&serde_json::json!({
3559 "schema_version": 1,
3560 "revision": revision,
3561 "data": config,
3562 }))
3563 .unwrap()
3564 }
3565
3566 #[test]
3567 fn touched_shared_mcp_refs_stage_replacements_and_preserve_surviving_clears() {
3568 let shared =
3569 bamboo_config::CredentialRef::parse("mcp.shared.env_token".to_string()).unwrap();
3570 let mut current = disabled_mcp_config("first");
3571 let mut second = current.servers[0].clone();
3572 second.id = "second".to_string();
3573 current.servers.push(second);
3574 for server in &mut current.servers {
3575 let TransportConfig::Stdio(stdio) = &mut server.transport else {
3576 unreachable!()
3577 };
3578 stdio
3579 .env
3580 .insert("TOKEN".to_string(), "old-shared-secret".to_string());
3581 stdio
3582 .env_credential_refs
3583 .insert("TOKEN".to_string(), shared.as_str().to_string());
3584 }
3585 let touched = BTreeSet::from([shared]);
3586
3587 let mut replace = current.clone();
3588 for server in &mut replace.servers {
3589 let TransportConfig::Stdio(stdio) = &mut server.transport else {
3590 unreachable!()
3591 };
3592 stdio.env.get_mut("TOKEN").unwrap().clear();
3593 }
3594 let TransportConfig::Stdio(first) = &mut replace.servers[0].transport else {
3595 unreachable!()
3596 };
3597 first
3598 .env
3599 .insert("TOKEN".to_string(), "new-shared-secret".to_string());
3600 materialize_mcp_touched_replacements(&mut replace, &touched).unwrap();
3601 retain_mcp_credentials(¤t, &mut replace, &touched);
3602 for server in &replace.servers {
3603 let TransportConfig::Stdio(stdio) = &server.transport else {
3604 unreachable!()
3605 };
3606 assert_eq!(stdio.env["TOKEN"], "new-shared-secret");
3607 }
3608
3609 let mut clear_one = current.clone();
3610 for server in &mut clear_one.servers {
3611 let TransportConfig::Stdio(stdio) = &mut server.transport else {
3612 unreachable!()
3613 };
3614 stdio.env.get_mut("TOKEN").unwrap().clear();
3615 }
3616 let TransportConfig::Stdio(first) = &mut clear_one.servers[0].transport else {
3617 unreachable!()
3618 };
3619 first.env.remove("TOKEN");
3620 first.env_credential_refs.remove("TOKEN");
3621 materialize_mcp_touched_replacements(&mut clear_one, &touched).unwrap();
3622 retain_mcp_credentials(¤t, &mut clear_one, &touched);
3623 let TransportConfig::Stdio(first) = &clear_one.servers[0].transport else {
3624 unreachable!()
3625 };
3626 assert!(!first.env.contains_key("TOKEN"));
3627 assert!(!first.env_credential_refs.contains_key("TOKEN"));
3628 let TransportConfig::Stdio(second) = &clear_one.servers[1].transport else {
3629 unreachable!()
3630 };
3631 assert_eq!(second.env["TOKEN"], "old-shared-secret");
3632 assert_eq!(second.env_credential_refs["TOKEN"], "mcp.shared.env_token");
3633
3634 let header_ref =
3635 bamboo_config::CredentialRef::parse("mcp.shared.header_token".to_string()).unwrap();
3636 let current_http = McpConfig {
3637 version: 1,
3638 servers: vec![McpServerConfig {
3639 id: "http".to_string(),
3640 name: None,
3641 enabled: false,
3642 transport: TransportConfig::Sse(bamboo_mcp::SseConfig {
3643 url: "https://example.test/sse".to_string(),
3644 headers: vec![bamboo_mcp::HeaderConfig {
3645 name: "Authorization".to_string(),
3646 value: "old-header-secret".to_string(),
3647 value_encrypted: None,
3648 credential_ref: Some(header_ref.as_str().to_string()),
3649 }],
3650 connect_timeout_ms: 100,
3651 }),
3652 request_timeout_ms: 100,
3653 healthcheck_interval_ms: 100,
3654 reconnect: ReconnectConfig::default(),
3655 allowed_tools: vec![],
3656 denied_tools: vec![],
3657 }],
3658 };
3659 let mut delete_all_headers = current_http.clone();
3660 let TransportConfig::Sse(candidate) = &mut delete_all_headers.servers[0].transport else {
3661 unreachable!()
3662 };
3663 candidate.headers.clear();
3664 let touched = BTreeSet::from([header_ref]);
3665 materialize_mcp_touched_replacements(&mut delete_all_headers, &touched).unwrap();
3666 retain_mcp_credentials(¤t_http, &mut delete_all_headers, &touched);
3667 let TransportConfig::Sse(candidate) = &delete_all_headers.servers[0].transport else {
3668 unreachable!()
3669 };
3670 assert!(candidate.headers.is_empty());
3671 }
3672
3673 fn install_unrecoverable_pending_provider_migration(dir: &Path) {
3674 let transaction_id = uuid::Uuid::new_v4().to_string();
3675 std::fs::write(
3676 dir.join("config.json"),
3677 br#"{"providers":{"openai":{"model":"root-lkg"}}}"#,
3678 )
3679 .unwrap();
3680 std::fs::write(
3681 dir.join("providers.json"),
3682 br#"{"schema_version":1,"revision":2,"data":{"openai":{"model":"partial-must-not-load","credential_ref":"provider.openai.api_key"}}}"#,
3683 )
3684 .unwrap();
3685 std::fs::write(
3686 dir.join("config-credential-migration.json"),
3687 serde_json::to_vec_pretty(&serde_json::json!({
3688 "version": 1,
3689 "transaction_id": transaction_id.clone(),
3690 "stage_dir": format!(".config-credential-stage-v1-{transaction_id}"),
3691 "state": "pending",
3692 "files": [
3693 {
3694 "name": "credentials.json",
3695 "staged_name": "credentials.json",
3696 "sha256": "0".repeat(64),
3697 "sensitive": true
3698 },
3699 {
3700 "name": "providers.json",
3701 "staged_name": "providers.json",
3702 "sha256": "1".repeat(64),
3703 "original_sha256": "2".repeat(64),
3704 "migration_generation": 2,
3705 "sensitive": false
3706 }
3707 ]
3708 }))
3709 .unwrap(),
3710 )
3711 .unwrap();
3712 }
3713
3714 async fn wait_for_mcp_health(
3715 state: &AppState,
3716 status: SectionStatus,
3717 minimum_revision: u64,
3718 ) -> ConfigLiveHealth {
3719 match tokio::time::timeout(Duration::from_secs(4), async {
3720 loop {
3721 let health = state
3722 .mcp_config_live_health
3723 .read()
3724 .unwrap_or_else(|poisoned| poisoned.into_inner())
3725 .clone();
3726 if health.status == status && health.revision >= minimum_revision {
3727 break health;
3728 }
3729 tokio::time::sleep(Duration::from_millis(20)).await;
3730 }
3731 })
3732 .await
3733 {
3734 Ok(health) => health,
3735 Err(_) => panic!(
3736 "MCP health transition timed out: {:?}",
3737 state
3738 .mcp_config_live_health
3739 .read()
3740 .unwrap_or_else(|poisoned| poisoned.into_inner())
3741 .clone()
3742 ),
3743 }
3744 }
3745
3746 async fn next_config_event(
3747 feed: &mut tokio::sync::broadcast::Receiver<Arc<bamboo_engine::events::ChangeEvent>>,
3748 expected_section: &str,
3749 ) -> AgentEvent {
3750 tokio::time::timeout(Duration::from_secs(3), async {
3751 loop {
3752 let envelope = feed.recv().await.expect("account feed remains open");
3753 match &envelope.event {
3754 AgentEvent::ConfigChanged { section, .. }
3755 | AgentEvent::ConfigInvalid { section, .. }
3756 | AgentEvent::ConfigRecovered { section, .. }
3757 if section == expected_section =>
3758 {
3759 break envelope.event.clone();
3760 }
3761 _ => {}
3762 }
3763 }
3764 })
3765 .await
3766 .expect("config event timed out")
3767 }
3768
3769 async fn next_mcp_config_event(
3770 feed: &mut tokio::sync::broadcast::Receiver<Arc<bamboo_engine::events::ChangeEvent>>,
3771 ) -> AgentEvent {
3772 next_config_event(feed, "mcp").await
3773 }
3774
3775 async fn wait_for_facade_health(
3776 state: &AppState,
3777 id: SectionId,
3778 status: SectionStatus,
3779 revision: u64,
3780 ) -> bamboo_config::SectionHealth {
3781 tokio::time::timeout(Duration::from_secs(4), async {
3782 loop {
3783 let health = state
3784 .config_facade
3785 .as_ref()
3786 .expect("production state owns a facade")
3787 .registry()
3788 .health()
3789 .unwrap()
3790 .into_iter()
3791 .find(|health| health.section == id)
3792 .unwrap();
3793 if health.status == status && health.revision == revision {
3794 break health;
3795 }
3796 tokio::time::sleep(Duration::from_millis(20)).await;
3797 }
3798 })
3799 .await
3800 .expect("facade health transition timed out")
3801 }
3802
3803 #[test]
3804 fn initial_provider_health_validates_primary_and_backup() {
3805 let dir = tempfile::tempdir().unwrap();
3806 let store = AtomicJsonStore::new(dir.path().join("providers.json"), 1);
3807 let missing = initial_provider_health(&store);
3808 assert_eq!(missing.status, SectionStatus::Missing);
3809 assert_eq!(missing.source_kind, SectionSourceKind::Default);
3810
3811 std::fs::write(dir.path().join("providers.json"), b"{broken").unwrap();
3812 let invalid = initial_provider_health(&store);
3813 assert_eq!(invalid.status, SectionStatus::Invalid);
3814 assert_eq!(invalid.source_kind, SectionSourceKind::File);
3815
3816 std::fs::write(dir.path().join("providers.json.bak"), b"{}").unwrap();
3817 let recovered = initial_provider_health(&store);
3818 assert_eq!(recovered.status, SectionStatus::Degraded);
3819 assert_eq!(recovered.source_kind, SectionSourceKind::Backup);
3820 assert!(recovered
3821 .last_error
3822 .as_deref()
3823 .unwrap()
3824 .contains("last-known-good backup"));
3825
3826 std::fs::write(dir.path().join("providers.json"), b"{}").unwrap();
3827 let healthy = initial_provider_health(&store);
3828 assert_eq!(healthy.status, SectionStatus::Healthy);
3829 assert_eq!(healthy.source_kind, SectionSourceKind::File);
3830 }
3831
3832 #[tokio::test]
3833 async fn unrecoverable_pending_manifest_never_publishes_partial_provider_state() {
3834 let _key = bamboo_config::encryption::set_test_encryption_key([0x6c; 32]);
3835 let dir = tempfile::tempdir().unwrap();
3836 install_unrecoverable_pending_provider_migration(dir.path());
3837
3838 let loaded = Config::from_data_dir_without_env(Some(dir.path().to_path_buf()));
3839 assert_eq!(
3840 loaded.providers().openai.as_ref().unwrap().model.as_deref(),
3841 Some("root-lkg")
3842 );
3843 let store = AtomicJsonStore::new(dir.path().join("providers.json"), 1);
3844 let health = initial_provider_health(&store);
3845 assert_eq!(health.status, SectionStatus::Degraded);
3846 assert!(health
3847 .last_error
3848 .as_deref()
3849 .unwrap()
3850 .contains("migration is pending"));
3851 let error = match load_and_prepare_provider_candidate(&store, 0, loaded).await {
3852 Ok(_) => panic!("pending migration must reject provider candidate"),
3853 Err(error) => error,
3854 };
3855 assert!(error.message.contains("retaining last-known-good runtime"));
3856 assert!(!error.message.contains("partial-must-not-load"));
3857
3858 let state = AppState::new(dir.path().to_path_buf()).await.unwrap();
3859 assert_eq!(
3860 state
3861 .config
3862 .read()
3863 .await
3864 .providers()
3865 .openai
3866 .as_ref()
3867 .unwrap()
3868 .model
3869 .as_deref(),
3870 Some("root-lkg")
3871 );
3872 assert_eq!(
3873 state
3874 .config_live_health
3875 .read()
3876 .unwrap_or_else(|poisoned| poisoned.into_inner())
3877 .status,
3878 SectionStatus::Degraded
3879 );
3880 }
3881
3882 #[async_trait::async_trait]
3883 impl LLMProvider for WorkingProvider {
3884 async fn chat_stream(
3885 &self,
3886 _messages: &[Message],
3887 _tools: &[ToolSchema],
3888 _max_output_tokens: Option<u32>,
3889 _model: &str,
3890 ) -> Result<LLMStream, LLMError> {
3891 Err(LLMError::Api("working-provider-marker".to_string()))
3892 }
3893 }
3894
3895 #[tokio::test]
3896 async fn cancelled_provider_put_cannot_commit_before_publication_guards() {
3897 let _key = bamboo_config::encryption::set_test_encryption_key([0x53; 32]);
3898 let dir = tempfile::tempdir().unwrap();
3899 let state = AppState::new(dir.path().to_path_buf()).await.unwrap();
3900 let secret = "provider-cancel-secret";
3901 let reference = bamboo_config::credential_ref("provider", "openai", "api_key").unwrap();
3902 bamboo_config::CredentialStore::open(dir.path())
3903 .replace(
3904 reference.clone(),
3905 secret,
3906 bamboo_config::CredentialSource::User,
3907 0,
3908 )
3909 .unwrap();
3910 {
3911 let mut config = state.config.write().await;
3912 config.provider = "openai".to_string();
3913 *config.providers_mut() = ProviderConfigs {
3914 openai: Some(bamboo_config::OpenAIConfig {
3915 api_key: secret.to_string(),
3916 credential_ref: Some(reference),
3917 ..Default::default()
3918 }),
3919 ..Default::default()
3920 };
3921 }
3922 let provider_lock = state.provider.clone();
3923 let held_provider = provider_lock.write().await;
3924 let providers_before = std::fs::read(dir.path().join("providers.json")).unwrap();
3925 let mut operation = Box::pin(state.put_provider_section(
3926 0,
3927 ProviderConfigs {
3928 openai: Some(bamboo_config::OpenAIConfig {
3929 model: Some("candidate-model".to_string()),
3930 ..Default::default()
3931 }),
3932 ..Default::default()
3933 },
3934 ));
3935
3936 assert!(
3937 tokio::time::timeout(Duration::from_millis(500), &mut operation)
3938 .await
3939 .is_err()
3940 );
3941 drop(operation);
3942 assert_eq!(
3943 std::fs::read(dir.path().join("providers.json")).unwrap(),
3944 providers_before,
3945 "cancellation while waiting for publication guards must precede durable commit"
3946 );
3947 drop(held_provider);
3948 }
3949
3950 #[test]
3951 fn cancelled_provider_settings_request_finishes_exact_commit_and_live_publication() {
3952 let _key = bamboo_config::encryption::set_test_encryption_key([0x71; 32]);
3953 let runtime = tokio::runtime::Builder::new_multi_thread()
3954 .worker_threads(2)
3955 .max_blocking_threads(1)
3956 .enable_all()
3957 .build()
3958 .unwrap();
3959 runtime.block_on(async {
3960 let dir = tempfile::tempdir().unwrap();
3961 let state = Arc::new(AppState::new(dir.path().to_path_buf()).await.unwrap());
3962 wait_for_mcp_health(&state, SectionStatus::Healthy, 0).await;
3963
3964 let (started_tx, started_rx) = tokio::sync::oneshot::channel();
3965 let (release_tx, release_rx) = std::sync::mpsc::sync_channel(0);
3966 let blocker = tokio::task::spawn_blocking(move || {
3967 let _ = started_tx.send(());
3968 release_rx.recv().unwrap();
3969 });
3970 started_rx.await.unwrap();
3971
3972 let operation_state = state.clone();
3973 let operation = tokio::spawn(async move {
3974 operation_state
3975 .put_provider_settings(0, |_current, candidate| {
3976 candidate.provider = "openai".to_string();
3977 candidate.providers_mut().openai = Some(bamboo_config::OpenAIConfig {
3978 api_key: "provider-settings-cancel-secret".to_string(),
3979 model: Some("provider-settings-cancel-model".to_string()),
3980 ..Default::default()
3981 });
3982 Ok((BTreeSet::from(["openai".to_string()]), BTreeSet::new()))
3983 })
3984 .await
3985 });
3986
3987 tokio::time::timeout(Duration::from_secs(1), async {
3988 loop {
3989 if state.config_io_lock.try_lock().is_err() {
3990 break;
3991 }
3992 assert!(!operation.is_finished());
3993 tokio::task::yield_now().await;
3994 }
3995 })
3996 .await
3997 .expect("provider settings mutation acquires the config IO lock");
3998 operation.abort();
3999 let _ = operation.await;
4000 release_tx.send(()).unwrap();
4001 blocker.await.unwrap();
4002
4003 tokio::time::timeout(Duration::from_secs(5), async {
4004 loop {
4005 let committed = std::fs::read(dir.path().join("providers.json"))
4006 .ok()
4007 .and_then(|bytes| serde_json::from_slice::<Value>(&bytes).ok())
4008 .is_some_and(|value| {
4009 value["revision"] == 1
4010 && value["data"]["openai"]["model"]
4011 == "provider-settings-cancel-model"
4012 });
4013 if committed {
4014 break;
4015 }
4016 tokio::task::yield_now().await;
4017 }
4018 })
4019 .await
4020 .expect("owned provider settings transaction completes after cancellation");
4021
4022 let converged =
4023 tokio::time::timeout(Duration::from_secs(5), state.config_io_lock.lock())
4024 .await
4025 .expect("owned provider runtime publication completes after cancellation");
4026 drop(converged);
4027 let live = state.config.read().await;
4028 let openai = live.providers().openai.as_ref().unwrap();
4029 assert_eq!(
4030 openai.model.as_deref(),
4031 Some("provider-settings-cancel-model")
4032 );
4033 assert_eq!(openai.api_key, "provider-settings-cancel-secret");
4034 drop(live);
4035 let providers = std::fs::read_to_string(dir.path().join("providers.json")).unwrap();
4036 let credentials = std::fs::read_to_string(dir.path().join("credentials.json")).unwrap();
4037 assert!(!providers.contains("provider-settings-cancel-secret"));
4038 assert!(!credentials.contains("provider-settings-cancel-secret"));
4039 });
4040 }
4041
4042 #[test]
4043 fn cancelled_proxy_update_cannot_leave_durable_state_ahead_of_live_snapshot() {
4044 let runtime = tokio::runtime::Builder::new_multi_thread()
4045 .worker_threads(2)
4046 .max_blocking_threads(1)
4047 .enable_all()
4048 .build()
4049 .unwrap();
4050 runtime.block_on(async {
4051 let dir = tempfile::tempdir().unwrap();
4052 let state = Arc::new(AppState::new(dir.path().to_path_buf()).await.unwrap());
4053 wait_for_mcp_health(&state, SectionStatus::Healthy, 0).await;
4054
4055 let (started_tx, started_rx) = tokio::sync::oneshot::channel();
4059 let (release_tx, release_rx) = std::sync::mpsc::sync_channel(0);
4060 let blocker = tokio::task::spawn_blocking(move || {
4061 let _ = started_tx.send(());
4062 release_rx.recv().unwrap();
4063 });
4064 started_rx.await.unwrap();
4065
4066 let operation_state = state.clone();
4067 let operation = tokio::spawn(async move {
4068 operation_state
4069 .update_proxy_auth_credential(
4070 Some(bamboo_config::ProxyAuth {
4071 username: "cancel-user".to_string(),
4072 password: "cancel-secret".to_string(),
4073 }),
4074 0,
4075 ConfigUpdateEffects {
4076 reload_provider: true,
4077 reconcile_mcp: true,
4078 },
4079 )
4080 .await
4081 });
4082
4083 tokio::time::timeout(Duration::from_secs(1), async {
4088 loop {
4089 if state.config_io_lock.try_lock().is_err() {
4090 break;
4091 }
4092 assert!(!operation.is_finished());
4093 tokio::task::yield_now().await;
4094 }
4095 })
4096 .await
4097 .expect("proxy mutation acquires the config IO lock");
4098 operation.abort();
4099 let _ = operation.await;
4100 release_tx.send(()).unwrap();
4101 blocker.await.unwrap();
4102
4103 tokio::time::timeout(Duration::from_secs(5), async {
4104 loop {
4105 let credentials_ready = std::fs::read(dir.path().join("credentials.json"))
4106 .ok()
4107 .and_then(|bytes| serde_json::from_slice::<serde_json::Value>(&bytes).ok())
4108 .and_then(|value| value.get("revision").and_then(|value| value.as_u64()))
4109 == Some(1);
4110 let config_ready = std::fs::read(dir.path().join("core.json"))
4111 .ok()
4112 .and_then(|bytes| serde_json::from_slice::<serde_json::Value>(&bytes).ok())
4113 .and_then(|value| {
4114 value
4115 .get("data")
4116 .and_then(|value| value.get("proxy_auth_credential_ref"))
4117 .and_then(|value| value.as_str())
4118 .map(str::to_string)
4119 })
4120 .as_deref()
4121 == Some("proxy.default.auth");
4122 if credentials_ready && config_ready {
4123 break;
4124 }
4125 tokio::task::yield_now().await;
4126 }
4127 })
4128 .await
4129 .expect("owned durable transaction completes after caller cancellation");
4130
4131 let converged =
4135 tokio::time::timeout(Duration::from_secs(5), state.config_io_lock.lock())
4136 .await
4137 .expect("owned runtime convergence completes after cancellation");
4138 drop(converged);
4139
4140 let live = state.config.read().await;
4141 assert_eq!(
4142 live.proxy_auth_credential_ref
4143 .as_ref()
4144 .map(|reference| reference.as_str()),
4145 Some("proxy.default.auth")
4146 );
4147 let auth = live
4148 .proxy_auth
4149 .as_ref()
4150 .expect("durable proxy auth must be published despite cancellation");
4151 assert_eq!(auth.username, "cancel-user");
4152 assert_eq!(auth.password, "cancel-secret");
4153 drop(live);
4154
4155 let root = std::fs::read_to_string(dir.path().join("core.json")).unwrap();
4156 let credentials = std::fs::read_to_string(dir.path().join("credentials.json")).unwrap();
4157 assert!(!root.contains("cancel-secret"));
4158 assert!(!credentials.contains("cancel-secret"));
4159 });
4160 }
4161
4162 #[tokio::test]
4163 async fn missing_or_corrupt_referenced_credentials_reject_candidates_redacted() {
4164 let _key = bamboo_config::encryption::set_test_encryption_key([0x68; 32]);
4165 for corrupt_credentials in [false, true] {
4166 let dir = tempfile::tempdir().unwrap();
4167 let reference = bamboo_config::credential_ref("provider", "openai", "api_key").unwrap();
4168 let providers = ProviderConfigs {
4169 openai: Some(bamboo_config::OpenAIConfig {
4170 credential_ref: Some(reference),
4171 model: Some("candidate".to_string()),
4172 ..Default::default()
4173 }),
4174 ..Default::default()
4175 };
4176 let provider_store = AtomicJsonStore::new(dir.path().join("providers.json"), 1);
4177 provider_store
4178 .commit(0, providers, validate_provider_config)
4179 .unwrap();
4180 if corrupt_credentials {
4181 std::fs::write(dir.path().join("credentials.json"), b"{corrupt-secret").unwrap();
4182 }
4183 let error =
4184 match load_and_prepare_provider_candidate(&provider_store, 0, Config::default())
4185 .await
4186 {
4187 Ok(_) => panic!("unavailable credential must reject provider candidate"),
4188 Err(error) => error,
4189 };
4190 assert_eq!(error.message, "provider credential is unavailable");
4191 assert!(!error
4192 .message
4193 .contains(dir.path().to_string_lossy().as_ref()));
4194
4195 let mut mcp = disabled_mcp_config("credential-lkg");
4196 let TransportConfig::Stdio(stdio) = &mut mcp.servers[0].transport else {
4197 unreachable!()
4198 };
4199 stdio.env_credential_refs.insert(
4200 "TOKEN".to_string(),
4201 bamboo_config::credential_ref("mcp", "credential-lkg", "env_TOKEN")
4202 .unwrap()
4203 .as_str()
4204 .to_string(),
4205 );
4206 let mcp_store = AtomicJsonStore::new(dir.path().join("mcp.json"), 1);
4207 mcp_store.commit(0, mcp, validate_mcp_config).unwrap();
4208 let error = match load_and_validate_mcp_candidate(
4209 &mcp_store,
4210 0,
4211 Config::default(),
4212 false,
4213 )
4214 .await
4215 {
4216 Ok(_) => panic!("unavailable credential must reject MCP candidate"),
4217 Err(error) => error,
4218 };
4219 assert_eq!(error.message, "MCP credential is unavailable");
4220 assert!(!error.message.contains("TOKEN"));
4221 assert!(!error
4222 .message
4223 .contains(dir.path().to_string_lossy().as_ref()));
4224 }
4225 }
4226
4227 #[tokio::test]
4228 async fn typed_provider_put_switches_refs_and_rejects_missing_ref_without_mutation() {
4229 let _key = bamboo_config::encryption::set_test_encryption_key([0x6d; 32]);
4230 let dir = tempfile::tempdir().unwrap();
4231 let state = AppState::new(dir.path().to_path_buf()).await.unwrap();
4232 let ref_a = bamboo_config::credential_ref("provider", "openai-a", "api_key").unwrap();
4233 let ref_b = bamboo_config::credential_ref("provider", "openai-b", "api_key").unwrap();
4234 let missing = bamboo_config::credential_ref("provider", "missing", "api_key").unwrap();
4235 state
4236 .credential_store
4237 .replace(
4238 ref_a.clone(),
4239 "provider-secret-a",
4240 bamboo_config::CredentialSource::User,
4241 0,
4242 )
4243 .unwrap();
4244 state
4245 .credential_store
4246 .replace(
4247 ref_b.clone(),
4248 "provider-secret-b",
4249 bamboo_config::CredentialSource::User,
4250 1,
4251 )
4252 .unwrap();
4253 {
4254 let mut config = state.config.write().await;
4255 config.provider = "openai".to_string();
4256 *config.providers_mut() = ProviderConfigs {
4257 openai: Some(bamboo_config::OpenAIConfig {
4258 api_key: "provider-secret-a".to_string(),
4259 credential_ref: Some(ref_a),
4260 ..Default::default()
4261 }),
4262 ..Default::default()
4263 };
4264 }
4265
4266 let revision = state
4267 .put_provider_section(
4268 0,
4269 ProviderConfigs {
4270 openai: Some(bamboo_config::OpenAIConfig {
4271 credential_ref: Some(ref_b.clone()),
4272 model: Some("switched".to_string()),
4273 ..Default::default()
4274 }),
4275 ..Default::default()
4276 },
4277 )
4278 .await
4279 .unwrap();
4280 assert_eq!(revision, 1);
4281 let runtime = state.config.read().await;
4282 let openai = runtime.providers().openai.as_ref().unwrap();
4283 assert_eq!(openai.credential_ref.as_ref(), Some(&ref_b));
4284 assert_eq!(openai.api_key, "provider-secret-b");
4285 drop(runtime);
4286 let disk_before = std::fs::read(dir.path().join("providers.json")).unwrap();
4287
4288 assert!(state
4289 .put_provider_section(
4290 1,
4291 ProviderConfigs {
4292 openai: Some(bamboo_config::OpenAIConfig {
4293 credential_ref: Some(missing),
4294 model: Some("must-not-publish".to_string()),
4295 ..Default::default()
4296 }),
4297 ..Default::default()
4298 },
4299 )
4300 .await
4301 .is_err());
4302 assert_eq!(
4303 std::fs::read(dir.path().join("providers.json")).unwrap(),
4304 disk_before
4305 );
4306 let runtime = state.config.read().await;
4307 let openai = runtime.providers().openai.as_ref().unwrap();
4308 assert_eq!(openai.credential_ref.as_ref(), Some(&ref_b));
4309 assert_eq!(openai.api_key, "provider-secret-b");
4310 }
4311
4312 #[tokio::test]
4313 async fn typed_mcp_put_switches_stdio_and_header_refs_atomically() {
4314 let _key = bamboo_config::encryption::set_test_encryption_key([0x6e; 32]);
4315 let dir = tempfile::tempdir().unwrap();
4316 let state = AppState::new(dir.path().to_path_buf()).await.unwrap();
4317 let refs = [
4318 bamboo_config::credential_ref("mcp", "stdio-a", "env_TOKEN").unwrap(),
4319 bamboo_config::credential_ref("mcp", "header-a", "header_Authorization").unwrap(),
4320 bamboo_config::credential_ref("mcp", "stdio-b", "env_TOKEN").unwrap(),
4321 bamboo_config::credential_ref("mcp", "header-b", "header_Authorization").unwrap(),
4322 ];
4323 for (revision, (reference, value)) in refs
4324 .iter()
4325 .zip(["env-a", "header-a", "env-b", "header-b"])
4326 .enumerate()
4327 {
4328 state
4329 .credential_store
4330 .replace(
4331 reference.clone(),
4332 value,
4333 bamboo_config::CredentialSource::User,
4334 revision as u64,
4335 )
4336 .unwrap();
4337 }
4338 let make_config = |env_ref: &bamboo_config::CredentialRef,
4339 header_ref: &bamboo_config::CredentialRef| {
4340 McpConfig {
4341 version: 1,
4342 servers: vec![
4343 McpServerConfig {
4344 id: "switch-stdio".to_string(),
4345 name: None,
4346 enabled: false,
4347 transport: TransportConfig::Stdio(StdioConfig {
4348 command: "unused-disabled-command".to_string(),
4349 args: vec![],
4350 cwd: None,
4351 env: std::collections::HashMap::new(),
4352 env_encrypted: std::collections::HashMap::new(),
4353 env_credential_refs: std::collections::HashMap::from([(
4354 "TOKEN".to_string(),
4355 env_ref.as_str().to_string(),
4356 )]),
4357 startup_timeout_ms: 100,
4358 }),
4359 request_timeout_ms: 100,
4360 healthcheck_interval_ms: 100,
4361 reconnect: ReconnectConfig::default(),
4362 allowed_tools: vec![],
4363 denied_tools: vec![],
4364 },
4365 McpServerConfig {
4366 id: "switch-header".to_string(),
4367 name: None,
4368 enabled: false,
4369 transport: TransportConfig::Sse(bamboo_mcp::SseConfig {
4370 url: "https://example.test/sse".to_string(),
4371 headers: vec![bamboo_mcp::HeaderConfig {
4372 name: "Authorization".to_string(),
4373 value: String::new(),
4374 value_encrypted: None,
4375 credential_ref: Some(header_ref.as_str().to_string()),
4376 }],
4377 connect_timeout_ms: 100,
4378 }),
4379 request_timeout_ms: 100,
4380 healthcheck_interval_ms: 100,
4381 reconnect: ReconnectConfig::default(),
4382 allowed_tools: vec![],
4383 denied_tools: vec![],
4384 },
4385 ],
4386 }
4387 };
4388 let mut current = make_config(&refs[0], &refs[1]);
4389 if let TransportConfig::Stdio(stdio) = &mut current.servers[0].transport {
4390 stdio.env.insert("TOKEN".to_string(), "env-a".to_string());
4391 }
4392 if let TransportConfig::Sse(sse) = &mut current.servers[1].transport {
4393 sse.headers[0].value = "header-a".to_string();
4394 }
4395 state.config.write().await.mcp = current;
4396
4397 assert_eq!(
4398 state
4399 .put_mcp_section(0, make_config(&refs[2], &refs[3]))
4400 .await
4401 .unwrap(),
4402 1
4403 );
4404 let runtime = state.config.read().await;
4405 let TransportConfig::Stdio(stdio) = &runtime.mcp.servers[0].transport else {
4406 panic!("stdio transport")
4407 };
4408 assert_eq!(stdio.env["TOKEN"], "env-b");
4409 let TransportConfig::Sse(sse) = &runtime.mcp.servers[1].transport else {
4410 panic!("sse transport")
4411 };
4412 assert_eq!(sse.headers[0].value, "header-b");
4413 drop(runtime);
4414 let disk_before = std::fs::read(dir.path().join("mcp.json")).unwrap();
4415 let missing_env = bamboo_config::credential_ref("mcp", "missing", "env_TOKEN").unwrap();
4416 let missing_header =
4417 bamboo_config::credential_ref("mcp", "missing", "header_Authorization").unwrap();
4418 assert!(state
4419 .put_mcp_section(1, make_config(&missing_env, &missing_header))
4420 .await
4421 .is_err());
4422 assert_eq!(
4423 std::fs::read(dir.path().join("mcp.json")).unwrap(),
4424 disk_before
4425 );
4426 let runtime = state.config.read().await;
4427 let TransportConfig::Stdio(stdio) = &runtime.mcp.servers[0].transport else {
4428 panic!("stdio transport")
4429 };
4430 assert_eq!(stdio.env["TOKEN"], "env-b");
4431 }
4432
4433 #[tokio::test]
4434 async fn failed_candidate_keeps_existing_provider_registry_and_runtime() {
4435 let dir = tempfile::tempdir().unwrap();
4436 let state = AppState::new(dir.path().to_path_buf()).await.unwrap();
4437 let working: Arc<dyn LLMProvider> = Arc::new(WorkingProvider);
4438 state
4439 .provider_registry
4440 .insert("working".to_string(), working.clone());
4441 state.provider_registry.set_default("working".to_string());
4442 *state.provider.write().await = working.clone();
4443 state.config.write().await.provider = "openai".to_string();
4444
4445 assert!(state.reload_provider().await.is_err());
4446 assert_eq!(state.provider_registry.default_provider_name(), "working");
4447 assert!(Arc::ptr_eq(
4448 &state.provider_registry.get_default().unwrap(),
4449 &working
4450 ));
4451 let live = state.provider.read().await;
4452 assert!(Arc::ptr_eq(&*live, &working));
4453 }
4454
4455 #[tokio::test]
4456 async fn provider_watcher_retains_lkg_on_invalid_and_recovers_after_repair() {
4457 let _key = bamboo_config::encryption::set_test_encryption_key([0x43; 32]);
4458 let dir = tempfile::tempdir().unwrap();
4459 let state = AppState::new(dir.path().to_path_buf()).await.unwrap();
4460 let working: Arc<dyn LLMProvider> = Arc::new(WorkingProvider);
4461 state
4462 .provider_registry
4463 .insert("working".to_string(), working.clone());
4464 state.provider_registry.set_default("working".to_string());
4465 *state.provider.write().await = working.clone();
4466 state.config.write().await.provider = "openai".to_string();
4467 wait_for_mcp_health(&state, SectionStatus::Healthy, 0).await;
4468 let mut feed = state.account_sink.subscribe();
4469 let providers_path = dir.path().join("providers.json");
4470
4471 std::fs::write(&providers_path, b"{broken").unwrap();
4472 tokio::time::timeout(Duration::from_secs(3), async {
4473 loop {
4474 if state
4475 .config_live_health
4476 .read()
4477 .unwrap_or_else(|poisoned| poisoned.into_inner())
4478 .status
4479 == SectionStatus::Invalid
4480 {
4481 break;
4482 }
4483 tokio::time::sleep(Duration::from_millis(20)).await;
4484 }
4485 })
4486 .await
4487 .unwrap();
4488 assert_eq!(
4489 state
4490 .config_live_health
4491 .read()
4492 .unwrap_or_else(|poisoned| poisoned.into_inner())
4493 .revision,
4494 0,
4495 "invalid edits must not advance the LKG revision"
4496 );
4497 {
4498 let health = state
4499 .config_live_health
4500 .read()
4501 .unwrap_or_else(|poisoned| poisoned.into_inner());
4502 assert_eq!(health.status, SectionStatus::Invalid);
4503 assert_eq!(health.source_kind, SectionSourceKind::File);
4504 assert_eq!(health.source_path, providers_path);
4505 }
4506 assert!(Arc::ptr_eq(
4507 &state.provider_registry.get_default().unwrap(),
4508 &working
4509 ));
4510 let invalid = next_config_event(&mut feed, "providers").await;
4511 assert!(matches!(
4512 invalid,
4513 AgentEvent::ConfigInvalid { revision: 0, .. }
4514 ));
4515
4516 let reference = bamboo_config::credential_ref("provider", "openai", "api_key").unwrap();
4517 let credential_store = bamboo_config::CredentialStore::open(dir.path());
4518 let credential_revision = credential_store.revision().unwrap();
4519 credential_store
4520 .replace(
4521 reference.clone(),
4522 "watcher-test-key",
4523 bamboo_config::CredentialSource::User,
4524 credential_revision,
4525 )
4526 .unwrap();
4527 let providers = ProviderConfigs {
4528 openai: Some(bamboo_config::OpenAIConfig {
4529 credential_ref: Some(reference),
4530 ..Default::default()
4531 }),
4532 ..Default::default()
4533 };
4534 std::fs::write(
4535 &providers_path,
4536 serde_json::to_vec_pretty(&providers).unwrap(),
4537 )
4538 .unwrap();
4539 tokio::time::timeout(Duration::from_secs(3), async {
4540 loop {
4541 let health = state
4542 .config_live_health
4543 .read()
4544 .unwrap_or_else(|poisoned| poisoned.into_inner())
4545 .clone();
4546 if health.status == SectionStatus::Healthy && health.revision == 1 {
4547 break;
4548 }
4549 tokio::time::sleep(Duration::from_millis(20)).await;
4550 }
4551 })
4552 .await
4553 .unwrap();
4554 let recovered = next_config_event(&mut feed, "providers").await;
4555 assert!(matches!(
4556 recovered,
4557 AgentEvent::ConfigRecovered { revision: 1, .. }
4558 ));
4559 assert_eq!(state.provider_registry.default_provider_name(), "openai");
4560 }
4561
4562 #[tokio::test]
4563 async fn ordinary_section_watcher_updates_runtime_retains_lkg_and_recovers() {
4564 let _key = bamboo_config::encryption::set_test_encryption_key([0x44; 32]);
4565 let dir = tempfile::tempdir().unwrap();
4566 let state = AppState::new(dir.path().to_path_buf()).await.unwrap();
4567 wait_for_mcp_health(&state, SectionStatus::Healthy, 0).await;
4568 let mut feed = state.account_sink.subscribe();
4569 let path = dir.path().join("core.json");
4570 let mut document: serde_json::Value =
4571 serde_json::from_slice(&std::fs::read(&path).unwrap()).unwrap();
4572 document["revision"] = serde_json::json!(2);
4573 document["data"]["server"]["port"] = serde_json::json!(9876);
4574 std::fs::write(&path, serde_json::to_vec_pretty(&document).unwrap()).unwrap();
4575
4576 wait_for_facade_health(&state, SectionId::Core, SectionStatus::Healthy, 2).await;
4577 tokio::time::timeout(Duration::from_secs(3), async {
4578 loop {
4579 if state.config.read().await.server.port == 9876 {
4580 break;
4581 }
4582 tokio::time::sleep(Duration::from_millis(20)).await;
4583 }
4584 })
4585 .await
4586 .unwrap();
4587 assert!(matches!(
4588 next_config_event(&mut feed, "core").await,
4589 AgentEvent::ConfigChanged { revision: 2, .. }
4590 ));
4591
4592 std::fs::write(&path, b"{broken").unwrap();
4593 wait_for_facade_health(&state, SectionId::Core, SectionStatus::Invalid, 2).await;
4594 assert_eq!(state.config.read().await.server.port, 9876);
4595 assert!(matches!(
4596 next_config_event(&mut feed, "core").await,
4597 AgentEvent::ConfigInvalid { revision: 2, .. }
4598 ));
4599
4600 document["revision"] = serde_json::json!(3);
4601 document["data"]["server"]["port"] = serde_json::json!(9877);
4602 std::fs::write(&path, serde_json::to_vec_pretty(&document).unwrap()).unwrap();
4603 wait_for_facade_health(&state, SectionId::Core, SectionStatus::Healthy, 3).await;
4604 tokio::time::timeout(Duration::from_secs(3), async {
4605 loop {
4606 if state.config.read().await.server.port == 9877 {
4607 break;
4608 }
4609 tokio::time::sleep(Duration::from_millis(20)).await;
4610 }
4611 })
4612 .await
4613 .unwrap();
4614 assert!(matches!(
4615 next_config_event(&mut feed, "core").await,
4616 AgentEvent::ConfigRecovered { revision: 3, .. }
4617 ));
4618
4619 std::fs::remove_file(&path).unwrap();
4620 wait_for_facade_health(&state, SectionId::Core, SectionStatus::Missing, 3).await;
4621 assert_eq!(state.config.read().await.server.port, 9877);
4622 assert!(matches!(
4623 next_config_event(&mut feed, "core").await,
4624 AgentEvent::ConfigInvalid { revision: 3, .. }
4625 ));
4626
4627 document["revision"] = serde_json::json!(4);
4628 document["data"]["server"]["port"] = serde_json::json!(9878);
4629 let swap = dir.path().join("core.json.swap");
4630 std::fs::write(&swap, serde_json::to_vec_pretty(&document).unwrap()).unwrap();
4631 std::fs::rename(&swap, &path).unwrap();
4632 wait_for_facade_health(&state, SectionId::Core, SectionStatus::Healthy, 4).await;
4633 tokio::time::timeout(Duration::from_secs(3), async {
4634 loop {
4635 if state.config.read().await.server.port == 9878 {
4636 break;
4637 }
4638 tokio::time::sleep(Duration::from_millis(20)).await;
4639 }
4640 })
4641 .await
4642 .unwrap();
4643 assert!(matches!(
4644 next_config_event(&mut feed, "core").await,
4645 AgentEvent::ConfigRecovered { revision: 4, .. }
4646 ));
4647 }
4648
4649 #[tokio::test]
4650 async fn mcp_watcher_updates_lkg_rejects_invalid_and_recovers_after_atomic_replace() {
4651 let _key = bamboo_config::encryption::set_test_encryption_key([0x45; 32]);
4652 let dir = tempfile::tempdir().unwrap();
4653 let state = AppState::new(dir.path().to_path_buf()).await.unwrap();
4654 wait_for_mcp_health(&state, SectionStatus::Healthy, 0).await;
4655 let mut feed = state.account_sink.subscribe();
4656 let path = dir.path().join("mcp.json");
4657
4658 std::fs::write(&path, mcp_document_bytes(2, &disabled_mcp_config("first"))).unwrap();
4659 let first = wait_for_mcp_health(&state, SectionStatus::Healthy, 2).await;
4660 assert_eq!(first.revision, 2);
4661 assert_eq!(state.config.read().await.mcp.servers[0].id, "first");
4662 assert!(matches!(
4663 next_mcp_config_event(&mut feed).await,
4664 AgentEvent::ConfigChanged { revision: 2, .. }
4665 ));
4666
4667 std::fs::write(&path, b"{broken").unwrap();
4668 let invalid = wait_for_mcp_health(&state, SectionStatus::Invalid, 2).await;
4669 assert_eq!(invalid.revision, 2, "invalid candidates cannot advance LKG");
4670 assert_eq!(state.config.read().await.mcp.servers[0].id, "first");
4671 assert!(matches!(
4672 next_mcp_config_event(&mut feed).await,
4673 AgentEvent::ConfigInvalid { revision: 2, .. }
4674 ));
4675
4676 let swap = dir.path().join("mcp.json.swap");
4681 std::fs::write(
4682 &swap,
4683 mcp_document_bytes(3, &disabled_mcp_config("intermediate")),
4684 )
4685 .unwrap();
4686 std::fs::rename(&swap, &path).unwrap();
4687 std::fs::write(
4688 &path,
4689 mcp_document_bytes(3, &disabled_mcp_config("recovered")),
4690 )
4691 .unwrap();
4692 let recovered = wait_for_mcp_health(&state, SectionStatus::Healthy, 3).await;
4693 assert_eq!(recovered.revision, 3, "rename burst should coalesce once");
4694 assert_eq!(state.config.read().await.mcp.servers[0].id, "recovered");
4695 assert!(matches!(
4696 next_mcp_config_event(&mut feed).await,
4697 AgentEvent::ConfigRecovered { revision: 3, .. }
4698 ));
4699
4700 std::fs::write(
4705 &path,
4706 mcp_document_bytes(3, &disabled_mcp_config("normalized")),
4707 )
4708 .unwrap();
4709 let normalized = wait_for_mcp_health(&state, SectionStatus::Healthy, 4).await;
4710 assert_eq!(normalized.revision, 4);
4711 assert_eq!(state.config.read().await.mcp.servers[0].id, "normalized");
4712 assert!(matches!(
4713 next_mcp_config_event(&mut feed).await,
4714 AgentEvent::ConfigChanged { revision: 4, .. }
4715 ));
4716 assert!(
4717 tokio::time::timeout(Duration::from_millis(500), feed.recv())
4718 .await
4719 .is_err()
4720 );
4721 let persisted: serde_json::Value =
4722 serde_json::from_slice(&std::fs::read(&path).unwrap()).unwrap();
4723 assert_eq!(persisted["revision"], 4);
4724 }
4725
4726 #[tokio::test]
4727 async fn mcp_sidecar_present_at_startup_is_applied_through_runtime_transaction() {
4728 let _key = bamboo_config::encryption::set_test_encryption_key([0x47; 32]);
4729 let dir = tempfile::tempdir().unwrap();
4730 std::fs::write(
4731 dir.path().join("mcp.json"),
4732 mcp_document_bytes(1, &disabled_mcp_config("startup-sidecar")),
4733 )
4734 .unwrap();
4735
4736 let state = AppState::new(dir.path().to_path_buf()).await.unwrap();
4737 let health = wait_for_mcp_health(&state, SectionStatus::Healthy, 1).await;
4738 assert_eq!(health.revision, 1);
4739 assert_eq!(health.source_kind, SectionSourceKind::File);
4740 assert_eq!(
4741 state.config.read().await.mcp.servers[0].id,
4742 "startup-sidecar"
4743 );
4744 }
4745
4746 #[tokio::test]
4747 async fn mcp_startup_uses_valid_backup_and_reports_degraded_invalid_health() {
4748 let _key = bamboo_config::encryption::set_test_encryption_key([0x48; 32]);
4749 let dir = tempfile::tempdir().unwrap();
4750 let path = dir.path().join("mcp.json");
4751 let store = AtomicJsonStore::new(&path, 1);
4752 store
4753 .commit(0, disabled_mcp_config("backup-lkg"), validate_mcp_config)
4754 .unwrap();
4755 store
4756 .commit(1, disabled_mcp_config("new-primary"), validate_mcp_config)
4757 .unwrap();
4758 std::fs::write(&path, b"{corrupt-primary").unwrap();
4759
4760 let state = AppState::new(dir.path().to_path_buf()).await.unwrap();
4761 let health = wait_for_mcp_health(&state, SectionStatus::Degraded, 1).await;
4762 assert_eq!(health.revision, 1);
4763 assert_eq!(health.source_kind, SectionSourceKind::Backup);
4764 assert_eq!(health.source_path, path.with_extension("json.bak"));
4765 assert!(health
4766 .last_error
4767 .as_deref()
4768 .unwrap()
4769 .contains("last-known-good backup runtime"));
4770 assert_eq!(state.config.read().await.mcp.servers[0].id, "backup-lkg");
4771
4772 tokio::time::timeout(Duration::from_secs(3), async {
4773 loop {
4774 if state.account_sink.latest_seq() > 0 {
4775 break;
4776 }
4777 tokio::time::sleep(Duration::from_millis(20)).await;
4778 }
4779 })
4780 .await
4781 .unwrap();
4782 tokio::time::sleep(Duration::from_millis(500)).await;
4783 let events =
4784 bamboo_engine::events::journal::read_since(state.account_sink.events_dir(), 0).unwrap();
4785 assert_eq!(
4786 events
4787 .iter()
4788 .filter(|event| matches!(
4789 &event.event,
4790 AgentEvent::ConfigInvalid { section, revision }
4791 if section == "mcp" && *revision == 1
4792 ))
4793 .count(),
4794 1
4795 );
4796 let stable_health = state
4797 .mcp_config_live_health
4798 .read()
4799 .unwrap_or_else(|poisoned| poisoned.into_inner())
4800 .clone();
4801 assert_eq!(stable_health.status, SectionStatus::Degraded);
4802 assert_eq!(stable_health.source_kind, SectionSourceKind::Backup);
4803 assert_eq!(stable_health.source_path, path.with_extension("json.bak"));
4804 }
4805
4806 #[tokio::test]
4807 async fn mcp_runtime_init_failure_marks_degraded_and_retains_lkg_config() {
4808 let _key = bamboo_config::encryption::set_test_encryption_key([0x46; 32]);
4809 let dir = tempfile::tempdir().unwrap();
4810 let state = AppState::new(dir.path().to_path_buf()).await.unwrap();
4811 wait_for_mcp_health(&state, SectionStatus::Healthy, 0).await;
4812 let mut feed = state.account_sink.subscribe();
4813 let path = dir.path().join("mcp.json");
4814
4815 std::fs::write(
4816 &path,
4817 mcp_document_bytes(1, &disabled_mcp_config("last-known-good")),
4818 )
4819 .unwrap();
4820 wait_for_mcp_health(&state, SectionStatus::Healthy, 1).await;
4821 let _ = next_mcp_config_event(&mut feed).await;
4822
4823 let mut failing = disabled_mcp_config("candidate");
4824 failing.servers[0].enabled = true;
4825 if let TransportConfig::Stdio(stdio) = &mut failing.servers[0].transport {
4826 stdio.command = "definitely-not-a-real-mcp-command-597".to_string();
4827 }
4828 std::fs::write(&path, mcp_document_bytes(2, &failing)).unwrap();
4829
4830 let degraded = wait_for_mcp_health(&state, SectionStatus::Degraded, 1).await;
4831 assert_eq!(degraded.revision, 1);
4832 assert!(degraded
4833 .last_error
4834 .as_deref()
4835 .unwrap()
4836 .contains("last-known-good runtime"));
4837 assert_eq!(
4838 state.config.read().await.mcp.servers[0].id,
4839 "last-known-good"
4840 );
4841 assert!(state.mcp_manager.list_servers().is_empty());
4842 assert!(matches!(
4843 next_mcp_config_event(&mut feed).await,
4844 AgentEvent::ConfigInvalid { revision: 1, .. }
4845 ));
4846 }
4847}