1use super::*;
2
3use std::collections::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_mcp_section(
1486 &self,
1487 expected_revision: u64,
1488 mut candidate: McpConfig,
1489 ) -> Result<u64, ConfigSectionMutationError> {
1490 let _io = self.config_io_lock.lock().await;
1491 ensure_provider_mcp_migration_ready(&self.app_data_dir)
1492 .map_err(ConfigSectionMutationError::Store)?;
1493 retain_mcp_credentials(&self.config.read().await.mcp, &mut candidate);
1494 validate_mcp_config(&candidate).map_err(ConfigSectionMutationError::Invalid)?;
1495 let mut hydration_config = Config::default();
1496 hydration_config.mcp = candidate;
1497 hydration_config
1498 .hydrate_mcp_credentials_from_store(&self.app_data_dir)
1499 .map_err(|_| {
1500 ConfigSectionMutationError::Invalid(
1501 "referenced MCP credential is unavailable".to_string(),
1502 )
1503 })?;
1504 let candidate = hydration_config.mcp.clone();
1505 let mut revision = None;
1506 let mut store_error = None;
1507 let durable_candidate = credential_ref_mcp_document(&candidate)?;
1508 let mut next_config = candidate.clone();
1509 retain_mcp_credential_refs(&durable_candidate, &mut next_config);
1510 let result = self
1511 .mcp_manager
1512 .reconcile_from_config_transactional_after(&candidate, || async {
1513 let mut live_config = self.config.write().await;
1517 let commit = if let Some(facade) = self.config_facade.as_ref() {
1518 facade
1519 .registry()
1520 .mcp
1521 .commit(expected_revision, McpSection(durable_candidate))
1522 .map(|event| match event {
1523 ConfigSectionEvent::Changed { revision, .. } => revision,
1524 _ => unreachable!("a successful section commit is changed"),
1525 })
1526 } else {
1527 AtomicJsonStore::new(self.app_data_dir.join("mcp.json"), 1).commit(
1528 expected_revision,
1529 durable_candidate,
1530 validate_mcp_config,
1531 )
1532 };
1533 match commit {
1534 Ok(committed) => {
1535 live_config.mcp = next_config;
1536 revision = Some(committed);
1537 Ok(())
1538 }
1539 Err(error) => {
1540 store_error = Some(error);
1541 Err(bamboo_mcp::McpError::InvalidConfig(
1542 "MCP section durable commit failed".to_string(),
1543 ))
1544 }
1545 }
1546 })
1547 .await;
1548 if let Some(error) = store_error {
1549 return Err(ConfigSectionMutationError::Store(error));
1550 }
1551 if result.is_err() {
1552 let message =
1553 "MCP runtime initialization failed; retaining last-known-good runtime".to_string();
1554 publish_section_failure(
1555 &self.mcp_config_live_health,
1556 &self.account_sink,
1557 "mcp",
1558 SectionStatus::Degraded,
1559 message.clone(),
1560 );
1561 return Err(ConfigSectionMutationError::Runtime(message));
1562 }
1563 let revision = revision.expect("successful MCP reconcile commits a revision");
1564 publish_section_success(
1565 &self.mcp_config_live_health,
1566 &self.account_sink,
1567 "mcp",
1568 self.app_data_dir.join("mcp.json"),
1569 section_is_unhealthy(&self.mcp_config_live_health),
1570 Some(revision),
1571 );
1572 Ok(revision)
1573 }
1574}
1575
1576fn credential_reference_inventory(value: &Value) -> std::collections::BTreeMap<String, Value> {
1577 fn collect(value: &Value, path: &str, output: &mut std::collections::BTreeMap<String, Value>) {
1578 match value {
1579 Value::Object(object) => {
1580 for (key, value) in object {
1581 let child_path =
1582 format!("{path}/{}", key.replace('~', "~0").replace('/', "~1"));
1583 let normalized = key
1584 .chars()
1585 .filter(|ch| ch.is_ascii_alphanumeric())
1586 .flat_map(char::to_lowercase)
1587 .collect::<String>();
1588 if normalized == "credentialref"
1589 || normalized.ends_with("credentialref")
1590 || normalized.ends_with("credentialrefs")
1591 {
1592 output.insert(child_path, value.clone());
1593 } else {
1594 collect(value, &child_path, output);
1595 }
1596 }
1597 }
1598 Value::Array(values) => {
1599 for (index, value) in values.iter().enumerate() {
1600 collect(value, &format!("{path}/{index}"), output);
1601 }
1602 }
1603 _ => {}
1604 }
1605 }
1606
1607 let mut output = std::collections::BTreeMap::new();
1608 collect(value, "", &mut output);
1609 output
1610}
1611
1612fn provider_durable_document(
1613 providers: &ProviderConfigs,
1614) -> Result<ProviderConfigs, ConfigSectionMutationError> {
1615 let mut document = providers.clone();
1616 macro_rules! sanitize {
1617 ($field:ident) => {
1618 if let Some(provider) = document.$field.as_mut() {
1619 provider.api_key.clear();
1620 provider.api_key_encrypted = None;
1621 }
1622 };
1623 }
1624 sanitize!(openai);
1625 sanitize!(anthropic);
1626 sanitize!(gemini);
1627 if let Some(provider) = document.bodhi.as_mut() {
1628 provider.api_key.clear();
1629 provider.api_key_encrypted = None;
1630 }
1631 validate_provider_config(&document).map_err(ConfigSectionMutationError::Invalid)?;
1632 Ok(document)
1633}
1634
1635fn retain_provider_credentials(current: &ProviderConfigs, candidate: &mut ProviderConfigs) {
1636 candidate.extra = current.extra.clone();
1637 macro_rules! retain {
1638 ($field:ident) => {
1639 if let (Some(current), Some(candidate)) = (¤t.$field, &mut candidate.$field) {
1640 candidate.api_key = current.api_key.clone();
1641 candidate.api_key_encrypted = current.api_key_encrypted.clone();
1642 if candidate.credential_ref.is_none() {
1643 candidate.credential_ref = current.credential_ref.clone();
1644 }
1645 if candidate.credential_ref != current.credential_ref {
1646 candidate.api_key.clear();
1647 candidate.api_key_encrypted = None;
1648 }
1649 candidate.api_key_from_env = current.api_key_from_env;
1650 candidate.request_overrides = current.request_overrides.clone();
1651 candidate.extra = current.extra.clone();
1652 }
1653 };
1654 }
1655 retain!(openai);
1656 retain!(anthropic);
1657 retain!(gemini);
1658 if let (Some(current), Some(candidate)) = (¤t.bodhi, &mut candidate.bodhi) {
1659 candidate.api_key = current.api_key.clone();
1660 candidate.api_key_encrypted = current.api_key_encrypted.clone();
1661 if candidate.credential_ref.is_none() {
1662 candidate.credential_ref = current.credential_ref.clone();
1663 }
1664 if candidate.credential_ref != current.credential_ref {
1665 candidate.api_key.clear();
1666 candidate.api_key_encrypted = None;
1667 }
1668 candidate.extra = current.extra.clone();
1669 }
1670 if let (Some(current), Some(candidate)) = (¤t.copilot, &mut candidate.copilot) {
1671 candidate.request_overrides = current.request_overrides.clone();
1672 candidate.extra = current.extra.clone();
1673 }
1674}
1675
1676fn retain_mcp_credentials(current: &McpConfig, candidate: &mut McpConfig) {
1677 for candidate_server in &mut candidate.servers {
1678 let Some(current_server) = current
1679 .servers
1680 .iter()
1681 .find(|server| server.id == candidate_server.id)
1682 else {
1683 continue;
1684 };
1685 match (¤t_server.transport, &mut candidate_server.transport) {
1686 (TransportConfig::Stdio(current), TransportConfig::Stdio(candidate)) => {
1687 if candidate.env.is_empty()
1688 && candidate.env_encrypted.is_empty()
1689 && candidate.env_credential_refs.is_empty()
1690 {
1691 candidate.env = current.env.clone();
1692 candidate.env_encrypted = current.env_encrypted.clone();
1693 candidate.env_credential_refs = current.env_credential_refs.clone();
1694 }
1695 }
1696 (TransportConfig::Sse(current), TransportConfig::Sse(candidate))
1697 if candidate.headers.is_empty() =>
1698 {
1699 candidate.headers = current.headers.clone();
1700 }
1701 (
1702 TransportConfig::StreamableHttp(current),
1703 TransportConfig::StreamableHttp(candidate),
1704 ) if candidate.headers.is_empty() => {
1705 candidate.headers = current.headers.clone();
1706 }
1707 _ => {}
1708 }
1709 }
1710}
1711
1712fn credential_ref_mcp_document(
1713 runtime: &McpConfig,
1714) -> Result<McpConfig, ConfigSectionMutationError> {
1715 let mut document = runtime.clone();
1716 for server in &mut document.servers {
1717 match &mut server.transport {
1718 TransportConfig::Stdio(config) => {
1719 config.env_encrypted.clear();
1720 config.env.retain(|name, value| {
1721 !(value.is_empty() || config.env_credential_refs.contains_key(name))
1722 });
1723 if !config.env.is_empty() {
1724 return Err(ConfigSectionMutationError::Invalid(
1725 "MCP secret requires a credential reference".to_string(),
1726 ));
1727 }
1728 }
1729 TransportConfig::Sse(config) => reference_headers(&mut config.headers)?,
1730 TransportConfig::StreamableHttp(config) => reference_headers(&mut config.headers)?,
1731 }
1732 }
1733 Ok(document)
1734}
1735
1736fn retain_mcp_credential_refs(document: &McpConfig, runtime: &mut McpConfig) {
1737 for runtime_server in &mut runtime.servers {
1738 let Some(document_server) = document
1739 .servers
1740 .iter()
1741 .find(|server| server.id == runtime_server.id)
1742 else {
1743 continue;
1744 };
1745 match (&document_server.transport, &mut runtime_server.transport) {
1746 (TransportConfig::Stdio(document), TransportConfig::Stdio(runtime)) => {
1747 runtime.env_encrypted.clear();
1748 runtime.env_credential_refs = document.env_credential_refs.clone();
1749 }
1750 (TransportConfig::Sse(document), TransportConfig::Sse(runtime)) => {
1751 copy_header_ciphertext(&document.headers, &mut runtime.headers);
1752 }
1753 (
1754 TransportConfig::StreamableHttp(document),
1755 TransportConfig::StreamableHttp(runtime),
1756 ) => copy_header_ciphertext(&document.headers, &mut runtime.headers),
1757 _ => {}
1758 }
1759 }
1760}
1761
1762fn copy_header_ciphertext(
1763 document: &[bamboo_mcp::HeaderConfig],
1764 runtime: &mut [bamboo_mcp::HeaderConfig],
1765) {
1766 for runtime_header in runtime {
1767 if let Some(document_header) = document
1768 .iter()
1769 .find(|header| header.name == runtime_header.name)
1770 {
1771 runtime_header.value_encrypted = None;
1772 runtime_header.credential_ref = document_header.credential_ref.clone();
1773 }
1774 }
1775}
1776
1777fn reference_headers(
1778 headers: &mut [bamboo_mcp::HeaderConfig],
1779) -> Result<(), ConfigSectionMutationError> {
1780 for header in headers {
1781 if !header.value.is_empty() && header.credential_ref.is_none() {
1782 return Err(ConfigSectionMutationError::Invalid(
1783 "MCP secret requires a credential reference".to_string(),
1784 ));
1785 }
1786 header.value.clear();
1787 header.value_encrypted = None;
1788 }
1789 Ok(())
1790}
1791
1792impl AppState {
1793 pub async fn reload_provider(&self) -> Result<(), bamboo_llm::LLMError> {
1825 let config = self.config.read().await.clone();
1826 let candidate_registry =
1827 bamboo_llm::ProviderRegistry::from_config(&config, self.app_data_dir.clone()).await?;
1828 let default_provider_name = candidate_registry.default_provider_name();
1829 tracing::info!(
1830 default_provider = %default_provider_name,
1831 legacy_provider = %config.provider,
1832 has_provider_instances = config.has_provider_instances(),
1833 "Reloading provider runtime from current config"
1834 );
1835
1836 let new_provider = candidate_registry.get_default().ok_or_else(|| {
1837 let message = if config.has_provider_instances() {
1838 format!(
1839 "Default provider instance '{}' is not available or failed to initialize",
1840 default_provider_name
1841 )
1842 } else {
1843 format!(
1844 "Provider '{}' is not available or failed to initialize",
1845 config.provider
1846 )
1847 };
1848 bamboo_llm::LLMError::Auth(message)
1849 })?;
1850
1851 let mut provider = self.provider.write().await;
1852 self.provider_registry.replace_with(candidate_registry);
1853 *provider = new_provider;
1854
1855 tracing::info!(
1856 default_provider = %default_provider_name,
1857 "Provider reloaded successfully"
1858 );
1859 Ok(())
1860 }
1861
1862 pub async fn reload_config(&self) -> Config {
1892 let _io = self.config_io_lock.lock().await;
1897 let mut config = self.config.write().await;
1898 let new_config = self
1899 .config_facade
1900 .as_ref()
1901 .map(|facade| load_facade_effective_config(facade, &self.app_data_dir))
1902 .unwrap_or_else(|| {
1903 Config::from_data_dir_without_publish(Some(self.app_data_dir.clone()))
1904 });
1905 new_config.publish_env_vars();
1906 *config = new_config.clone();
1907 new_config
1908 }
1909
1910 async fn persist_config_snapshot(&self, config: Config) -> anyhow::Result<()> {
1911 let data_dir = self.app_data_dir.clone();
1912 if self.config_facade.is_some() {
1913 tokio::task::spawn_blocking(move || {
1914 bamboo_config::persist_facade_effective_config(data_dir, &config)
1915 })
1916 .await
1917 .map_err(|e| anyhow::anyhow!("Config save task failed: {e}"))??;
1918 let sections = bamboo_config::SECTION_DESCRIPTORS
1919 .iter()
1920 .map(|descriptor| descriptor.id)
1921 .collect::<Vec<_>>();
1922 publish_exact_facade_commit(self.config_facade.as_ref(), &self.account_sink, §ions)
1923 .map_err(|error| anyhow::anyhow!(error.to_string()))?;
1924 } else {
1925 tokio::task::spawn_blocking(move || config.save_to_dir(data_dir))
1926 .await
1927 .map_err(|e| anyhow::anyhow!("Config save task failed: {e}"))??;
1928 }
1929 Ok(())
1930 }
1931
1932 pub async fn update_config<F>(
1939 &self,
1940 update: F,
1941 effects: ConfigUpdateEffects,
1942 ) -> Result<Config, AppError>
1943 where
1944 F: FnOnce(&mut Config) -> Result<(), AppError>,
1945 {
1946 let snapshot = {
1952 let _io = self.config_io_lock.lock().await;
1953 let (snapshot, enforcement_newly_off) = {
1954 let cfg = self.config.read().await;
1955 reject_if_recovery_pending(&cfg)?;
1962 let was_off = cfg.plugin_trust.enforcement_is_off();
1963 let mut candidate = cfg.clone();
1964 update(&mut candidate)?;
1965 candidate.assign_connect_platform_ids();
1971 candidate.refresh_encrypted_secrets().map_err(|e| {
1979 AppError::InternalError(anyhow::anyhow!(
1980 "Failed to refresh encrypted secrets: {e}"
1981 ))
1982 })?;
1983 let newly_off = !was_off && candidate.plugin_trust.enforcement_is_off();
1984 (candidate, newly_off)
1985 };
1986 if enforcement_newly_off {
1993 warn_plugin_trust_enforcement_off();
1994 }
1995 self.persist_config_snapshot(snapshot.clone())
1996 .await
1997 .map_err(|e| {
1998 AppError::InternalError(anyhow::anyhow!("Failed to save config: {e}"))
1999 })?;
2000 let snapshot = self
2001 .config_facade
2002 .as_ref()
2003 .map(|facade| load_facade_effective_config(facade, &self.app_data_dir))
2004 .unwrap_or(snapshot);
2005 {
2006 let mut cfg = self.config.write().await;
2007 snapshot.publish_env_vars();
2008 *cfg = snapshot.clone();
2009 }
2010 snapshot
2011 };
2012
2013 self.apply_config_effects(snapshot.clone(), effects).await?;
2014 Ok(snapshot)
2015 }
2016
2017 pub async fn update_config_with_provider_credentials<F>(
2020 &self,
2021 update: F,
2022 provider_intents: std::collections::BTreeSet<String>,
2023 provider_instance_intents: std::collections::BTreeSet<String>,
2024 effects: ConfigUpdateEffects,
2025 ) -> Result<Config, AppError>
2026 where
2027 F: FnOnce(&mut Config) -> Result<(), AppError>,
2028 {
2029 if provider_intents.is_empty() && provider_instance_intents.is_empty() {
2030 return self.update_config(update, effects).await;
2031 }
2032 let config_facade = self.config_facade.clone();
2033 let account_sink = self.account_sink.clone();
2034 let (snapshot, enforcement_newly_off) = {
2035 let _io = self.config_io_lock.lock().await;
2036 let (mut candidate, enforcement_newly_off) = {
2037 let cfg = self.config.read().await;
2038 reject_if_recovery_pending(&cfg)?;
2039 let was_off = cfg.plugin_trust.enforcement_is_off();
2040 let mut candidate = cfg.clone();
2041 update(&mut candidate)?;
2042 let mut non_provider_candidate = candidate.clone();
2048 apply_runtime_section(SectionId::Providers, &cfg, &mut non_provider_candidate);
2049 let mut changed =
2050 bamboo_config::changed_facade_sections(&cfg, &non_provider_candidate).map_err(
2051 |_| {
2052 AppError::InternalError(anyhow::anyhow!(
2053 "failed to compare modular configuration sections"
2054 ))
2055 },
2056 )?;
2057 if serde_json::to_value(cfg.subagents()).ok()
2062 == serde_json::to_value(candidate.subagents()).ok()
2063 {
2064 changed.retain(|section| *section != SectionId::Subagents);
2065 }
2066 if let Some(other) = changed
2067 .into_iter()
2068 .find(|section| *section != SectionId::Providers)
2069 {
2070 return Err(AppError::BadRequest(format!(
2071 "provider credential updates cannot be combined with {} changes; split the request",
2072 other.descriptor().name
2073 )));
2074 }
2075 candidate.assign_connect_platform_ids();
2076 candidate.refresh_encrypted_secrets().map_err(|error| {
2077 AppError::InternalError(anyhow::anyhow!(
2078 "Failed to refresh encrypted secrets: {error}"
2079 ))
2080 })?;
2081 let newly_off = !was_off && candidate.plugin_trust.enforcement_is_off();
2082 (candidate, newly_off)
2083 };
2084 let data_dir = self.app_data_dir.clone();
2085 let candidate = tokio::task::spawn_blocking(move || {
2086 bamboo_config::persist_provider_instance_credential_transaction(
2087 &data_dir,
2088 &mut candidate,
2089 &provider_intents,
2090 &provider_instance_intents,
2091 )?;
2092 load_committed_effective_config(&data_dir)
2093 })
2094 .await
2095 .map_err(|error| {
2096 AppError::InternalError(anyhow::anyhow!(
2097 "provider credential transaction task failed: {error}"
2098 ))
2099 })?
2100 .map_err(|error| match error {
2101 ConfigStoreError::Conflict { expected, actual } => {
2102 AppError::ConfigConflict { expected, actual }
2103 }
2104 ConfigStoreError::Validation(message) => AppError::BadRequest(message),
2105 ConfigStoreError::Io(error) => AppError::StorageError(error),
2106 ConfigStoreError::Json(_) => {
2107 AppError::BadRequest("configuration document is invalid".to_string())
2108 }
2109 ConfigStoreError::Watch(error) => {
2110 AppError::InternalError(anyhow::anyhow!("configuration watch failed: {error}"))
2111 }
2112 })?;
2113 publish_exact_facade_commit(
2114 config_facade.as_ref(),
2115 &account_sink,
2116 &[SectionId::Credentials, SectionId::Providers],
2117 )?;
2118 {
2119 let mut cfg = self.config.write().await;
2120 candidate.publish_env_vars();
2121 *cfg = candidate.clone();
2122 }
2123 (candidate, enforcement_newly_off)
2124 };
2125 if enforcement_newly_off {
2126 warn_plugin_trust_enforcement_off();
2127 }
2128 self.apply_config_effects(snapshot.clone(), effects).await?;
2129 Ok(snapshot)
2130 }
2131
2132 pub async fn update_env_var_credentials<F>(
2136 &self,
2137 expected_revision: u64,
2138 env_intents: std::collections::BTreeSet<String>,
2139 update: F,
2140 ) -> Result<(Config, u64), AppError>
2141 where
2142 F: FnOnce(&mut Config) -> Result<(), AppError> + Send + 'static,
2143 {
2144 let config_io_lock = self.config_io_lock.clone();
2145 let config = self.config.clone();
2146 let app_data_dir = self.app_data_dir.clone();
2147 let account_sink = self.account_sink.clone();
2148 let config_facade = self.config_facade.clone();
2149 let transaction = tokio::spawn(async move {
2150 let _io = config_io_lock.lock().await;
2151 let mut candidate = {
2152 let current = config.read().await;
2153 reject_if_recovery_pending(¤t)?;
2154 let mut candidate = current.clone();
2155 update(&mut candidate)?;
2156 candidate.assign_connect_platform_ids();
2157 candidate
2158 };
2159 let transaction_dir = app_data_dir.clone();
2160 let (candidate, revision) = tokio::task::spawn_blocking(move || {
2161 let revision = bamboo_config::persist_env_var_credential_transaction_at_revision(
2162 &transaction_dir,
2163 &mut candidate,
2164 &env_intents,
2165 expected_revision,
2166 )?;
2167 let committed = load_committed_effective_config(&transaction_dir)?;
2170 Ok::<_, ConfigStoreError>((committed, revision))
2171 })
2172 .await
2173 .map_err(|error| {
2174 AppError::InternalError(anyhow::anyhow!(
2175 "env credential transaction task failed: {error}"
2176 ))
2177 })?
2178 .map_err(|error| match error {
2179 ConfigStoreError::Conflict { expected, actual } => {
2180 AppError::ConfigConflict { expected, actual }
2181 }
2182 ConfigStoreError::Validation(message) => AppError::BadRequest(message),
2183 ConfigStoreError::Io(error) => AppError::StorageError(error),
2184 ConfigStoreError::Json(_) => {
2185 AppError::BadRequest("configuration document is invalid".to_string())
2186 }
2187 ConfigStoreError::Watch(error) => {
2188 AppError::InternalError(anyhow::anyhow!("configuration watch failed: {error}"))
2189 }
2190 })?;
2191 publish_exact_facade_commit(
2192 config_facade.as_ref(),
2193 &account_sink,
2194 &[SectionId::Credentials, SectionId::Env],
2195 )?;
2196 candidate.publish_env_vars();
2197 *config.write().await = candidate.clone();
2198 Ok::<_, AppError>((candidate, revision))
2199 });
2200 transaction.await.map_err(|error| {
2201 AppError::InternalError(anyhow::anyhow!(
2202 "env credential transaction task failed: {error}"
2203 ))
2204 })?
2205 }
2206
2207 pub async fn update_notification_credentials<F>(
2212 &self,
2213 expected_revision: u64,
2214 secret_intents: std::collections::BTreeSet<String>,
2215 reset_domain: bool,
2216 update: F,
2217 ) -> Result<(Config, u64), AppError>
2218 where
2219 F: FnOnce(&mut Config) -> Result<(), AppError> + Send + 'static,
2220 {
2221 let config_io_lock = self.config_io_lock.clone();
2222 let config = self.config.clone();
2223 let app_data_dir = self.app_data_dir.clone();
2224 let account_sink = self.account_sink.clone();
2225 let config_facade = self.config_facade.clone();
2226 let transaction = tokio::spawn(async move {
2227 let _io = config_io_lock.lock().await;
2228 let mut candidate = {
2229 let current = config.read().await;
2230 reject_if_recovery_pending(¤t)?;
2231 let mut candidate = current.clone();
2232 update(&mut candidate)?;
2233 candidate
2234 };
2235 let transaction_dir = app_data_dir.clone();
2236 let (candidate, revision) = tokio::task::spawn_blocking(move || {
2237 let revision =
2238 bamboo_config::persist_notification_credential_transaction_at_revision_with_reset(
2239 &transaction_dir,
2240 &mut candidate,
2241 &secret_intents,
2242 reset_domain,
2243 expected_revision,
2244 )?;
2245 let committed = load_committed_effective_config(&transaction_dir)?;
2246 Ok::<_, ConfigStoreError>((committed, revision))
2247 })
2248 .await
2249 .map_err(|error| {
2250 AppError::InternalError(anyhow::anyhow!(
2251 "notification credential transaction task failed: {error}"
2252 ))
2253 })?
2254 .map_err(|error| match error {
2255 ConfigStoreError::Conflict { expected, actual } => {
2256 AppError::ConfigConflict { expected, actual }
2257 }
2258 ConfigStoreError::Validation(message) => AppError::BadRequest(message),
2259 ConfigStoreError::Io(error) => AppError::StorageError(error),
2260 ConfigStoreError::Json(_) => {
2261 AppError::BadRequest("configuration document is invalid".to_string())
2262 }
2263 ConfigStoreError::Watch(error) => {
2264 AppError::InternalError(anyhow::anyhow!("configuration watch failed: {error}"))
2265 }
2266 })?;
2267 publish_exact_facade_commit(
2268 config_facade.as_ref(),
2269 &account_sink,
2270 &[SectionId::Credentials, SectionId::Notifications],
2271 )?;
2272 *config.write().await = candidate.clone();
2273 Ok::<_, AppError>((candidate, revision))
2274 });
2275 transaction.await.map_err(|error| {
2276 AppError::InternalError(anyhow::anyhow!(
2277 "notification credential transaction task failed: {error}"
2278 ))
2279 })?
2280 }
2281
2282 pub async fn update_connect_credentials<F>(
2287 &self,
2288 expected_revision: u64,
2289 secret_intents: bamboo_config::patch::ConnectSecretIntents,
2290 update: F,
2291 ) -> Result<(Config, u64), AppError>
2292 where
2293 F: FnOnce(&mut Config) -> Result<(), AppError> + Send + 'static,
2294 {
2295 let config_io_lock = self.config_io_lock.clone();
2296 let config = self.config.clone();
2297 let app_data_dir = self.app_data_dir.clone();
2298 let account_sink = self.account_sink.clone();
2299 let config_facade = self.config_facade.clone();
2300 let transaction = tokio::spawn(async move {
2301 let _io = config_io_lock.lock().await;
2302 let mut candidate = {
2303 let current = config.read().await;
2304 reject_if_recovery_pending(¤t)?;
2305 let mut candidate = current.clone();
2306 update(&mut candidate)?;
2307 candidate.assign_connect_platform_ids();
2308 candidate
2309 };
2310 let transaction_dir = app_data_dir.clone();
2311 let (candidate, revision) = tokio::task::spawn_blocking(move || {
2312 let revision = bamboo_config::persist_connect_credential_transaction_at_revision(
2313 &transaction_dir,
2314 &mut candidate,
2315 &secret_intents,
2316 expected_revision,
2317 )?;
2318 let committed = load_committed_effective_config(&transaction_dir)?;
2319 Ok::<_, ConfigStoreError>((committed, revision))
2320 })
2321 .await
2322 .map_err(|error| {
2323 AppError::InternalError(anyhow::anyhow!(
2324 "connect credential transaction task failed: {error}"
2325 ))
2326 })?
2327 .map_err(|error| match error {
2328 ConfigStoreError::Conflict { expected, actual } => {
2329 AppError::ConfigConflict { expected, actual }
2330 }
2331 ConfigStoreError::Validation(message) => AppError::BadRequest(message),
2332 ConfigStoreError::Io(error) => AppError::StorageError(error),
2333 ConfigStoreError::Json(_) => {
2334 AppError::BadRequest("configuration document is invalid".to_string())
2335 }
2336 ConfigStoreError::Watch(error) => {
2337 AppError::InternalError(anyhow::anyhow!("configuration watch failed: {error}"))
2338 }
2339 })?;
2340 publish_exact_facade_commit(
2341 config_facade.as_ref(),
2342 &account_sink,
2343 &[SectionId::Credentials, SectionId::Connect],
2344 )?;
2345 *config.write().await = candidate.clone();
2346 Ok::<_, AppError>((candidate, revision))
2347 });
2348 transaction.await.map_err(|error| {
2349 AppError::InternalError(anyhow::anyhow!(
2350 "connect credential transaction task failed: {error}"
2351 ))
2352 })?
2353 }
2354
2355 pub async fn update_access_control_credentials<F>(
2358 &self,
2359 expected_revision: u64,
2360 password_intent: bool,
2361 device_intents: std::collections::BTreeSet<String>,
2362 update: F,
2363 ) -> Result<(Config, u64), AppError>
2364 where
2365 F: FnOnce(&mut Config) -> Result<(), AppError> + Send + 'static,
2366 {
2367 let config_io_lock = self.config_io_lock.clone();
2368 let config = self.config.clone();
2369 let app_data_dir = self.app_data_dir.clone();
2370 let account_sink = self.account_sink.clone();
2371 let config_facade = self.config_facade.clone();
2372 let transaction = tokio::spawn(async move {
2373 let _io = config_io_lock.lock().await;
2374 let mut candidate = {
2375 let current = config.read().await;
2376 reject_if_recovery_pending(¤t)?;
2377 let mut candidate = current.clone();
2378 update(&mut candidate)?;
2379 candidate
2380 };
2381 let transaction_dir = app_data_dir.clone();
2382 let (candidate, revision) = tokio::task::spawn_blocking(move || {
2383 let revision =
2384 bamboo_config::persist_access_control_credential_transaction_at_revision(
2385 &transaction_dir,
2386 &mut candidate,
2387 password_intent,
2388 &device_intents,
2389 expected_revision,
2390 )?;
2391 let committed = load_committed_effective_config(&transaction_dir)?;
2392 Ok::<_, ConfigStoreError>((committed, revision))
2393 })
2394 .await
2395 .map_err(|error| {
2396 AppError::InternalError(anyhow::anyhow!(
2397 "access-control credential transaction task failed: {error}"
2398 ))
2399 })?
2400 .map_err(|error| match error {
2401 ConfigStoreError::Conflict { expected, actual } => {
2402 AppError::ConfigConflict { expected, actual }
2403 }
2404 ConfigStoreError::Validation(message) => AppError::BadRequest(message),
2405 ConfigStoreError::Io(error) => AppError::StorageError(error),
2406 ConfigStoreError::Json(_) => {
2407 AppError::BadRequest("configuration document is invalid".to_string())
2408 }
2409 ConfigStoreError::Watch(error) => {
2410 AppError::InternalError(anyhow::anyhow!("configuration watch failed: {error}"))
2411 }
2412 })?;
2413 publish_exact_facade_commit(
2414 config_facade.as_ref(),
2415 &account_sink,
2416 &[SectionId::Credentials, SectionId::AccessControl],
2417 )?;
2418 *config.write().await = candidate.clone();
2419 Ok::<_, AppError>((candidate, revision))
2420 });
2421 transaction.await.map_err(|error| {
2422 AppError::InternalError(anyhow::anyhow!(
2423 "access-control credential transaction task failed: {error}"
2424 ))
2425 })?
2426 }
2427
2428 pub async fn update_cluster_fabric_credentials<F>(
2433 &self,
2434 expected_revision: u64,
2435 node_intents: std::collections::BTreeSet<String>,
2436 update: F,
2437 ) -> Result<(Config, u64), AppError>
2438 where
2439 F: FnOnce(&mut Config) -> Result<(), AppError> + Send + 'static,
2440 {
2441 let config_io_lock = self.config_io_lock.clone();
2442 let config = self.config.clone();
2443 let app_data_dir = self.app_data_dir.clone();
2444 let account_sink = self.account_sink.clone();
2445 let config_facade = self.config_facade.clone();
2446 let transaction = tokio::spawn(async move {
2447 let _io = config_io_lock.lock().await;
2448 let mut candidate = {
2449 let current = config.read().await;
2450 reject_if_recovery_pending(¤t)?;
2451 let mut candidate = current.clone();
2452 update(&mut candidate)?;
2453 candidate
2454 };
2455 let transaction_dir = app_data_dir.clone();
2456 let (candidate, revision) = tokio::task::spawn_blocking(move || {
2457 let revision =
2458 bamboo_config::persist_cluster_fabric_credential_transaction_at_revision(
2459 &transaction_dir,
2460 &mut candidate,
2461 &node_intents,
2462 expected_revision,
2463 )?;
2464 let committed = load_committed_effective_config(&transaction_dir)?;
2465 Ok::<_, ConfigStoreError>((committed, revision))
2466 })
2467 .await
2468 .map_err(|error| {
2469 AppError::InternalError(anyhow::anyhow!(
2470 "cluster credential transaction task failed: {error}"
2471 ))
2472 })?
2473 .map_err(|error| match error {
2474 ConfigStoreError::Conflict { expected, actual } => {
2475 AppError::ConfigConflict { expected, actual }
2476 }
2477 ConfigStoreError::Validation(message) => AppError::BadRequest(message),
2478 ConfigStoreError::Io(error) => AppError::StorageError(error),
2479 ConfigStoreError::Json(_) => {
2480 AppError::BadRequest("configuration document is invalid".to_string())
2481 }
2482 ConfigStoreError::Watch(error) => {
2483 AppError::InternalError(anyhow::anyhow!("configuration watch failed: {error}"))
2484 }
2485 })?;
2486 publish_exact_facade_commit(
2487 config_facade.as_ref(),
2488 &account_sink,
2489 &[SectionId::Credentials, SectionId::ClusterFabric],
2490 )?;
2491 *config.write().await = candidate.clone();
2492 Ok::<_, AppError>((candidate, revision))
2493 });
2494 transaction.await.map_err(|error| {
2495 AppError::InternalError(anyhow::anyhow!(
2496 "cluster credential transaction task failed: {error}"
2497 ))
2498 })?
2499 }
2500
2501 pub async fn update_proxy_auth_credential(
2505 &self,
2506 auth: Option<bamboo_config::ProxyAuth>,
2507 expected_revision: u64,
2508 effects: ConfigUpdateEffects,
2509 ) -> Result<
2510 (
2511 Config,
2512 bamboo_config::CredentialStatus,
2513 bamboo_config::CredentialStoreHealth,
2514 ),
2515 AppError,
2516 > {
2517 let config_io_lock = self.config_io_lock.clone();
2518 let config = self.config.clone();
2519 let app_data_dir = self.app_data_dir.clone();
2520 let credential_store = self.credential_store.clone();
2521 let provider_registry = self.provider_registry.clone();
2522 let provider = self.provider.clone();
2523 let mcp_manager = self.mcp_manager.clone();
2524 let config_facade = self.config_facade.clone();
2525 let account_sink = self.account_sink.clone();
2526
2527 let transaction = tokio::spawn(async move {
2532 let _io = config_io_lock.lock().await;
2533 let mut candidate = {
2534 let cfg = config.read().await;
2535 reject_if_recovery_pending(&cfg)?;
2536 let mut candidate = cfg.clone();
2537 candidate.proxy_auth = auth;
2538 candidate.assign_connect_platform_ids();
2539 candidate.refresh_encrypted_secrets().map_err(|error| {
2540 AppError::InternalError(anyhow::anyhow!(
2541 "Failed to refresh encrypted secrets: {error}"
2542 ))
2543 })?;
2544 candidate
2545 };
2546 let transaction_dir = app_data_dir.clone();
2547 let status_reference =
2548 candidate
2549 .proxy_auth_credential_ref
2550 .clone()
2551 .unwrap_or_else(|| {
2552 bamboo_config::CredentialRef::parse("proxy.default.auth")
2553 .expect("canonical proxy credential reference is valid")
2554 });
2555 let (candidate, reference) = tokio::task::spawn_blocking(move || {
2556 bamboo_config::persist_proxy_auth_credential_transaction_at_revision(
2557 &transaction_dir,
2558 &mut candidate,
2559 expected_revision,
2560 )?;
2561 Ok::<_, ConfigStoreError>((
2562 load_committed_effective_config(&transaction_dir)?,
2563 status_reference,
2564 ))
2565 })
2566 .await
2567 .map_err(|error| {
2568 AppError::InternalError(anyhow::anyhow!(
2569 "proxy credential transaction task failed: {error}"
2570 ))
2571 })?
2572 .map_err(|error| match error {
2573 ConfigStoreError::Conflict { expected, actual } => {
2574 AppError::ConfigConflict { expected, actual }
2575 }
2576 ConfigStoreError::Validation(message) => AppError::BadRequest(message),
2577 ConfigStoreError::Io(error) => AppError::StorageError(error),
2578 ConfigStoreError::Json(_) => {
2579 AppError::BadRequest("configuration document is invalid".to_string())
2580 }
2581 ConfigStoreError::Watch(error) => {
2582 AppError::InternalError(anyhow::anyhow!("configuration watch failed: {error}"))
2583 }
2584 })?;
2585
2586 publish_exact_facade_commit(
2587 config_facade.as_ref(),
2588 &account_sink,
2589 &[SectionId::Credentials, SectionId::Core],
2590 )?;
2591
2592 candidate.publish_env_vars();
2596 *config.write().await = candidate.clone();
2597
2598 if effects.reload_provider {
2599 match bamboo_llm::ProviderRegistry::from_config(&candidate, app_data_dir.clone())
2600 .await
2601 {
2602 Ok(candidate_registry) => {
2603 if let Some(candidate_provider) = candidate_registry.get_default() {
2604 let mut live_provider = provider.write().await;
2605 provider_registry.replace_with(candidate_registry);
2606 *live_provider = candidate_provider;
2607 } else {
2608 tracing::warn!(
2609 "proxy auth committed but provider reload had no default provider"
2610 );
2611 }
2612 }
2613 Err(error) => {
2614 tracing::warn!(
2615 error = %error,
2616 "proxy auth committed but provider reload failed"
2617 );
2618 }
2619 }
2620 }
2621
2622 if effects.reconcile_mcp {
2623 mcp_manager.reconcile_from_config(&candidate.mcp).await;
2624 }
2625
2626 let (status, health) =
2627 credential_store
2628 .status_with_health(&reference)
2629 .map_err(|error| match error {
2630 ConfigStoreError::Conflict { expected, actual } => {
2631 AppError::ConfigConflict { expected, actual }
2632 }
2633 ConfigStoreError::Validation(_) | ConfigStoreError::Json(_) => {
2634 AppError::InternalError(anyhow::anyhow!(
2635 "credential store validation failed"
2636 ))
2637 }
2638 ConfigStoreError::Io(error) => AppError::StorageError(error),
2639 ConfigStoreError::Watch(error) => AppError::InternalError(anyhow::anyhow!(
2640 "configuration watch failed: {error}"
2641 )),
2642 })?;
2643 Ok::<_, AppError>((candidate, status, health))
2644 });
2645 transaction.await.map_err(|error| {
2646 AppError::InternalError(anyhow::anyhow!(
2647 "proxy credential mutation task failed: {error}"
2648 ))
2649 })?
2650 }
2651
2652 pub async fn replace_config(
2654 &self,
2655 mut new_config: Config,
2656 effects: ConfigUpdateEffects,
2657 ) -> Result<Config, AppError> {
2658 new_config.assign_connect_platform_ids();
2664 new_config.refresh_encrypted_secrets().map_err(|e| {
2667 AppError::InternalError(anyhow::anyhow!("Failed to refresh encrypted secrets: {e}"))
2668 })?;
2669
2670 let new_config = {
2673 let _io = self.config_io_lock.lock().await;
2674 let was_off = {
2675 let cfg = self.config.read().await;
2676 reject_if_recovery_pending(&cfg)?;
2679 cfg.plugin_trust.enforcement_is_off()
2680 };
2681 self.persist_config_snapshot(new_config.clone())
2682 .await
2683 .map_err(|e| {
2684 AppError::InternalError(anyhow::anyhow!("Failed to save config: {e}"))
2685 })?;
2686 let published = self
2687 .config_facade
2688 .as_ref()
2689 .map(|facade| load_facade_effective_config(facade, &self.app_data_dir))
2690 .unwrap_or(new_config);
2691 let enforcement_newly_off = !was_off && published.plugin_trust.enforcement_is_off();
2692 published.publish_env_vars();
2693 *self.config.write().await = published.clone();
2694 if enforcement_newly_off {
2698 warn_plugin_trust_enforcement_off();
2699 }
2700 published
2701 };
2702
2703 self.apply_config_effects(new_config.clone(), effects)
2704 .await?;
2705 Ok(new_config)
2706 }
2707
2708 async fn apply_config_effects(
2709 &self,
2710 new_config: Config,
2711 effects: ConfigUpdateEffects,
2712 ) -> Result<(), AppError> {
2713 if effects.reload_provider {
2714 self.reload_provider().await.map_err(|e| {
2715 AppError::InternalError(anyhow::anyhow!(
2716 "Failed to reload provider after updating config: {e}"
2717 ))
2718 })?;
2719 }
2720
2721 if effects.reconcile_mcp {
2722 self.mcp_manager
2723 .reconcile_from_config(&new_config.mcp)
2724 .await;
2725 }
2726
2727 Ok(())
2728 }
2729
2730 pub async fn confirm_config_recovery(&self, accept: bool) -> Result<Config, AppError> {
2746 let _io = self.config_io_lock.lock().await;
2747
2748 if !accept {
2749 let cfg = self.config.read().await;
2750 return match cfg.recovery_status() {
2751 Some(_) => Ok(cfg.clone()),
2752 None => Err(AppError::BadRequest(
2753 "No pending config-corruption recovery to resolve".to_string(),
2754 )),
2755 };
2756 }
2757
2758 let mut candidate = {
2759 let cfg = self.config.read().await;
2760 match cfg.recovery_status() {
2761 Some(_) => cfg.clone(),
2762 None => {
2763 return Err(AppError::BadRequest(
2764 "No pending config-corruption recovery to resolve".to_string(),
2765 ))
2766 }
2767 }
2768 };
2769
2770 let data_dir = self.app_data_dir.clone();
2771 candidate = tokio::task::spawn_blocking(move || {
2772 candidate
2773 .confirm_recovery_and_save_to_dir(data_dir)
2774 .map(|_| candidate)
2775 })
2776 .await
2777 .map_err(|e| {
2778 AppError::InternalError(anyhow::anyhow!("Config recovery-confirm task failed: {e}"))
2779 })?
2780 .map_err(|e| {
2781 AppError::InternalError(anyhow::anyhow!("Failed to save recovered config: {e}"))
2782 })?;
2783
2784 {
2785 let mut cfg = self.config.write().await;
2786 *cfg = candidate.clone();
2787 cfg.publish_env_vars();
2788 }
2789
2790 Ok(candidate)
2791 }
2792}
2793
2794fn reject_if_recovery_pending(cfg: &Config) -> Result<(), AppError> {
2802 if let Some(status) = cfg.recovery_status() {
2803 if !status.confirmed {
2804 return Err(AppError::ConfigRecoveryPending(format!(
2805 "config.json was recovered from corruption ({:?}) and is awaiting \
2806 confirmation; confirm or reject the recovery (see /bamboo/config/recovery-status \
2807 and /bamboo/config/recovery/confirm) before changing settings",
2808 status.source
2809 )));
2810 }
2811 }
2812 Ok(())
2813}
2814
2815pub(crate) fn warn_plugin_trust_enforcement_off() {
2825 tracing::warn!(
2826 "plugin_trust.enforcement is OFF — plugin installs from ANY URL are accepted \
2827 without host/signature/checksum verification (config.json plugin_trust.enforcement)"
2828 );
2829}
2830
2831#[cfg(test)]
2832mod live_reload_tests {
2833 use super::*;
2834 use bamboo_agent_core::{Message, ToolSchema};
2835 use bamboo_llm::{LLMError, LLMStream};
2836 use bamboo_mcp::{McpServerConfig, ReconnectConfig, StdioConfig};
2837
2838 struct WorkingProvider;
2839
2840 fn disabled_mcp_config(id: &str) -> McpConfig {
2841 McpConfig {
2842 version: 1,
2843 servers: vec![McpServerConfig {
2844 id: id.to_string(),
2845 name: None,
2846 enabled: false,
2847 transport: TransportConfig::Stdio(StdioConfig {
2848 command: "unused-disabled-command".to_string(),
2849 args: vec![],
2850 cwd: None,
2851 env: std::collections::HashMap::new(),
2852 env_encrypted: std::collections::HashMap::new(),
2853 env_credential_refs: std::collections::HashMap::new(),
2854 startup_timeout_ms: 100,
2855 }),
2856 request_timeout_ms: 100,
2857 healthcheck_interval_ms: 100,
2858 reconnect: ReconnectConfig::default(),
2859 allowed_tools: vec![],
2860 denied_tools: vec![],
2861 }],
2862 }
2863 }
2864
2865 fn mcp_document_bytes(revision: u64, config: &McpConfig) -> Vec<u8> {
2866 serde_json::to_vec_pretty(&serde_json::json!({
2867 "schema_version": 1,
2868 "revision": revision,
2869 "data": config,
2870 }))
2871 .unwrap()
2872 }
2873
2874 fn install_unrecoverable_pending_provider_migration(dir: &Path) {
2875 let transaction_id = uuid::Uuid::new_v4().to_string();
2876 std::fs::write(
2877 dir.join("config.json"),
2878 br#"{"providers":{"openai":{"model":"root-lkg"}}}"#,
2879 )
2880 .unwrap();
2881 std::fs::write(
2882 dir.join("providers.json"),
2883 br#"{"schema_version":1,"revision":2,"data":{"openai":{"model":"partial-must-not-load","credential_ref":"provider.openai.api_key"}}}"#,
2884 )
2885 .unwrap();
2886 std::fs::write(
2887 dir.join("config-credential-migration.json"),
2888 serde_json::to_vec_pretty(&serde_json::json!({
2889 "version": 1,
2890 "transaction_id": transaction_id.clone(),
2891 "stage_dir": format!(".config-credential-stage-v1-{transaction_id}"),
2892 "state": "pending",
2893 "files": [
2894 {
2895 "name": "credentials.json",
2896 "staged_name": "credentials.json",
2897 "sha256": "0".repeat(64),
2898 "sensitive": true
2899 },
2900 {
2901 "name": "providers.json",
2902 "staged_name": "providers.json",
2903 "sha256": "1".repeat(64),
2904 "original_sha256": "2".repeat(64),
2905 "migration_generation": 2,
2906 "sensitive": false
2907 }
2908 ]
2909 }))
2910 .unwrap(),
2911 )
2912 .unwrap();
2913 }
2914
2915 async fn wait_for_mcp_health(
2916 state: &AppState,
2917 status: SectionStatus,
2918 minimum_revision: u64,
2919 ) -> ConfigLiveHealth {
2920 match tokio::time::timeout(Duration::from_secs(4), async {
2921 loop {
2922 let health = state
2923 .mcp_config_live_health
2924 .read()
2925 .unwrap_or_else(|poisoned| poisoned.into_inner())
2926 .clone();
2927 if health.status == status && health.revision >= minimum_revision {
2928 break health;
2929 }
2930 tokio::time::sleep(Duration::from_millis(20)).await;
2931 }
2932 })
2933 .await
2934 {
2935 Ok(health) => health,
2936 Err(_) => panic!(
2937 "MCP health transition timed out: {:?}",
2938 state
2939 .mcp_config_live_health
2940 .read()
2941 .unwrap_or_else(|poisoned| poisoned.into_inner())
2942 .clone()
2943 ),
2944 }
2945 }
2946
2947 async fn next_config_event(
2948 feed: &mut tokio::sync::broadcast::Receiver<Arc<bamboo_engine::events::ChangeEvent>>,
2949 expected_section: &str,
2950 ) -> AgentEvent {
2951 tokio::time::timeout(Duration::from_secs(3), async {
2952 loop {
2953 let envelope = feed.recv().await.expect("account feed remains open");
2954 match &envelope.event {
2955 AgentEvent::ConfigChanged { section, .. }
2956 | AgentEvent::ConfigInvalid { section, .. }
2957 | AgentEvent::ConfigRecovered { section, .. }
2958 if section == expected_section =>
2959 {
2960 break envelope.event.clone();
2961 }
2962 _ => {}
2963 }
2964 }
2965 })
2966 .await
2967 .expect("config event timed out")
2968 }
2969
2970 async fn next_mcp_config_event(
2971 feed: &mut tokio::sync::broadcast::Receiver<Arc<bamboo_engine::events::ChangeEvent>>,
2972 ) -> AgentEvent {
2973 next_config_event(feed, "mcp").await
2974 }
2975
2976 async fn wait_for_facade_health(
2977 state: &AppState,
2978 id: SectionId,
2979 status: SectionStatus,
2980 revision: u64,
2981 ) -> bamboo_config::SectionHealth {
2982 tokio::time::timeout(Duration::from_secs(4), async {
2983 loop {
2984 let health = state
2985 .config_facade
2986 .as_ref()
2987 .expect("production state owns a facade")
2988 .registry()
2989 .health()
2990 .unwrap()
2991 .into_iter()
2992 .find(|health| health.section == id)
2993 .unwrap();
2994 if health.status == status && health.revision == revision {
2995 break health;
2996 }
2997 tokio::time::sleep(Duration::from_millis(20)).await;
2998 }
2999 })
3000 .await
3001 .expect("facade health transition timed out")
3002 }
3003
3004 #[test]
3005 fn initial_provider_health_validates_primary_and_backup() {
3006 let dir = tempfile::tempdir().unwrap();
3007 let store = AtomicJsonStore::new(dir.path().join("providers.json"), 1);
3008 let missing = initial_provider_health(&store);
3009 assert_eq!(missing.status, SectionStatus::Missing);
3010 assert_eq!(missing.source_kind, SectionSourceKind::Default);
3011
3012 std::fs::write(dir.path().join("providers.json"), b"{broken").unwrap();
3013 let invalid = initial_provider_health(&store);
3014 assert_eq!(invalid.status, SectionStatus::Invalid);
3015 assert_eq!(invalid.source_kind, SectionSourceKind::File);
3016
3017 std::fs::write(dir.path().join("providers.json.bak"), b"{}").unwrap();
3018 let recovered = initial_provider_health(&store);
3019 assert_eq!(recovered.status, SectionStatus::Degraded);
3020 assert_eq!(recovered.source_kind, SectionSourceKind::Backup);
3021 assert!(recovered
3022 .last_error
3023 .as_deref()
3024 .unwrap()
3025 .contains("last-known-good backup"));
3026
3027 std::fs::write(dir.path().join("providers.json"), b"{}").unwrap();
3028 let healthy = initial_provider_health(&store);
3029 assert_eq!(healthy.status, SectionStatus::Healthy);
3030 assert_eq!(healthy.source_kind, SectionSourceKind::File);
3031 }
3032
3033 #[tokio::test]
3034 async fn unrecoverable_pending_manifest_never_publishes_partial_provider_state() {
3035 let _key = bamboo_config::encryption::set_test_encryption_key([0x6c; 32]);
3036 let dir = tempfile::tempdir().unwrap();
3037 install_unrecoverable_pending_provider_migration(dir.path());
3038
3039 let loaded = Config::from_data_dir_without_env(Some(dir.path().to_path_buf()));
3040 assert_eq!(
3041 loaded.providers().openai.as_ref().unwrap().model.as_deref(),
3042 Some("root-lkg")
3043 );
3044 let store = AtomicJsonStore::new(dir.path().join("providers.json"), 1);
3045 let health = initial_provider_health(&store);
3046 assert_eq!(health.status, SectionStatus::Degraded);
3047 assert!(health
3048 .last_error
3049 .as_deref()
3050 .unwrap()
3051 .contains("migration is pending"));
3052 let error = match load_and_prepare_provider_candidate(&store, 0, loaded).await {
3053 Ok(_) => panic!("pending migration must reject provider candidate"),
3054 Err(error) => error,
3055 };
3056 assert!(error.message.contains("retaining last-known-good runtime"));
3057 assert!(!error.message.contains("partial-must-not-load"));
3058
3059 let state = AppState::new(dir.path().to_path_buf()).await.unwrap();
3060 assert_eq!(
3061 state
3062 .config
3063 .read()
3064 .await
3065 .providers()
3066 .openai
3067 .as_ref()
3068 .unwrap()
3069 .model
3070 .as_deref(),
3071 Some("root-lkg")
3072 );
3073 assert_eq!(
3074 state
3075 .config_live_health
3076 .read()
3077 .unwrap_or_else(|poisoned| poisoned.into_inner())
3078 .status,
3079 SectionStatus::Degraded
3080 );
3081 }
3082
3083 #[async_trait::async_trait]
3084 impl LLMProvider for WorkingProvider {
3085 async fn chat_stream(
3086 &self,
3087 _messages: &[Message],
3088 _tools: &[ToolSchema],
3089 _max_output_tokens: Option<u32>,
3090 _model: &str,
3091 ) -> Result<LLMStream, LLMError> {
3092 Err(LLMError::Api("working-provider-marker".to_string()))
3093 }
3094 }
3095
3096 #[tokio::test]
3097 async fn cancelled_provider_put_cannot_commit_before_publication_guards() {
3098 let _key = bamboo_config::encryption::set_test_encryption_key([0x53; 32]);
3099 let dir = tempfile::tempdir().unwrap();
3100 let state = AppState::new(dir.path().to_path_buf()).await.unwrap();
3101 let secret = "provider-cancel-secret";
3102 let reference = bamboo_config::credential_ref("provider", "openai", "api_key").unwrap();
3103 bamboo_config::CredentialStore::open(dir.path())
3104 .replace(
3105 reference.clone(),
3106 secret,
3107 bamboo_config::CredentialSource::User,
3108 0,
3109 )
3110 .unwrap();
3111 {
3112 let mut config = state.config.write().await;
3113 config.provider = "openai".to_string();
3114 *config.providers_mut() = ProviderConfigs {
3115 openai: Some(bamboo_config::OpenAIConfig {
3116 api_key: secret.to_string(),
3117 credential_ref: Some(reference),
3118 ..Default::default()
3119 }),
3120 ..Default::default()
3121 };
3122 }
3123 let provider_lock = state.provider.clone();
3124 let held_provider = provider_lock.write().await;
3125 let providers_before = std::fs::read(dir.path().join("providers.json")).unwrap();
3126 let mut operation = Box::pin(state.put_provider_section(
3127 0,
3128 ProviderConfigs {
3129 openai: Some(bamboo_config::OpenAIConfig {
3130 model: Some("candidate-model".to_string()),
3131 ..Default::default()
3132 }),
3133 ..Default::default()
3134 },
3135 ));
3136
3137 assert!(
3138 tokio::time::timeout(Duration::from_millis(500), &mut operation)
3139 .await
3140 .is_err()
3141 );
3142 drop(operation);
3143 assert_eq!(
3144 std::fs::read(dir.path().join("providers.json")).unwrap(),
3145 providers_before,
3146 "cancellation while waiting for publication guards must precede durable commit"
3147 );
3148 drop(held_provider);
3149 }
3150
3151 #[test]
3152 fn cancelled_proxy_update_cannot_leave_durable_state_ahead_of_live_snapshot() {
3153 let runtime = tokio::runtime::Builder::new_multi_thread()
3154 .worker_threads(2)
3155 .max_blocking_threads(1)
3156 .enable_all()
3157 .build()
3158 .unwrap();
3159 runtime.block_on(async {
3160 let dir = tempfile::tempdir().unwrap();
3161 let state = Arc::new(AppState::new(dir.path().to_path_buf()).await.unwrap());
3162 wait_for_mcp_health(&state, SectionStatus::Healthy, 0).await;
3163
3164 let (started_tx, started_rx) = tokio::sync::oneshot::channel();
3168 let (release_tx, release_rx) = std::sync::mpsc::sync_channel(0);
3169 let blocker = tokio::task::spawn_blocking(move || {
3170 let _ = started_tx.send(());
3171 release_rx.recv().unwrap();
3172 });
3173 started_rx.await.unwrap();
3174
3175 let operation_state = state.clone();
3176 let operation = tokio::spawn(async move {
3177 operation_state
3178 .update_proxy_auth_credential(
3179 Some(bamboo_config::ProxyAuth {
3180 username: "cancel-user".to_string(),
3181 password: "cancel-secret".to_string(),
3182 }),
3183 0,
3184 ConfigUpdateEffects {
3185 reload_provider: true,
3186 reconcile_mcp: true,
3187 },
3188 )
3189 .await
3190 });
3191
3192 tokio::time::timeout(Duration::from_secs(1), async {
3197 loop {
3198 if state.config_io_lock.try_lock().is_err() {
3199 break;
3200 }
3201 assert!(!operation.is_finished());
3202 tokio::task::yield_now().await;
3203 }
3204 })
3205 .await
3206 .expect("proxy mutation acquires the config IO lock");
3207 operation.abort();
3208 let _ = operation.await;
3209 release_tx.send(()).unwrap();
3210 blocker.await.unwrap();
3211
3212 tokio::time::timeout(Duration::from_secs(5), async {
3213 loop {
3214 let credentials_ready = std::fs::read(dir.path().join("credentials.json"))
3215 .ok()
3216 .and_then(|bytes| serde_json::from_slice::<serde_json::Value>(&bytes).ok())
3217 .and_then(|value| value.get("revision").and_then(|value| value.as_u64()))
3218 == Some(1);
3219 let config_ready = std::fs::read(dir.path().join("core.json"))
3220 .ok()
3221 .and_then(|bytes| serde_json::from_slice::<serde_json::Value>(&bytes).ok())
3222 .and_then(|value| {
3223 value
3224 .get("data")
3225 .and_then(|value| value.get("proxy_auth_credential_ref"))
3226 .and_then(|value| value.as_str())
3227 .map(str::to_string)
3228 })
3229 .as_deref()
3230 == Some("proxy.default.auth");
3231 if credentials_ready && config_ready {
3232 break;
3233 }
3234 tokio::task::yield_now().await;
3235 }
3236 })
3237 .await
3238 .expect("owned durable transaction completes after caller cancellation");
3239
3240 let converged =
3244 tokio::time::timeout(Duration::from_secs(5), state.config_io_lock.lock())
3245 .await
3246 .expect("owned runtime convergence completes after cancellation");
3247 drop(converged);
3248
3249 let live = state.config.read().await;
3250 assert_eq!(
3251 live.proxy_auth_credential_ref
3252 .as_ref()
3253 .map(|reference| reference.as_str()),
3254 Some("proxy.default.auth")
3255 );
3256 let auth = live
3257 .proxy_auth
3258 .as_ref()
3259 .expect("durable proxy auth must be published despite cancellation");
3260 assert_eq!(auth.username, "cancel-user");
3261 assert_eq!(auth.password, "cancel-secret");
3262 drop(live);
3263
3264 let root = std::fs::read_to_string(dir.path().join("core.json")).unwrap();
3265 let credentials = std::fs::read_to_string(dir.path().join("credentials.json")).unwrap();
3266 assert!(!root.contains("cancel-secret"));
3267 assert!(!credentials.contains("cancel-secret"));
3268 });
3269 }
3270
3271 #[tokio::test]
3272 async fn missing_or_corrupt_referenced_credentials_reject_candidates_redacted() {
3273 let _key = bamboo_config::encryption::set_test_encryption_key([0x68; 32]);
3274 for corrupt_credentials in [false, true] {
3275 let dir = tempfile::tempdir().unwrap();
3276 let reference = bamboo_config::credential_ref("provider", "openai", "api_key").unwrap();
3277 let providers = ProviderConfigs {
3278 openai: Some(bamboo_config::OpenAIConfig {
3279 credential_ref: Some(reference),
3280 model: Some("candidate".to_string()),
3281 ..Default::default()
3282 }),
3283 ..Default::default()
3284 };
3285 let provider_store = AtomicJsonStore::new(dir.path().join("providers.json"), 1);
3286 provider_store
3287 .commit(0, providers, validate_provider_config)
3288 .unwrap();
3289 if corrupt_credentials {
3290 std::fs::write(dir.path().join("credentials.json"), b"{corrupt-secret").unwrap();
3291 }
3292 let error =
3293 match load_and_prepare_provider_candidate(&provider_store, 0, Config::default())
3294 .await
3295 {
3296 Ok(_) => panic!("unavailable credential must reject provider candidate"),
3297 Err(error) => error,
3298 };
3299 assert_eq!(error.message, "provider credential is unavailable");
3300 assert!(!error
3301 .message
3302 .contains(dir.path().to_string_lossy().as_ref()));
3303
3304 let mut mcp = disabled_mcp_config("credential-lkg");
3305 let TransportConfig::Stdio(stdio) = &mut mcp.servers[0].transport else {
3306 unreachable!()
3307 };
3308 stdio.env_credential_refs.insert(
3309 "TOKEN".to_string(),
3310 bamboo_config::credential_ref("mcp", "credential-lkg", "env_TOKEN")
3311 .unwrap()
3312 .as_str()
3313 .to_string(),
3314 );
3315 let mcp_store = AtomicJsonStore::new(dir.path().join("mcp.json"), 1);
3316 mcp_store.commit(0, mcp, validate_mcp_config).unwrap();
3317 let error = match load_and_validate_mcp_candidate(
3318 &mcp_store,
3319 0,
3320 Config::default(),
3321 false,
3322 )
3323 .await
3324 {
3325 Ok(_) => panic!("unavailable credential must reject MCP candidate"),
3326 Err(error) => error,
3327 };
3328 assert_eq!(error.message, "MCP credential is unavailable");
3329 assert!(!error.message.contains("TOKEN"));
3330 assert!(!error
3331 .message
3332 .contains(dir.path().to_string_lossy().as_ref()));
3333 }
3334 }
3335
3336 #[tokio::test]
3337 async fn typed_provider_put_switches_refs_and_rejects_missing_ref_without_mutation() {
3338 let _key = bamboo_config::encryption::set_test_encryption_key([0x6d; 32]);
3339 let dir = tempfile::tempdir().unwrap();
3340 let state = AppState::new(dir.path().to_path_buf()).await.unwrap();
3341 let ref_a = bamboo_config::credential_ref("provider", "openai-a", "api_key").unwrap();
3342 let ref_b = bamboo_config::credential_ref("provider", "openai-b", "api_key").unwrap();
3343 let missing = bamboo_config::credential_ref("provider", "missing", "api_key").unwrap();
3344 state
3345 .credential_store
3346 .replace(
3347 ref_a.clone(),
3348 "provider-secret-a",
3349 bamboo_config::CredentialSource::User,
3350 0,
3351 )
3352 .unwrap();
3353 state
3354 .credential_store
3355 .replace(
3356 ref_b.clone(),
3357 "provider-secret-b",
3358 bamboo_config::CredentialSource::User,
3359 1,
3360 )
3361 .unwrap();
3362 {
3363 let mut config = state.config.write().await;
3364 config.provider = "openai".to_string();
3365 *config.providers_mut() = ProviderConfigs {
3366 openai: Some(bamboo_config::OpenAIConfig {
3367 api_key: "provider-secret-a".to_string(),
3368 credential_ref: Some(ref_a),
3369 ..Default::default()
3370 }),
3371 ..Default::default()
3372 };
3373 }
3374
3375 let revision = state
3376 .put_provider_section(
3377 0,
3378 ProviderConfigs {
3379 openai: Some(bamboo_config::OpenAIConfig {
3380 credential_ref: Some(ref_b.clone()),
3381 model: Some("switched".to_string()),
3382 ..Default::default()
3383 }),
3384 ..Default::default()
3385 },
3386 )
3387 .await
3388 .unwrap();
3389 assert_eq!(revision, 1);
3390 let runtime = state.config.read().await;
3391 let openai = runtime.providers().openai.as_ref().unwrap();
3392 assert_eq!(openai.credential_ref.as_ref(), Some(&ref_b));
3393 assert_eq!(openai.api_key, "provider-secret-b");
3394 drop(runtime);
3395 let disk_before = std::fs::read(dir.path().join("providers.json")).unwrap();
3396
3397 assert!(state
3398 .put_provider_section(
3399 1,
3400 ProviderConfigs {
3401 openai: Some(bamboo_config::OpenAIConfig {
3402 credential_ref: Some(missing),
3403 model: Some("must-not-publish".to_string()),
3404 ..Default::default()
3405 }),
3406 ..Default::default()
3407 },
3408 )
3409 .await
3410 .is_err());
3411 assert_eq!(
3412 std::fs::read(dir.path().join("providers.json")).unwrap(),
3413 disk_before
3414 );
3415 let runtime = state.config.read().await;
3416 let openai = runtime.providers().openai.as_ref().unwrap();
3417 assert_eq!(openai.credential_ref.as_ref(), Some(&ref_b));
3418 assert_eq!(openai.api_key, "provider-secret-b");
3419 }
3420
3421 #[tokio::test]
3422 async fn typed_mcp_put_switches_stdio_and_header_refs_atomically() {
3423 let _key = bamboo_config::encryption::set_test_encryption_key([0x6e; 32]);
3424 let dir = tempfile::tempdir().unwrap();
3425 let state = AppState::new(dir.path().to_path_buf()).await.unwrap();
3426 let refs = [
3427 bamboo_config::credential_ref("mcp", "stdio-a", "env_TOKEN").unwrap(),
3428 bamboo_config::credential_ref("mcp", "header-a", "header_Authorization").unwrap(),
3429 bamboo_config::credential_ref("mcp", "stdio-b", "env_TOKEN").unwrap(),
3430 bamboo_config::credential_ref("mcp", "header-b", "header_Authorization").unwrap(),
3431 ];
3432 for (revision, (reference, value)) in refs
3433 .iter()
3434 .zip(["env-a", "header-a", "env-b", "header-b"])
3435 .enumerate()
3436 {
3437 state
3438 .credential_store
3439 .replace(
3440 reference.clone(),
3441 value,
3442 bamboo_config::CredentialSource::User,
3443 revision as u64,
3444 )
3445 .unwrap();
3446 }
3447 let make_config = |env_ref: &bamboo_config::CredentialRef,
3448 header_ref: &bamboo_config::CredentialRef| {
3449 McpConfig {
3450 version: 1,
3451 servers: vec![
3452 McpServerConfig {
3453 id: "switch-stdio".to_string(),
3454 name: None,
3455 enabled: false,
3456 transport: TransportConfig::Stdio(StdioConfig {
3457 command: "unused-disabled-command".to_string(),
3458 args: vec![],
3459 cwd: None,
3460 env: std::collections::HashMap::new(),
3461 env_encrypted: std::collections::HashMap::new(),
3462 env_credential_refs: std::collections::HashMap::from([(
3463 "TOKEN".to_string(),
3464 env_ref.as_str().to_string(),
3465 )]),
3466 startup_timeout_ms: 100,
3467 }),
3468 request_timeout_ms: 100,
3469 healthcheck_interval_ms: 100,
3470 reconnect: ReconnectConfig::default(),
3471 allowed_tools: vec![],
3472 denied_tools: vec![],
3473 },
3474 McpServerConfig {
3475 id: "switch-header".to_string(),
3476 name: None,
3477 enabled: false,
3478 transport: TransportConfig::Sse(bamboo_mcp::SseConfig {
3479 url: "https://example.test/sse".to_string(),
3480 headers: vec![bamboo_mcp::HeaderConfig {
3481 name: "Authorization".to_string(),
3482 value: String::new(),
3483 value_encrypted: None,
3484 credential_ref: Some(header_ref.as_str().to_string()),
3485 }],
3486 connect_timeout_ms: 100,
3487 }),
3488 request_timeout_ms: 100,
3489 healthcheck_interval_ms: 100,
3490 reconnect: ReconnectConfig::default(),
3491 allowed_tools: vec![],
3492 denied_tools: vec![],
3493 },
3494 ],
3495 }
3496 };
3497 let mut current = make_config(&refs[0], &refs[1]);
3498 if let TransportConfig::Stdio(stdio) = &mut current.servers[0].transport {
3499 stdio.env.insert("TOKEN".to_string(), "env-a".to_string());
3500 }
3501 if let TransportConfig::Sse(sse) = &mut current.servers[1].transport {
3502 sse.headers[0].value = "header-a".to_string();
3503 }
3504 state.config.write().await.mcp = current;
3505
3506 assert_eq!(
3507 state
3508 .put_mcp_section(0, make_config(&refs[2], &refs[3]))
3509 .await
3510 .unwrap(),
3511 1
3512 );
3513 let runtime = state.config.read().await;
3514 let TransportConfig::Stdio(stdio) = &runtime.mcp.servers[0].transport else {
3515 panic!("stdio transport")
3516 };
3517 assert_eq!(stdio.env["TOKEN"], "env-b");
3518 let TransportConfig::Sse(sse) = &runtime.mcp.servers[1].transport else {
3519 panic!("sse transport")
3520 };
3521 assert_eq!(sse.headers[0].value, "header-b");
3522 drop(runtime);
3523 let disk_before = std::fs::read(dir.path().join("mcp.json")).unwrap();
3524 let missing_env = bamboo_config::credential_ref("mcp", "missing", "env_TOKEN").unwrap();
3525 let missing_header =
3526 bamboo_config::credential_ref("mcp", "missing", "header_Authorization").unwrap();
3527 assert!(state
3528 .put_mcp_section(1, make_config(&missing_env, &missing_header))
3529 .await
3530 .is_err());
3531 assert_eq!(
3532 std::fs::read(dir.path().join("mcp.json")).unwrap(),
3533 disk_before
3534 );
3535 let runtime = state.config.read().await;
3536 let TransportConfig::Stdio(stdio) = &runtime.mcp.servers[0].transport else {
3537 panic!("stdio transport")
3538 };
3539 assert_eq!(stdio.env["TOKEN"], "env-b");
3540 }
3541
3542 #[tokio::test]
3543 async fn failed_candidate_keeps_existing_provider_registry_and_runtime() {
3544 let dir = tempfile::tempdir().unwrap();
3545 let state = AppState::new(dir.path().to_path_buf()).await.unwrap();
3546 let working: Arc<dyn LLMProvider> = Arc::new(WorkingProvider);
3547 state
3548 .provider_registry
3549 .insert("working".to_string(), working.clone());
3550 state.provider_registry.set_default("working".to_string());
3551 *state.provider.write().await = working.clone();
3552 state.config.write().await.provider = "openai".to_string();
3553
3554 assert!(state.reload_provider().await.is_err());
3555 assert_eq!(state.provider_registry.default_provider_name(), "working");
3556 assert!(Arc::ptr_eq(
3557 &state.provider_registry.get_default().unwrap(),
3558 &working
3559 ));
3560 let live = state.provider.read().await;
3561 assert!(Arc::ptr_eq(&*live, &working));
3562 }
3563
3564 #[tokio::test]
3565 async fn provider_watcher_retains_lkg_on_invalid_and_recovers_after_repair() {
3566 let _key = bamboo_config::encryption::set_test_encryption_key([0x43; 32]);
3567 let dir = tempfile::tempdir().unwrap();
3568 let state = AppState::new(dir.path().to_path_buf()).await.unwrap();
3569 let working: Arc<dyn LLMProvider> = Arc::new(WorkingProvider);
3570 state
3571 .provider_registry
3572 .insert("working".to_string(), working.clone());
3573 state.provider_registry.set_default("working".to_string());
3574 *state.provider.write().await = working.clone();
3575 state.config.write().await.provider = "openai".to_string();
3576 wait_for_mcp_health(&state, SectionStatus::Healthy, 0).await;
3577 let mut feed = state.account_sink.subscribe();
3578 let providers_path = dir.path().join("providers.json");
3579
3580 std::fs::write(&providers_path, b"{broken").unwrap();
3581 tokio::time::timeout(Duration::from_secs(3), async {
3582 loop {
3583 if state
3584 .config_live_health
3585 .read()
3586 .unwrap_or_else(|poisoned| poisoned.into_inner())
3587 .status
3588 == SectionStatus::Invalid
3589 {
3590 break;
3591 }
3592 tokio::time::sleep(Duration::from_millis(20)).await;
3593 }
3594 })
3595 .await
3596 .unwrap();
3597 assert_eq!(
3598 state
3599 .config_live_health
3600 .read()
3601 .unwrap_or_else(|poisoned| poisoned.into_inner())
3602 .revision,
3603 0,
3604 "invalid edits must not advance the LKG revision"
3605 );
3606 {
3607 let health = state
3608 .config_live_health
3609 .read()
3610 .unwrap_or_else(|poisoned| poisoned.into_inner());
3611 assert_eq!(health.status, SectionStatus::Invalid);
3612 assert_eq!(health.source_kind, SectionSourceKind::File);
3613 assert_eq!(health.source_path, providers_path);
3614 }
3615 assert!(Arc::ptr_eq(
3616 &state.provider_registry.get_default().unwrap(),
3617 &working
3618 ));
3619 let invalid = next_config_event(&mut feed, "providers").await;
3620 assert!(matches!(
3621 invalid,
3622 AgentEvent::ConfigInvalid { revision: 0, .. }
3623 ));
3624
3625 let reference = bamboo_config::credential_ref("provider", "openai", "api_key").unwrap();
3626 let credential_store = bamboo_config::CredentialStore::open(dir.path());
3627 let credential_revision = credential_store.revision().unwrap();
3628 credential_store
3629 .replace(
3630 reference.clone(),
3631 "watcher-test-key",
3632 bamboo_config::CredentialSource::User,
3633 credential_revision,
3634 )
3635 .unwrap();
3636 let providers = ProviderConfigs {
3637 openai: Some(bamboo_config::OpenAIConfig {
3638 credential_ref: Some(reference),
3639 ..Default::default()
3640 }),
3641 ..Default::default()
3642 };
3643 std::fs::write(
3644 &providers_path,
3645 serde_json::to_vec_pretty(&providers).unwrap(),
3646 )
3647 .unwrap();
3648 tokio::time::timeout(Duration::from_secs(3), async {
3649 loop {
3650 let health = state
3651 .config_live_health
3652 .read()
3653 .unwrap_or_else(|poisoned| poisoned.into_inner())
3654 .clone();
3655 if health.status == SectionStatus::Healthy && health.revision == 1 {
3656 break;
3657 }
3658 tokio::time::sleep(Duration::from_millis(20)).await;
3659 }
3660 })
3661 .await
3662 .unwrap();
3663 let recovered = next_config_event(&mut feed, "providers").await;
3664 assert!(matches!(
3665 recovered,
3666 AgentEvent::ConfigRecovered { revision: 1, .. }
3667 ));
3668 assert_eq!(state.provider_registry.default_provider_name(), "openai");
3669 }
3670
3671 #[tokio::test]
3672 async fn ordinary_section_watcher_updates_runtime_retains_lkg_and_recovers() {
3673 let _key = bamboo_config::encryption::set_test_encryption_key([0x44; 32]);
3674 let dir = tempfile::tempdir().unwrap();
3675 let state = AppState::new(dir.path().to_path_buf()).await.unwrap();
3676 wait_for_mcp_health(&state, SectionStatus::Healthy, 0).await;
3677 let mut feed = state.account_sink.subscribe();
3678 let path = dir.path().join("core.json");
3679 let mut document: serde_json::Value =
3680 serde_json::from_slice(&std::fs::read(&path).unwrap()).unwrap();
3681 document["revision"] = serde_json::json!(2);
3682 document["data"]["server"]["port"] = serde_json::json!(9876);
3683 std::fs::write(&path, serde_json::to_vec_pretty(&document).unwrap()).unwrap();
3684
3685 wait_for_facade_health(&state, SectionId::Core, SectionStatus::Healthy, 2).await;
3686 tokio::time::timeout(Duration::from_secs(3), async {
3687 loop {
3688 if state.config.read().await.server.port == 9876 {
3689 break;
3690 }
3691 tokio::time::sleep(Duration::from_millis(20)).await;
3692 }
3693 })
3694 .await
3695 .unwrap();
3696 assert!(matches!(
3697 next_config_event(&mut feed, "core").await,
3698 AgentEvent::ConfigChanged { revision: 2, .. }
3699 ));
3700
3701 std::fs::write(&path, b"{broken").unwrap();
3702 wait_for_facade_health(&state, SectionId::Core, SectionStatus::Invalid, 2).await;
3703 assert_eq!(state.config.read().await.server.port, 9876);
3704 assert!(matches!(
3705 next_config_event(&mut feed, "core").await,
3706 AgentEvent::ConfigInvalid { revision: 2, .. }
3707 ));
3708
3709 document["revision"] = serde_json::json!(3);
3710 document["data"]["server"]["port"] = serde_json::json!(9877);
3711 std::fs::write(&path, serde_json::to_vec_pretty(&document).unwrap()).unwrap();
3712 wait_for_facade_health(&state, SectionId::Core, SectionStatus::Healthy, 3).await;
3713 tokio::time::timeout(Duration::from_secs(3), async {
3714 loop {
3715 if state.config.read().await.server.port == 9877 {
3716 break;
3717 }
3718 tokio::time::sleep(Duration::from_millis(20)).await;
3719 }
3720 })
3721 .await
3722 .unwrap();
3723 assert!(matches!(
3724 next_config_event(&mut feed, "core").await,
3725 AgentEvent::ConfigRecovered { revision: 3, .. }
3726 ));
3727
3728 std::fs::remove_file(&path).unwrap();
3729 wait_for_facade_health(&state, SectionId::Core, SectionStatus::Missing, 3).await;
3730 assert_eq!(state.config.read().await.server.port, 9877);
3731 assert!(matches!(
3732 next_config_event(&mut feed, "core").await,
3733 AgentEvent::ConfigInvalid { revision: 3, .. }
3734 ));
3735
3736 document["revision"] = serde_json::json!(4);
3737 document["data"]["server"]["port"] = serde_json::json!(9878);
3738 let swap = dir.path().join("core.json.swap");
3739 std::fs::write(&swap, serde_json::to_vec_pretty(&document).unwrap()).unwrap();
3740 std::fs::rename(&swap, &path).unwrap();
3741 wait_for_facade_health(&state, SectionId::Core, SectionStatus::Healthy, 4).await;
3742 tokio::time::timeout(Duration::from_secs(3), async {
3743 loop {
3744 if state.config.read().await.server.port == 9878 {
3745 break;
3746 }
3747 tokio::time::sleep(Duration::from_millis(20)).await;
3748 }
3749 })
3750 .await
3751 .unwrap();
3752 assert!(matches!(
3753 next_config_event(&mut feed, "core").await,
3754 AgentEvent::ConfigRecovered { revision: 4, .. }
3755 ));
3756 }
3757
3758 #[tokio::test]
3759 async fn mcp_watcher_updates_lkg_rejects_invalid_and_recovers_after_atomic_replace() {
3760 let _key = bamboo_config::encryption::set_test_encryption_key([0x45; 32]);
3761 let dir = tempfile::tempdir().unwrap();
3762 let state = AppState::new(dir.path().to_path_buf()).await.unwrap();
3763 wait_for_mcp_health(&state, SectionStatus::Healthy, 0).await;
3764 let mut feed = state.account_sink.subscribe();
3765 let path = dir.path().join("mcp.json");
3766
3767 std::fs::write(&path, mcp_document_bytes(2, &disabled_mcp_config("first"))).unwrap();
3768 let first = wait_for_mcp_health(&state, SectionStatus::Healthy, 2).await;
3769 assert_eq!(first.revision, 2);
3770 assert_eq!(state.config.read().await.mcp.servers[0].id, "first");
3771 assert!(matches!(
3772 next_mcp_config_event(&mut feed).await,
3773 AgentEvent::ConfigChanged { revision: 2, .. }
3774 ));
3775
3776 std::fs::write(&path, b"{broken").unwrap();
3777 let invalid = wait_for_mcp_health(&state, SectionStatus::Invalid, 2).await;
3778 assert_eq!(invalid.revision, 2, "invalid candidates cannot advance LKG");
3779 assert_eq!(state.config.read().await.mcp.servers[0].id, "first");
3780 assert!(matches!(
3781 next_mcp_config_event(&mut feed).await,
3782 AgentEvent::ConfigInvalid { revision: 2, .. }
3783 ));
3784
3785 let swap = dir.path().join("mcp.json.swap");
3790 std::fs::write(
3791 &swap,
3792 mcp_document_bytes(3, &disabled_mcp_config("intermediate")),
3793 )
3794 .unwrap();
3795 std::fs::rename(&swap, &path).unwrap();
3796 std::fs::write(
3797 &path,
3798 mcp_document_bytes(3, &disabled_mcp_config("recovered")),
3799 )
3800 .unwrap();
3801 let recovered = wait_for_mcp_health(&state, SectionStatus::Healthy, 3).await;
3802 assert_eq!(recovered.revision, 3, "rename burst should coalesce once");
3803 assert_eq!(state.config.read().await.mcp.servers[0].id, "recovered");
3804 assert!(matches!(
3805 next_mcp_config_event(&mut feed).await,
3806 AgentEvent::ConfigRecovered { revision: 3, .. }
3807 ));
3808
3809 std::fs::write(
3814 &path,
3815 mcp_document_bytes(3, &disabled_mcp_config("normalized")),
3816 )
3817 .unwrap();
3818 let normalized = wait_for_mcp_health(&state, SectionStatus::Healthy, 4).await;
3819 assert_eq!(normalized.revision, 4);
3820 assert_eq!(state.config.read().await.mcp.servers[0].id, "normalized");
3821 assert!(matches!(
3822 next_mcp_config_event(&mut feed).await,
3823 AgentEvent::ConfigChanged { revision: 4, .. }
3824 ));
3825 assert!(
3826 tokio::time::timeout(Duration::from_millis(500), feed.recv())
3827 .await
3828 .is_err()
3829 );
3830 let persisted: serde_json::Value =
3831 serde_json::from_slice(&std::fs::read(&path).unwrap()).unwrap();
3832 assert_eq!(persisted["revision"], 4);
3833 }
3834
3835 #[tokio::test]
3836 async fn mcp_sidecar_present_at_startup_is_applied_through_runtime_transaction() {
3837 let _key = bamboo_config::encryption::set_test_encryption_key([0x47; 32]);
3838 let dir = tempfile::tempdir().unwrap();
3839 std::fs::write(
3840 dir.path().join("mcp.json"),
3841 mcp_document_bytes(1, &disabled_mcp_config("startup-sidecar")),
3842 )
3843 .unwrap();
3844
3845 let state = AppState::new(dir.path().to_path_buf()).await.unwrap();
3846 let health = wait_for_mcp_health(&state, SectionStatus::Healthy, 1).await;
3847 assert_eq!(health.revision, 1);
3848 assert_eq!(health.source_kind, SectionSourceKind::File);
3849 assert_eq!(
3850 state.config.read().await.mcp.servers[0].id,
3851 "startup-sidecar"
3852 );
3853 }
3854
3855 #[tokio::test]
3856 async fn mcp_startup_uses_valid_backup_and_reports_degraded_invalid_health() {
3857 let _key = bamboo_config::encryption::set_test_encryption_key([0x48; 32]);
3858 let dir = tempfile::tempdir().unwrap();
3859 let path = dir.path().join("mcp.json");
3860 let store = AtomicJsonStore::new(&path, 1);
3861 store
3862 .commit(0, disabled_mcp_config("backup-lkg"), validate_mcp_config)
3863 .unwrap();
3864 store
3865 .commit(1, disabled_mcp_config("new-primary"), validate_mcp_config)
3866 .unwrap();
3867 std::fs::write(&path, b"{corrupt-primary").unwrap();
3868
3869 let state = AppState::new(dir.path().to_path_buf()).await.unwrap();
3870 let health = wait_for_mcp_health(&state, SectionStatus::Degraded, 1).await;
3871 assert_eq!(health.revision, 1);
3872 assert_eq!(health.source_kind, SectionSourceKind::Backup);
3873 assert_eq!(health.source_path, path.with_extension("json.bak"));
3874 assert!(health
3875 .last_error
3876 .as_deref()
3877 .unwrap()
3878 .contains("last-known-good backup runtime"));
3879 assert_eq!(state.config.read().await.mcp.servers[0].id, "backup-lkg");
3880
3881 tokio::time::timeout(Duration::from_secs(3), async {
3882 loop {
3883 if state.account_sink.latest_seq() > 0 {
3884 break;
3885 }
3886 tokio::time::sleep(Duration::from_millis(20)).await;
3887 }
3888 })
3889 .await
3890 .unwrap();
3891 tokio::time::sleep(Duration::from_millis(500)).await;
3892 let events =
3893 bamboo_engine::events::journal::read_since(state.account_sink.events_dir(), 0).unwrap();
3894 assert_eq!(
3895 events
3896 .iter()
3897 .filter(|event| matches!(
3898 &event.event,
3899 AgentEvent::ConfigInvalid { section, revision }
3900 if section == "mcp" && *revision == 1
3901 ))
3902 .count(),
3903 1
3904 );
3905 let stable_health = state
3906 .mcp_config_live_health
3907 .read()
3908 .unwrap_or_else(|poisoned| poisoned.into_inner())
3909 .clone();
3910 assert_eq!(stable_health.status, SectionStatus::Degraded);
3911 assert_eq!(stable_health.source_kind, SectionSourceKind::Backup);
3912 assert_eq!(stable_health.source_path, path.with_extension("json.bak"));
3913 }
3914
3915 #[tokio::test]
3916 async fn mcp_runtime_init_failure_marks_degraded_and_retains_lkg_config() {
3917 let _key = bamboo_config::encryption::set_test_encryption_key([0x46; 32]);
3918 let dir = tempfile::tempdir().unwrap();
3919 let state = AppState::new(dir.path().to_path_buf()).await.unwrap();
3920 wait_for_mcp_health(&state, SectionStatus::Healthy, 0).await;
3921 let mut feed = state.account_sink.subscribe();
3922 let path = dir.path().join("mcp.json");
3923
3924 std::fs::write(
3925 &path,
3926 mcp_document_bytes(1, &disabled_mcp_config("last-known-good")),
3927 )
3928 .unwrap();
3929 wait_for_mcp_health(&state, SectionStatus::Healthy, 1).await;
3930 let _ = next_mcp_config_event(&mut feed).await;
3931
3932 let mut failing = disabled_mcp_config("candidate");
3933 failing.servers[0].enabled = true;
3934 if let TransportConfig::Stdio(stdio) = &mut failing.servers[0].transport {
3935 stdio.command = "definitely-not-a-real-mcp-command-597".to_string();
3936 }
3937 std::fs::write(&path, mcp_document_bytes(2, &failing)).unwrap();
3938
3939 let degraded = wait_for_mcp_health(&state, SectionStatus::Degraded, 1).await;
3940 assert_eq!(degraded.revision, 1);
3941 assert!(degraded
3942 .last_error
3943 .as_deref()
3944 .unwrap()
3945 .contains("last-known-good runtime"));
3946 assert_eq!(
3947 state.config.read().await.mcp.servers[0].id,
3948 "last-known-good"
3949 );
3950 assert!(state.mcp_manager.list_servers().is_empty());
3951 assert!(matches!(
3952 next_mcp_config_event(&mut feed).await,
3953 AgentEvent::ConfigInvalid { revision: 1, .. }
3954 ));
3955 }
3956}