1use super::*;
2
3use std::collections::{BTreeMap, BTreeSet};
4use std::path::{Path, PathBuf};
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
18#[cfg(test)]
19struct ClusterAfterCommitBeforeAdoptionTestHook {
20 expected_revision: u64,
21 hook: Box<dyn FnOnce(&Path) + Send + 'static>,
22}
23
24#[cfg(test)]
25fn cluster_after_commit_before_adoption_test_hooks() -> &'static std::sync::Mutex<
26 std::collections::HashMap<PathBuf, ClusterAfterCommitBeforeAdoptionTestHook>,
27> {
28 static HOOKS: std::sync::OnceLock<
29 std::sync::Mutex<
30 std::collections::HashMap<PathBuf, ClusterAfterCommitBeforeAdoptionTestHook>,
31 >,
32 > = std::sync::OnceLock::new();
33 HOOKS.get_or_init(|| std::sync::Mutex::new(std::collections::HashMap::new()))
34}
35
36#[cfg(test)]
37fn set_cluster_after_commit_before_adoption_test_hook(
38 data_dir: &Path,
39 expected_revision: u64,
40 hook: impl FnOnce(&Path) + Send + 'static,
41) {
42 cluster_after_commit_before_adoption_test_hooks()
43 .lock()
44 .unwrap_or_else(|poisoned| poisoned.into_inner())
45 .insert(
46 data_dir.to_path_buf(),
47 ClusterAfterCommitBeforeAdoptionTestHook {
48 expected_revision,
49 hook: Box::new(hook),
50 },
51 );
52}
53
54#[cfg(test)]
55fn run_cluster_after_commit_before_adoption_test_hook(data_dir: &Path, expected_revision: u64) {
56 let hook = {
57 let mut hooks = cluster_after_commit_before_adoption_test_hooks()
58 .lock()
59 .unwrap_or_else(|poisoned| poisoned.into_inner());
60 if hooks
61 .get(data_dir)
62 .is_some_and(|hook| hook.expected_revision == expected_revision)
63 {
64 hooks.remove(data_dir)
65 } else {
66 None
67 }
68 };
69 if let Some(hook) = hook {
70 (hook.hook)(data_dir);
71 }
72}
73
74#[cfg(test)]
75type CredentialCommitTestHook = Box<dyn FnOnce() + Send + 'static>;
76#[cfg(test)]
77type CredentialCommitTestHooks =
78 std::sync::Mutex<std::collections::HashMap<(PathBuf, SectionId), CredentialCommitTestHook>>;
79
80#[cfg(test)]
81fn credential_after_commit_before_live_test_hooks() -> &'static CredentialCommitTestHooks {
82 static HOOKS: std::sync::OnceLock<CredentialCommitTestHooks> = std::sync::OnceLock::new();
83 HOOKS.get_or_init(|| std::sync::Mutex::new(std::collections::HashMap::new()))
84}
85
86#[cfg(test)]
87fn set_credential_after_commit_before_live_test_hook(
88 data_dir: &Path,
89 section: SectionId,
90 hook: impl FnOnce() + Send + 'static,
91) {
92 credential_after_commit_before_live_test_hooks()
93 .lock()
94 .unwrap_or_else(|poisoned| poisoned.into_inner())
95 .insert((data_dir.to_path_buf(), section), Box::new(hook));
96}
97
98#[cfg(test)]
99fn run_credential_after_commit_before_live_test_hook(data_dir: &Path, section: SectionId) {
100 let hook = credential_after_commit_before_live_test_hooks()
101 .lock()
102 .unwrap_or_else(|poisoned| poisoned.into_inner())
103 .remove(&(data_dir.to_path_buf(), section));
104 if let Some(hook) = hook {
105 hook();
106 }
107}
108
109#[cfg(test)]
110type GenericBeforeEventTestHook = Box<dyn FnOnce() + Send + 'static>;
111#[cfg(test)]
112type GenericBeforeEventTestHooks =
113 std::sync::Mutex<std::collections::HashMap<PathBuf, GenericBeforeEventTestHook>>;
114
115#[cfg(test)]
116fn generic_before_event_test_hooks() -> &'static GenericBeforeEventTestHooks {
117 static HOOKS: std::sync::OnceLock<GenericBeforeEventTestHooks> = std::sync::OnceLock::new();
118 HOOKS.get_or_init(|| std::sync::Mutex::new(std::collections::HashMap::new()))
119}
120
121#[cfg(test)]
122fn set_generic_before_event_test_hook(data_dir: &Path, hook: impl FnOnce() + Send + 'static) {
123 generic_before_event_test_hooks()
124 .lock()
125 .unwrap_or_else(|poisoned| poisoned.into_inner())
126 .insert(data_dir.to_path_buf(), Box::new(hook));
127}
128
129#[cfg(test)]
130fn run_generic_before_event_test_hook(data_dir: &Path) {
131 let hook = generic_before_event_test_hooks()
132 .lock()
133 .unwrap_or_else(|poisoned| poisoned.into_inner())
134 .remove(data_dir);
135 if let Some(hook) = hook {
136 hook();
137 }
138}
139
140#[cfg(test)]
141type GenericBeforeProviderPublishTestHook = Box<dyn FnOnce() + Send + 'static>;
142#[cfg(test)]
143type GenericBeforeProviderPublishTestHooks =
144 std::sync::Mutex<std::collections::HashMap<PathBuf, GenericBeforeProviderPublishTestHook>>;
145
146#[cfg(test)]
147fn generic_before_provider_publish_test_hooks() -> &'static GenericBeforeProviderPublishTestHooks {
148 static HOOKS: std::sync::OnceLock<GenericBeforeProviderPublishTestHooks> =
149 std::sync::OnceLock::new();
150 HOOKS.get_or_init(|| std::sync::Mutex::new(std::collections::HashMap::new()))
151}
152
153#[cfg(test)]
154fn set_generic_before_provider_publish_test_hook(
155 data_dir: &Path,
156 hook: impl FnOnce() + Send + 'static,
157) {
158 generic_before_provider_publish_test_hooks()
159 .lock()
160 .unwrap_or_else(|poisoned| poisoned.into_inner())
161 .insert(data_dir.to_path_buf(), Box::new(hook));
162}
163
164#[cfg(test)]
165fn run_generic_before_provider_publish_test_hook(data_dir: &Path) {
166 let hook = generic_before_provider_publish_test_hooks()
167 .lock()
168 .unwrap_or_else(|poisoned| poisoned.into_inner())
169 .remove(data_dir);
170 if let Some(hook) = hook {
171 hook();
172 }
173}
174
175struct FacadeRuntimeMaterialization {
176 config: Config,
177 failures: BTreeSet<SectionId>,
178}
179
180fn materialize_facade_effective_config(
181 facade: &bamboo_config::ConfigFacade,
182 data_dir: &Path,
183) -> FacadeRuntimeMaterialization {
184 let mut config = facade.effective_config();
185 let mut failures = BTreeSet::new();
186 if let Err(error) = config.hydrate_proxy_auth_from_store(data_dir) {
187 tracing::warn!(error = %error, "proxy auth credential hydration unavailable");
188 config.proxy_auth = None;
189 failures.insert(SectionId::Core);
190 }
191 if let Err(error) = config.hydrate_provider_credentials_from_store(data_dir) {
192 tracing::warn!(error = %error, "provider credential hydration unavailable");
193 failures.insert(SectionId::Providers);
194 }
195 if let Err(error) = config.hydrate_mcp_credentials_from_store(data_dir) {
196 tracing::warn!(error = %error, "MCP credential hydration unavailable");
197 failures.insert(SectionId::Mcp);
198 }
199 if let Err(error) = config.hydrate_env_var_credentials_from_store(data_dir) {
200 tracing::warn!(error = %error, "env credential hydration unavailable");
201 for entry in &mut config.env_vars {
202 if entry.secret {
203 entry.value.clear();
204 }
205 }
206 failures.insert(SectionId::Env);
207 }
208 if let Err(error) = config.hydrate_cluster_credentials_from_store(data_dir) {
209 tracing::warn!(error = %error, "cluster credential hydration unavailable");
210 failures.insert(SectionId::ClusterFabric);
211 }
212 if let Err(error) = config.hydrate_notification_credentials_from_store(data_dir) {
213 tracing::warn!(error = %error, "notification credential hydration unavailable");
214 config.notifications.ntfy.token = None;
215 config.notifications.bark.device_key = None;
216 failures.insert(SectionId::Notifications);
217 }
218 if let Err(error) = config.hydrate_connect_credentials_from_store(data_dir) {
219 tracing::warn!(error = %error, "connect credential hydration unavailable");
220 for platform in &mut config.connect.platforms {
221 platform.token = None;
222 platform.app_secret = None;
223 }
224 failures.insert(SectionId::Connect);
225 }
226 if let Err(error) = config.hydrate_access_control_credentials_from_store(data_dir) {
227 tracing::warn!(error = %error, "access-control credential hydration unavailable");
228 config.clear_access_control_runtime_verifiers();
229 failures.insert(SectionId::AccessControl);
230 }
231 if config
232 .access_control
233 .as_ref()
234 .is_some_and(|access| access.repair_required)
235 {
236 failures.insert(SectionId::AccessControl);
237 }
238 if let Some(broker) = config.subagents_mut().broker.as_mut() {
239 if let Err(error) = broker.hydrate_credential_from_store(data_dir) {
240 tracing::warn!(error = %error, "external broker credential hydration unavailable");
241 broker.token.clear();
242 failures.insert(SectionId::Subagents);
243 }
244 }
245 config.apply_runtime_env_overrides();
246 FacadeRuntimeMaterialization { config, failures }
247}
248
249pub(super) fn load_facade_effective_config(
250 facade: &bamboo_config::ConfigFacade,
251 data_dir: &Path,
252) -> Config {
253 let materialized = materialize_facade_effective_config(facade, data_dir);
254 for section in &materialized.failures {
255 facade.registry().mark_runtime_degraded(
256 *section,
257 "configuration runtime credential repair is required",
258 );
259 }
260 materialized.config
261}
262
263fn load_committed_effective_config(data_dir: &Path) -> Result<Config, ConfigStoreError> {
264 if bamboo_config::section_layout_is_active(data_dir)? {
265 let facade = bamboo_config::ConfigFacade::open(data_dir)?;
266 let config = load_facade_effective_config(&facade, data_dir);
267 Ok(config)
268 } else {
269 Ok(Config::from_data_dir_without_publish(Some(
270 data_dir.to_path_buf(),
271 )))
272 }
273}
274
275#[derive(Debug, Clone, Serialize)]
277pub struct ConfigLiveHealth {
278 pub revision: u64,
279 pub loaded_at: DateTime<Utc>,
280 pub source_path: PathBuf,
281 pub source_kind: SectionSourceKind,
282 pub status: SectionStatus,
283 pub last_error: Option<String>,
284}
285
286pub struct ConfigWatcherRuntime {
288 stop: Arc<AtomicBool>,
289 watcher_task: Option<std::thread::JoinHandle<()>>,
290 apply_task: Option<tokio::task::JoinHandle<()>>,
291}
292
293struct ConfigPathChanges {
294 paths: Vec<PathBuf>,
295 initial_mcp: bool,
296}
297
298impl ConfigWatcherRuntime {
299 #[allow(clippy::too_many_arguments)]
300 pub fn start(
301 data_dir: PathBuf,
302 config: Arc<RwLock<Config>>,
303 config_facade: Option<Arc<bamboo_config::ConfigFacade>>,
304 config_io_lock: Arc<tokio::sync::Mutex<()>>,
305 provider_registry: Arc<bamboo_llm::ProviderRegistry>,
306 provider: Arc<RwLock<Arc<dyn LLMProvider>>>,
307 mcp_manager: Arc<McpServerManager>,
308 account_sink: Arc<bamboo_engine::events::AccountEventSink>,
309 ) -> (
310 Self,
311 Arc<std::sync::RwLock<ConfigLiveHealth>>,
312 Arc<std::sync::RwLock<ConfigLiveHealth>>,
313 ) {
314 let provider_store = AtomicJsonStore::new(data_dir.join("providers.json"), 1);
315 let provider_health = Arc::new(std::sync::RwLock::new(initial_provider_health(
316 &provider_store,
317 )));
318 let mcp_store = AtomicJsonStore::new(data_dir.join("mcp.json"), 1);
319 let mcp_health = Arc::new(std::sync::RwLock::new(initial_mcp_health(&mcp_store)));
320 let stop = Arc::new(AtomicBool::new(false));
321 let watcher = match ConfigDirectoryWatcher::watch(&data_dir, Duration::from_millis(120)) {
322 Ok(watcher) => watcher,
323 Err(error) => {
324 tracing::warn!(error = %error, "live configuration watcher could not start");
325 {
326 let mut value = provider_health
327 .write()
328 .unwrap_or_else(|poisoned| poisoned.into_inner());
329 value.status = SectionStatus::Degraded;
330 value.last_error = Some("configuration watcher unavailable".to_string());
331 }
332 {
333 let mut value = mcp_health
334 .write()
335 .unwrap_or_else(|poisoned| poisoned.into_inner());
336 value.status = SectionStatus::Degraded;
337 value.last_error = Some("configuration watcher unavailable".to_string());
338 }
339 return (
340 Self {
341 stop,
342 watcher_task: None,
343 apply_task: None,
344 },
345 provider_health,
346 mcp_health,
347 );
348 }
349 };
350
351 let self_write_marker = watcher.self_write_marker();
355 let (changes_tx, mut changes_rx) =
356 tokio::sync::mpsc::unbounded_channel::<ConfigPathChanges>();
357 let initial_changes = changes_tx.clone();
358 let worker_stop = stop.clone();
359 let watcher_task = std::thread::spawn(move || {
360 while !worker_stop.load(Ordering::Relaxed) {
361 match watcher.recv_timeout(Duration::from_millis(250)) {
362 Ok(paths) => {
363 if changes_tx
364 .send(ConfigPathChanges {
365 paths,
366 initial_mcp: false,
367 })
368 .is_err()
369 {
370 break;
371 }
372 }
373 Err(std::sync::mpsc::RecvTimeoutError::Timeout) => {}
374 Err(std::sync::mpsc::RecvTimeoutError::Disconnected) => break,
375 }
376 }
377 });
378
379 let initial_mcp_path = data_dir.join("mcp.json");
384 if initial_mcp_path.exists() {
385 let _ = initial_changes.send(ConfigPathChanges {
386 paths: vec![initial_mcp_path],
387 initial_mcp: true,
388 });
389 }
390
391 let apply_provider_health = provider_health.clone();
392 let apply_mcp_health = mcp_health.clone();
393 let apply_task = tokio::spawn(async move {
394 while let Some(changes) = changes_rx.recv().await {
395 let watched_sections = config_facade
396 .as_ref()
397 .map(|_| {
398 changes
399 .paths
400 .iter()
401 .filter_map(|path| {
402 path.file_name()
403 .and_then(|name| name.to_str())
404 .and_then(SectionId::from_file_name)
405 })
406 .collect::<BTreeSet<_>>()
407 })
408 .unwrap_or_default();
409 let provider_watched = changes.paths.iter().any(|path| {
410 path.file_name().and_then(|name| name.to_str()) == Some("providers.json")
411 });
412 let mcp_watched = changes.paths.iter().any(|path| {
413 path.file_name().and_then(|name| name.to_str()) == Some("mcp.json")
414 });
415 let ordinary_watched = watched_sections
416 .iter()
417 .copied()
418 .filter(|id| !matches!(id, SectionId::Providers | SectionId::Mcp))
419 .collect::<Vec<_>>();
420 if !provider_watched && !mcp_watched && ordinary_watched.is_empty() {
421 continue;
422 }
423
424 let _io = config_io_lock.lock().await;
428 if let Some(facade) = config_facade.as_ref() {
429 reload_and_apply_ordinary_sections(
430 &data_dir,
431 &config,
432 facade,
433 &account_sink,
434 ordinary_watched,
435 )
436 .await;
437 }
438 if provider_watched {
439 if let Some(facade) = config_facade.as_ref() {
440 wait_for_section_file_settle(&data_dir, SectionId::Providers).await;
441 if let Some(event) =
442 facade.registry().reload_if_changed(SectionId::Providers)
443 {
444 if matches!(event, ConfigSectionEvent::Invalid { .. }) {
445 publish_section_failure(
446 &apply_provider_health,
447 &account_sink,
448 "providers",
449 facade.registry().providers.snapshot().status,
450 "provider section is invalid; retaining last-known-good runtime"
451 .to_string(),
452 );
453 } else {
454 self_write_marker.mark_self_write(provider_store.path());
455 let materialized =
456 materialize_facade_effective_config(facade, &data_dir);
457 if materialized.failures.contains(&SectionId::Providers) {
458 facade.registry().mark_runtime_degraded(
459 SectionId::Providers,
460 "provider credential hydration failed; retaining last-known-good runtime",
461 );
462 publish_section_failure(
463 &apply_provider_health,
464 &account_sink,
465 "providers",
466 SectionStatus::Degraded,
467 "provider credential hydration failed; retaining last-known-good runtime"
468 .to_string(),
469 );
470 } else {
471 let mut candidate = config.read().await.clone();
472 apply_runtime_section(
473 SectionId::Providers,
474 &materialized.config,
475 &mut candidate,
476 );
477 match prepare_provider_candidate(candidate, &data_dir).await {
478 Ok((candidate, registry, next_provider)) => {
479 let mut live_config = config.write().await;
480 let mut live_provider = provider.write().await;
481 let recovered =
482 section_is_unhealthy(&apply_provider_health);
483 candidate.publish_env_vars();
484 *live_config = candidate;
485 provider_registry.replace_with(registry);
486 *live_provider = next_provider;
487 drop(live_provider);
488 drop(live_config);
489 let revision =
490 facade.registry().providers.snapshot().revision;
491 publish_section_success(
492 &apply_provider_health,
493 &account_sink,
494 "providers",
495 data_dir.join("providers.json"),
496 recovered,
497 Some(revision),
498 );
499 }
500 Err(_) => {
501 facade.registry().mark_runtime_degraded(
502 SectionId::Providers,
503 "provider runtime initialization failed; retaining last-known-good runtime",
504 );
505 publish_section_failure(
506 &apply_provider_health,
507 &account_sink,
508 "providers",
509 SectionStatus::Degraded,
510 "provider runtime initialization failed; retaining last-known-good runtime"
511 .to_string(),
512 );
513 }
514 }
515 }
516 }
517 }
518 } else {
519 let current_config = config.read().await.clone();
520 let current_revision = apply_provider_health
521 .read()
522 .unwrap_or_else(|poisoned| poisoned.into_inner())
523 .revision;
524 let result = load_and_prepare_provider_candidate(
525 &provider_store,
526 current_revision,
527 current_config,
528 )
529 .await;
530 match result {
531 Ok(candidate) if candidate.unchanged => {}
532 Ok(candidate) => {
533 if candidate.normalized_external_revision {
534 self_write_marker.mark_self_write(provider_store.path());
535 }
536 let mut live_config = config.write().await;
537 let mut live_provider = provider.write().await;
538 let recovered = section_is_unhealthy(&apply_provider_health);
539 candidate.config.publish_env_vars();
540 *live_config = candidate.config;
541 provider_registry.replace_with(candidate.registry);
542 *live_provider = candidate.provider;
543 drop(live_provider);
544 drop(live_config);
545
546 publish_section_success(
547 &apply_provider_health,
548 &account_sink,
549 "providers",
550 data_dir.join("providers.json"),
551 recovered,
552 Some(candidate.revision),
553 );
554 }
555 Err(error) => publish_section_failure(
556 &apply_provider_health,
557 &account_sink,
558 "providers",
559 candidate_error_status(&error.kind),
560 error.message,
561 ),
562 }
563 }
564 }
565
566 if mcp_watched {
567 if let Some(facade) = config_facade.as_ref() {
568 wait_for_section_file_settle(&data_dir, SectionId::Mcp).await;
569 let event =
570 facade
571 .registry()
572 .reload_if_changed(SectionId::Mcp)
573 .or_else(|| {
574 changes.initial_mcp.then(|| ConfigSectionEvent::Changed {
575 section: "mcp".to_string(),
576 revision: facade.registry().mcp.snapshot().revision,
577 })
578 });
579 let Some(event) = event else {
580 continue;
581 };
582 if matches!(event, ConfigSectionEvent::Invalid { .. }) {
583 publish_section_failure(
584 &apply_mcp_health,
585 &account_sink,
586 "mcp",
587 facade.registry().mcp.snapshot().status,
588 "MCP section is invalid; retaining last-known-good runtime"
589 .to_string(),
590 );
591 } else {
592 self_write_marker.mark_self_write(mcp_store.path());
593 let materialized =
594 materialize_facade_effective_config(facade, &data_dir);
595 if materialized.failures.contains(&SectionId::Mcp) {
596 facade.registry().mark_runtime_degraded(
597 SectionId::Mcp,
598 "MCP credential hydration failed; retaining last-known-good runtime",
599 );
600 publish_section_failure(
601 &apply_mcp_health,
602 &account_sink,
603 "mcp",
604 SectionStatus::Degraded,
605 "MCP credential hydration failed; retaining last-known-good runtime"
606 .to_string(),
607 );
608 continue;
609 }
610 let next_mcp = materialized.config.mcp.clone();
611 let publish_config = config.clone();
612 match mcp_manager
613 .reconcile_from_config_transactional_after(
614 &materialized.config.mcp,
615 || async move {
616 publish_config.write().await.mcp = next_mcp;
617 Ok(())
618 },
619 )
620 .await
621 {
622 Ok(()) => {
623 let recovered = section_is_unhealthy(&apply_mcp_health);
624 let snapshot = facade.registry().mcp.snapshot();
625 let revision = snapshot.revision;
626 if changes.initial_mcp
627 && snapshot.status == SectionStatus::Healthy
628 {
629 set_live_health_revision(
635 &apply_mcp_health,
636 revision,
637 Some((
638 data_dir.join("mcp.json"),
639 SectionSourceKind::File,
640 )),
641 );
642 } else {
643 publish_section_success(
644 &apply_mcp_health,
645 &account_sink,
646 "mcp",
647 data_dir.join("mcp.json"),
648 recovered,
649 Some(revision),
650 );
651 }
652 }
653 Err(_) => {
654 facade.registry().mark_runtime_degraded(
655 SectionId::Mcp,
656 "MCP runtime initialization failed; retaining last-known-good runtime",
657 );
658 publish_section_failure(
659 &apply_mcp_health,
660 &account_sink,
661 "mcp",
662 SectionStatus::Degraded,
663 "MCP runtime initialization failed; retaining last-known-good runtime"
664 .to_string(),
665 )
666 }
667 }
668 }
669 } else {
670 let current_config = config.read().await.clone();
671 let current_revision = apply_mcp_health
672 .read()
673 .unwrap_or_else(|poisoned| poisoned.into_inner())
674 .revision;
675 let result = load_and_validate_mcp_candidate(
676 &mcp_store,
677 current_revision,
678 current_config,
679 changes.initial_mcp,
680 )
681 .await;
682 match result {
683 Ok(candidate) if candidate.unchanged => {}
684 Ok(candidate) => {
685 if candidate.normalized_external_revision
686 || candidate.source_kind == SectionSourceKind::Backup
687 {
688 self_write_marker.mark_self_write(mcp_store.path());
693 }
694 let next_mcp = candidate.config.mcp.clone();
695 let publish_config = config.clone();
696 match mcp_manager
697 .reconcile_from_config_transactional_after(
698 &candidate.config.mcp,
699 || async move {
700 publish_config.write().await.mcp = next_mcp;
701 Ok(())
702 },
703 )
704 .await
705 {
706 Ok(()) => {
707 let recovered = section_is_unhealthy(&apply_mcp_health);
708 if candidate.source_kind == SectionSourceKind::Backup {
709 publish_mcp_backup_lkg(
710 &apply_mcp_health,
711 &account_sink,
712 candidate.source_path,
713 candidate.revision,
714 );
715 } else {
716 publish_section_success(
717 &apply_mcp_health,
718 &account_sink,
719 "mcp",
720 data_dir.join("mcp.json"),
721 recovered,
722 Some(candidate.revision),
723 );
724 }
725 }
726 Err(_) => publish_section_failure(
727 &apply_mcp_health,
728 &account_sink,
729 "mcp",
730 SectionStatus::Degraded,
731 "MCP runtime initialization failed; retaining last-known-good runtime"
732 .to_string(),
733 ),
734 }
735 }
736 Err(error) => publish_section_failure(
737 &apply_mcp_health,
738 &account_sink,
739 "mcp",
740 candidate_error_status(&error.kind),
741 error.message,
742 ),
743 }
744 }
745 }
746 }
747 });
748
749 (
750 Self {
751 stop,
752 watcher_task: Some(watcher_task),
753 apply_task: Some(apply_task),
754 },
755 provider_health,
756 mcp_health,
757 )
758 }
759}
760
761async fn wait_for_section_file_settle(data_dir: &Path, id: SectionId) {
762 let path = data_dir.join(id.descriptor().file_name);
763 for _ in 0..3 {
764 if path.exists() {
765 return;
766 }
767 tokio::time::sleep(Duration::from_millis(50)).await;
768 }
769}
770
771async fn reload_and_apply_ordinary_sections(
777 data_dir: &Path,
778 config: &Arc<RwLock<Config>>,
779 facade: &bamboo_config::ConfigFacade,
780 account_sink: &bamboo_engine::events::AccountEventSink,
781 sections: impl IntoIterator<Item = SectionId>,
782) {
783 let mut publishable = Vec::new();
784 for id in sections {
785 wait_for_section_file_settle(data_dir, id).await;
786 let Some(event) = facade.registry().reload_if_changed(id) else {
787 continue;
788 };
789 if matches!(event, ConfigSectionEvent::Invalid { .. }) {
790 publish_registry_event(account_sink, &event);
791 } else {
792 publishable.push((id, event));
793 }
794 }
795 if publishable.is_empty() {
796 return;
797 }
798
799 let materialized = materialize_facade_effective_config(facade, data_dir);
800 let mut current = config.read().await.clone();
801 let mut applied = Vec::new();
802 for (id, event) in publishable {
803 if materialized.failures.contains(&id) {
804 if let Some(invalid) = facade.registry().mark_runtime_degraded(
805 id,
806 "configuration runtime hydration failed; retaining last-known-good runtime",
807 ) {
808 publish_registry_event(account_sink, &invalid);
809 }
810 continue;
811 }
812 apply_runtime_section(id, &materialized.config, &mut current);
813 applied.push((id, event));
814 }
815 if applied.is_empty() {
816 return;
817 }
818
819 let publishes_env = applied.iter().any(|(id, _)| *id == SectionId::Env);
820 let enforcement_newly_off = !config.read().await.plugin_trust.enforcement_is_off()
821 && current.plugin_trust.enforcement_is_off();
822 *config.write().await = current.clone();
823 if publishes_env {
824 current.publish_env_vars();
825 }
826 if enforcement_newly_off {
827 warn_plugin_trust_enforcement_off();
828 }
829 for (_, event) in applied {
830 publish_registry_event(account_sink, &event);
831 }
832}
833
834pub(super) fn publish_registry_event(
835 account_sink: &bamboo_engine::events::AccountEventSink,
836 event: &ConfigSectionEvent,
837) {
838 let event = match event {
839 ConfigSectionEvent::Changed { section, revision } => AgentEvent::ConfigChanged {
840 section: section.clone(),
841 revision: *revision,
842 },
843 ConfigSectionEvent::Invalid { section, revision } => AgentEvent::ConfigInvalid {
844 section: section.clone(),
845 revision: *revision,
846 },
847 ConfigSectionEvent::Recovered { section, revision } => AgentEvent::ConfigRecovered {
848 section: section.clone(),
849 revision: *revision,
850 },
851 };
852 account_sink.record(None, &event);
853}
854
855fn publish_exact_facade_events(
856 account_sink: &bamboo_engine::events::AccountEventSink,
857 events: &[ConfigSectionEvent],
858) -> Result<(), AppError> {
859 for event in events {
860 publish_registry_event(account_sink, event);
861 if matches!(event, ConfigSectionEvent::Invalid { .. }) {
862 return Err(AppError::InternalError(anyhow::anyhow!(
863 "committed configuration section became invalid before publication"
864 )));
865 }
866 }
867 Ok(())
868}
869
870struct InstalledCredentialSectionCommit {
871 events: Vec<ConfigSectionEvent>,
872 metadata: bamboo_config::CredentialSectionRuntimeMetadata,
873 section: Option<bamboo_config::SectionEnvelope<Value>>,
874}
875
876pub(crate) struct ExactCredentialSectionSnapshot {
877 pub config: Config,
878 pub section: bamboo_config::SectionEnvelope<Value>,
879 pub metadata: bamboo_config::CredentialSectionRuntimeMetadata,
880}
881
882fn map_exact_credential_store_error(error: ConfigStoreError) -> AppError {
883 match error {
884 ConfigStoreError::Conflict { expected, actual } => {
885 AppError::ConfigConflict { expected, actual }
886 }
887 ConfigStoreError::Validation(message) => AppError::BadRequest(message),
888 ConfigStoreError::CommitIndeterminate(message) => AppError::InternalError(anyhow::anyhow!(
889 "configuration commit outcome is indeterminate: {message}"
890 )),
891 ConfigStoreError::Io(error) => AppError::StorageError(error),
892 ConfigStoreError::Json(_) => {
893 AppError::BadRequest("configuration document is invalid".to_string())
894 }
895 ConfigStoreError::Watch(error) => {
896 AppError::InternalError(anyhow::anyhow!("configuration watch failed: {error}"))
897 }
898 }
899}
900
901async fn install_exact_credential_section_mutation_base(
902 data_dir: PathBuf,
903 section: SectionId,
904 expected_revision: u64,
905 target: &mut Config,
906) -> Result<bamboo_config::CredentialSectionRuntimeMetadata, AppError> {
907 let exact = tokio::task::spawn_blocking(move || {
908 bamboo_config::read_exact_credential_section_snapshot(
909 data_dir,
910 section,
911 Some(expected_revision),
912 )
913 })
914 .await
915 .map_err(|error| {
916 AppError::InternalError(anyhow::anyhow!(
917 "{} exact mutation snapshot task failed: {error}",
918 section.descriptor().name
919 ))
920 })?
921 .map_err(map_exact_credential_store_error)?;
922 Ok(exact.install_into(target))
923}
924
925fn read_credential_runtime_metadata(
926 data_dir: &std::path::Path,
927) -> Result<bamboo_config::CredentialSectionRuntimeMetadata, ConfigStoreError> {
928 let (credential_statuses, credential_health) =
929 bamboo_config::CredentialStore::open(data_dir).statuses_with_health()?;
930 Ok(bamboo_config::CredentialSectionRuntimeMetadata {
931 credential_statuses,
932 credential_health,
933 })
934}
935
936fn install_credential_section_commit(
937 commit: bamboo_config::CredentialSectionTransactionCommit,
938 target: &mut Config,
939) -> Result<InstalledCredentialSectionCommit, ConfigStoreError> {
940 let bamboo_config::CredentialSectionTransactionCommit {
941 revision: _,
942 section_adoption,
943 credential_adoption,
944 section,
945 runtime,
946 } = commit;
947 let section = section?;
948 let metadata = runtime?.install_into(target);
949 let mut events = Vec::new();
950 if let Some(adoption) = credential_adoption {
951 if let Some(event) = adoption? {
952 events.push(event);
953 }
954 }
955 if let Some(adoption) = section_adoption {
956 events.push(adoption?);
957 }
958 Ok(InstalledCredentialSectionCommit {
959 events,
960 metadata,
961 section: Some(section),
962 })
963}
964
965fn install_facade_config_commit(
966 commit: bamboo_config::FacadeConfigCommit,
967 target: &mut Config,
968) -> Result<Vec<ConfigSectionEvent>, ConfigStoreError> {
969 let bamboo_config::FacadeConfigCommit {
970 section_adoption,
971 runtime,
972 } = commit;
973 if let Some(runtime) = runtime? {
974 runtime.install_into(target);
975 }
976 section_adoption
977 .map(|adoption| adoption.map(|event| vec![event]))
978 .unwrap_or_else(|| Ok(Vec::new()))
979}
980
981fn apply_runtime_section(id: SectionId, source: &Config, target: &mut Config) {
982 match id {
983 SectionId::Core => {
984 target.http_proxy = source.http_proxy.clone();
985 target.https_proxy = source.https_proxy.clone();
986 target.proxy_auth = source.proxy_auth.clone();
987 target.proxy_auth_encrypted = None;
988 target.proxy_auth_credential_ref = source.proxy_auth_credential_ref.clone();
989 target.headless_auth = source.headless_auth;
990 target.server = source.server.clone();
991 target.default_work_area = source.default_work_area.clone();
992 target.run_budget = source.run_budget;
993 target.stream_timeout = source.stream_timeout;
994 target.extra = source.extra.clone();
995 }
996 SectionId::Providers => {
997 target.provider = source.provider.clone();
998 target.defaults = source.defaults.clone();
999 target.provider_instances = source.provider_instances.clone();
1000 target.default_provider_instance = source.default_provider_instance.clone();
1001 target.features = source.features.clone();
1002 *target.providers_mut() = source.providers().clone();
1003 }
1004 SectionId::Mcp => target.mcp = source.mcp.clone(),
1005 SectionId::ToolsSkills => {
1006 target.tools = source.tools.clone();
1007 target.skills = source.skills.clone();
1008 target.plugin_trust = source.plugin_trust.clone();
1009 }
1010 SectionId::Memory => *target.memory_mut() = source.memory().clone(),
1011 SectionId::Subagents => *target.subagents_mut() = source.subagents().clone(),
1012 SectionId::Notifications => target.notifications = source.notifications.clone(),
1013 SectionId::Connect => target.connect = source.connect.clone(),
1014 SectionId::ClusterFabric => target.cluster_fabric = source.cluster_fabric.clone(),
1015 SectionId::Env => target.env_vars = source.env_vars.clone(),
1016 SectionId::AccessControl => target.access_control = source.access_control.clone(),
1017 SectionId::Hooks => target.hooks = source.hooks.clone(),
1018 SectionId::ModelPolicy => {
1019 target.keyword_masking = source.keyword_masking.clone();
1020 target.anthropic_model_mapping = source.anthropic_model_mapping.clone();
1021 target.gemini_model_mapping = source.gemini_model_mapping.clone();
1022 }
1023 SectionId::ModelLimits | SectionId::Credentials => {}
1024 }
1025}
1026
1027fn restore_authoritative_cluster_fabric(
1032 facade: Option<&std::sync::Arc<bamboo_config::ConfigFacade>>,
1033 candidate: &mut Config,
1034) {
1035 if let Some(facade) = facade {
1036 candidate.cluster_fabric = facade.registry().cluster_fabric.snapshot().data.0.clone();
1037 }
1038}
1039
1040fn section_is_unhealthy(health: &std::sync::RwLock<ConfigLiveHealth>) -> bool {
1041 health
1042 .read()
1043 .unwrap_or_else(|poisoned| poisoned.into_inner())
1044 .status
1045 != SectionStatus::Healthy
1046}
1047
1048fn publish_section_success(
1049 health: &std::sync::RwLock<ConfigLiveHealth>,
1050 account_sink: &bamboo_engine::events::AccountEventSink,
1051 section: &str,
1052 source_path: PathBuf,
1053 recovered: bool,
1054 revision: Option<u64>,
1055) {
1056 let revision = match revision {
1057 Some(revision) => set_live_health_revision(
1058 health,
1059 revision,
1060 Some((source_path, SectionSourceKind::File)),
1061 ),
1062 None => update_live_health(
1063 health,
1064 SectionStatus::Healthy,
1065 None,
1066 true,
1067 Some((source_path, SectionSourceKind::File)),
1068 ),
1069 };
1070 let event = if recovered {
1071 AgentEvent::ConfigRecovered {
1072 section: section.to_string(),
1073 revision,
1074 }
1075 } else {
1076 AgentEvent::ConfigChanged {
1077 section: section.to_string(),
1078 revision,
1079 }
1080 };
1081 account_sink.record(None, &event);
1082}
1083
1084fn publish_section_failure(
1085 health: &std::sync::RwLock<ConfigLiveHealth>,
1086 account_sink: &bamboo_engine::events::AccountEventSink,
1087 section: &str,
1088 status: SectionStatus,
1089 message: String,
1090) {
1091 let revision = update_live_health(health, status, Some(message), false, None);
1092 account_sink.record(
1093 None,
1094 &AgentEvent::ConfigInvalid {
1095 section: section.to_string(),
1096 revision,
1097 },
1098 );
1099}
1100
1101fn publish_mcp_backup_lkg(
1102 health: &std::sync::RwLock<ConfigLiveHealth>,
1103 account_sink: &bamboo_engine::events::AccountEventSink,
1104 source_path: PathBuf,
1105 revision: u64,
1106) {
1107 {
1108 let mut health = health
1109 .write()
1110 .unwrap_or_else(|poisoned| poisoned.into_inner());
1111 health.revision = revision;
1112 health.loaded_at = Utc::now();
1113 health.source_path = source_path;
1114 health.source_kind = SectionSourceKind::Backup;
1115 health.status = SectionStatus::Degraded;
1116 health.last_error =
1117 Some("primary MCP section invalid; running last-known-good backup runtime".to_string());
1118 }
1119 account_sink.record(
1120 None,
1121 &AgentEvent::ConfigInvalid {
1122 section: "mcp".to_string(),
1123 revision,
1124 },
1125 );
1126}
1127
1128fn initial_provider_health(store: &AtomicJsonStore<ProviderConfigs>) -> ConfigLiveHealth {
1129 if ensure_provider_mcp_migration_ready(store.path().parent().unwrap_or_else(|| Path::new(".")))
1130 .is_err()
1131 {
1132 return ConfigLiveHealth {
1133 revision: 0,
1134 loaded_at: Utc::now(),
1135 source_path: store.path().to_path_buf(),
1136 source_kind: SectionSourceKind::File,
1137 status: SectionStatus::Degraded,
1138 last_error: Some("provider/MCP credential migration is pending".to_string()),
1139 };
1140 }
1141 match store.load_validated_allowing_unversioned(|_| Ok(())) {
1142 Ok(Some(stored)) => ConfigLiveHealth {
1143 revision: stored.revision,
1144 loaded_at: Utc::now(),
1145 source_path: stored.source_path,
1146 source_kind: if stored.recovered_from_backup {
1147 SectionSourceKind::Backup
1148 } else {
1149 SectionSourceKind::File
1150 },
1151 status: if stored.recovered_from_backup {
1152 SectionStatus::Degraded
1153 } else {
1154 SectionStatus::Healthy
1155 },
1156 last_error: stored.recovered_from_backup.then(|| {
1157 "primary provider section invalid; using last-known-good backup".to_string()
1158 }),
1159 },
1160 Ok(None) => ConfigLiveHealth {
1161 revision: 0,
1162 loaded_at: Utc::now(),
1163 source_path: store.path().to_path_buf(),
1164 source_kind: SectionSourceKind::Default,
1165 status: SectionStatus::Missing,
1166 last_error: None,
1167 },
1168 Err(_) => ConfigLiveHealth {
1169 revision: 0,
1170 loaded_at: Utc::now(),
1171 source_path: store.path().to_path_buf(),
1172 source_kind: SectionSourceKind::File,
1173 status: SectionStatus::Invalid,
1174 last_error: Some("provider section could not be parsed or read".to_string()),
1175 },
1176 }
1177}
1178
1179fn initial_mcp_health(store: &AtomicJsonStore<McpConfig>) -> ConfigLiveHealth {
1180 if ensure_provider_mcp_migration_ready(store.path().parent().unwrap_or_else(|| Path::new(".")))
1181 .is_err()
1182 {
1183 return ConfigLiveHealth {
1184 revision: 0,
1185 loaded_at: Utc::now(),
1186 source_path: store.path().to_path_buf(),
1187 source_kind: SectionSourceKind::File,
1188 status: SectionStatus::Degraded,
1189 last_error: Some("provider/MCP credential migration is pending".to_string()),
1190 };
1191 }
1192 match store.load_validated(validate_mcp_config) {
1193 Ok(Some(stored)) => ConfigLiveHealth {
1194 revision: 0,
1197 loaded_at: Utc::now(),
1198 source_path: store.path().to_path_buf(),
1199 source_kind: if stored.recovered_from_backup {
1200 SectionSourceKind::Backup
1201 } else {
1202 SectionSourceKind::File
1203 },
1204 status: SectionStatus::Degraded,
1205 last_error: Some(if stored.recovered_from_backup {
1206 "primary MCP section invalid; runtime initialization pending from backup"
1207 .to_string()
1208 } else {
1209 "MCP runtime initialization pending".to_string()
1210 }),
1211 },
1212 Ok(None) => ConfigLiveHealth {
1213 revision: 0,
1214 loaded_at: Utc::now(),
1215 source_path: store.path().to_path_buf(),
1216 source_kind: SectionSourceKind::Default,
1217 status: SectionStatus::Missing,
1218 last_error: None,
1219 },
1220 Err(_) => ConfigLiveHealth {
1221 revision: 0,
1222 loaded_at: Utc::now(),
1223 source_path: store.path().to_path_buf(),
1224 source_kind: SectionSourceKind::File,
1225 status: SectionStatus::Invalid,
1226 last_error: Some("MCP section could not be parsed or validated".to_string()),
1227 },
1228 }
1229}
1230
1231impl Drop for ConfigWatcherRuntime {
1232 fn drop(&mut self) {
1233 self.stop.store(true, Ordering::Relaxed);
1234 if let Some(task) = self.apply_task.take() {
1235 task.abort();
1236 }
1237 if let Some(task) = self.watcher_task.take() {
1238 let _ = task.join();
1239 }
1240 }
1241}
1242
1243async fn load_and_prepare_provider_candidate(
1244 store: &AtomicJsonStore<ProviderConfigs>,
1245 current_revision: u64,
1246 candidate_config: Config,
1247) -> Result<ProviderCandidate, ProviderCandidateError> {
1248 ensure_provider_mcp_migration_ready(store.path().parent().unwrap_or_else(|| Path::new(".")))
1249 .map_err(|_| {
1250 ProviderCandidateError::invalid(
1251 "provider/MCP credential migration is pending; retaining last-known-good runtime",
1252 )
1253 })?;
1254 for _ in 0..3 {
1257 if store.path().exists() {
1258 break;
1259 }
1260 tokio::time::sleep(Duration::from_millis(50)).await;
1261 }
1262 if !store.path().exists() {
1263 return Err(ProviderCandidateError::missing());
1264 }
1265 let stored = store
1266 .load_validated_for_reload_allowing_unversioned(
1267 current_revision,
1268 candidate_config.providers(),
1269 validate_provider_config,
1270 )
1271 .map_err(|_| {
1272 if store.path().exists() {
1273 ProviderCandidateError::invalid("provider section is invalid")
1274 } else {
1275 ProviderCandidateError::missing()
1276 }
1277 })?
1278 .ok_or_else(ProviderCandidateError::missing)?;
1279 if stored.recovered_from_backup {
1280 return Err(ProviderCandidateError::invalid(
1281 "primary provider section is invalid; retaining last-known-good runtime",
1282 ));
1283 }
1284 let unchanged = stored.revision == current_revision
1285 && serde_json::to_value(&stored.data).ok()
1286 == serde_json::to_value(candidate_config.providers()).ok();
1287 let mut candidate_config = candidate_config;
1288 *candidate_config.providers_mut() = stored.data;
1289 let (candidate_config, registry, provider) = prepare_provider_candidate(
1290 candidate_config,
1291 store
1292 .path()
1293 .parent()
1294 .unwrap_or_else(|| std::path::Path::new(".")),
1295 )
1296 .await?;
1297 Ok(ProviderCandidate {
1298 config: candidate_config,
1299 registry,
1300 provider,
1301 revision: stored.revision,
1302 normalized_external_revision: stored.normalized_external_revision,
1303 unchanged,
1304 })
1305}
1306
1307async fn prepare_provider_candidate(
1308 mut candidate_config: Config,
1309 data_dir: &std::path::Path,
1310) -> Result<(Config, bamboo_llm::ProviderRegistry, Arc<dyn LLMProvider>), ProviderCandidateError> {
1311 candidate_config.hydrate_provider_api_keys_from_encrypted();
1312 candidate_config
1313 .hydrate_provider_credentials_from_store(data_dir)
1314 .map_err(|_| ProviderCandidateError::invalid("provider credential is unavailable"))?;
1315 let candidate_registry =
1316 bamboo_llm::ProviderRegistry::from_config(&candidate_config, data_dir.to_path_buf())
1317 .await
1318 .map_err(|_| ProviderCandidateError::runtime())?;
1319 let candidate_provider = candidate_registry
1320 .get_default()
1321 .ok_or_else(ProviderCandidateError::runtime)?;
1322 Ok((candidate_config, candidate_registry, candidate_provider))
1323}
1324
1325struct ProviderCandidate {
1326 config: Config,
1327 registry: bamboo_llm::ProviderRegistry,
1328 provider: Arc<dyn LLMProvider>,
1329 revision: u64,
1330 normalized_external_revision: bool,
1331 unchanged: bool,
1332}
1333
1334async fn load_and_validate_mcp_candidate(
1335 store: &AtomicJsonStore<McpConfig>,
1336 current_revision: u64,
1337 mut candidate_config: Config,
1338 allow_startup_backup: bool,
1339) -> Result<McpCandidate, ProviderCandidateError> {
1340 ensure_provider_mcp_migration_ready(store.path().parent().unwrap_or_else(|| Path::new(".")))
1341 .map_err(|_| {
1342 ProviderCandidateError::invalid(
1343 "provider/MCP credential migration is pending; retaining last-known-good runtime",
1344 )
1345 })?;
1346 for _ in 0..3 {
1347 if store.path().exists() {
1348 break;
1349 }
1350 tokio::time::sleep(Duration::from_millis(50)).await;
1351 }
1352 if !store.path().exists() {
1353 return Err(ProviderCandidateError::missing_section(
1354 "MCP section is missing",
1355 ));
1356 }
1357 let current_document = mcp_durable_comparison_document(&candidate_config.mcp);
1358 let stored = store
1359 .load_validated_for_reload(current_revision, ¤t_document, validate_mcp_config)
1360 .map_err(|_| ProviderCandidateError::invalid("MCP section is invalid"))?
1361 .ok_or_else(|| ProviderCandidateError::missing_section("MCP section is missing"))?;
1362 if stored.recovered_from_backup && !allow_startup_backup {
1363 return Err(ProviderCandidateError::invalid(
1364 "primary MCP section is invalid; retaining last-known-good runtime",
1365 ));
1366 }
1367 let unchanged = !allow_startup_backup
1368 && stored.revision == current_revision
1369 && serde_json::to_value(&stored.data).ok() == serde_json::to_value(¤t_document).ok();
1370 candidate_config.mcp = stored.data;
1371 candidate_config.hydrate_mcp_secrets_from_encrypted();
1372 candidate_config
1373 .hydrate_mcp_credentials_from_store(
1374 store
1375 .path()
1376 .parent()
1377 .unwrap_or_else(|| std::path::Path::new(".")),
1378 )
1379 .map_err(|_| ProviderCandidateError::invalid("MCP credential is unavailable"))?;
1380 Ok(McpCandidate {
1381 config: candidate_config,
1382 revision: stored.revision,
1383 source_kind: if stored.recovered_from_backup {
1384 SectionSourceKind::Backup
1385 } else {
1386 SectionSourceKind::File
1387 },
1388 source_path: stored.source_path,
1389 normalized_external_revision: stored.normalized_external_revision,
1390 unchanged,
1391 })
1392}
1393
1394struct McpCandidate {
1395 config: Config,
1396 revision: u64,
1397 source_kind: SectionSourceKind,
1398 source_path: PathBuf,
1399 normalized_external_revision: bool,
1400 unchanged: bool,
1401}
1402
1403fn mcp_durable_comparison_document(config: &McpConfig) -> McpConfig {
1407 let mut document = config.clone();
1408 for server in &mut document.servers {
1409 match &mut server.transport {
1410 TransportConfig::Stdio(config) => {
1411 config.env.retain(|name, _| {
1412 !config.env_encrypted.contains_key(name)
1413 && !config.env_credential_refs.contains_key(name)
1414 });
1415 }
1416 TransportConfig::Sse(config) => clear_paired_header_plaintext(&mut config.headers),
1417 TransportConfig::StreamableHttp(config) => {
1418 clear_paired_header_plaintext(&mut config.headers)
1419 }
1420 }
1421 }
1422 document
1423}
1424
1425fn clear_paired_header_plaintext(headers: &mut [bamboo_mcp::HeaderConfig]) {
1426 for header in headers {
1427 if header.value_encrypted.is_some() || header.credential_ref.is_some() {
1428 header.value.clear();
1429 }
1430 }
1431}
1432
1433fn validate_mcp_config(config: &McpConfig) -> Result<(), String> {
1434 let mut ids = std::collections::HashSet::new();
1435 for server in &config.servers {
1436 if server.id.trim().is_empty() {
1437 return Err("MCP server id cannot be empty".to_string());
1438 }
1439 if !ids.insert(server.id.as_str()) {
1440 return Err(format!("duplicate MCP server id '{}'", server.id));
1441 }
1442 if server.request_timeout_ms == 0 || server.healthcheck_interval_ms == 0 {
1443 return Err(format!(
1444 "MCP server '{}' timeouts must be non-zero",
1445 server.id
1446 ));
1447 }
1448 match &server.transport {
1449 TransportConfig::Stdio(stdio) if stdio.command.trim().is_empty() => {
1450 return Err(format!(
1451 "MCP stdio server '{}' command cannot be empty",
1452 server.id
1453 ));
1454 }
1455 TransportConfig::Sse(sse) if sse.url.trim().is_empty() => {
1456 return Err(format!(
1457 "MCP SSE server '{}' URL cannot be empty",
1458 server.id
1459 ));
1460 }
1461 TransportConfig::StreamableHttp(http) if http.url.trim().is_empty() => {
1462 return Err(format!(
1463 "MCP HTTP server '{}' URL cannot be empty",
1464 server.id
1465 ));
1466 }
1467 _ => {}
1468 }
1469 match &server.transport {
1470 TransportConfig::Stdio(stdio) => {
1471 if !stdio.env_encrypted.is_empty()
1472 || stdio.env.iter().any(|(name, value)| {
1473 !value.is_empty() && !stdio.env_credential_refs.contains_key(name)
1474 })
1475 {
1476 return Err(format!(
1477 "MCP server '{}' contains a secret outside the credential store",
1478 server.id
1479 ));
1480 }
1481 for raw in stdio.env_credential_refs.values() {
1482 bamboo_config::CredentialRef::parse(raw.clone())
1483 .map_err(|_| "MCP credential reference is invalid".to_string())?;
1484 }
1485 }
1486 TransportConfig::Sse(config) => validate_header_refs(&server.id, &config.headers)?,
1487 TransportConfig::StreamableHttp(config) => {
1488 validate_header_refs(&server.id, &config.headers)?
1489 }
1490 }
1491 }
1492 Ok(())
1493}
1494
1495fn validate_provider_config(providers: &ProviderConfigs) -> Result<(), String> {
1496 macro_rules! validate {
1497 ($field:ident) => {
1498 if let Some(provider) = &providers.$field {
1499 if provider.api_key_encrypted.is_some()
1500 || (!provider.api_key.trim().is_empty()
1501 && !provider.api_key_from_env
1502 && provider.credential_ref.is_none())
1503 {
1504 return Err("provider secret is outside the credential store".to_string());
1505 }
1506 }
1507 };
1508 }
1509 validate!(openai);
1510 validate!(anthropic);
1511 validate!(gemini);
1512 if let Some(provider) = &providers.bodhi {
1513 if provider.api_key_encrypted.is_some()
1514 || (!provider.api_key.trim().is_empty() && provider.credential_ref.is_none())
1515 {
1516 return Err("provider secret is outside the credential store".to_string());
1517 }
1518 }
1519 Ok(())
1520}
1521
1522fn validate_header_refs(
1523 server_id: &str,
1524 headers: &[bamboo_mcp::HeaderConfig],
1525) -> Result<(), String> {
1526 for header in headers {
1527 if header.value_encrypted.is_some()
1528 || (!header.value.is_empty() && header.credential_ref.is_none())
1529 {
1530 return Err(format!(
1531 "MCP server '{server_id}' contains a secret outside the credential store"
1532 ));
1533 }
1534 if let Some(raw) = &header.credential_ref {
1535 bamboo_config::CredentialRef::parse(raw.clone())
1536 .map_err(|_| "MCP credential reference is invalid".to_string())?;
1537 }
1538 }
1539 Ok(())
1540}
1541
1542enum ProviderCandidateErrorKind {
1543 Missing,
1544 InvalidDocument,
1545 Runtime,
1546}
1547
1548struct ProviderCandidateError {
1549 kind: ProviderCandidateErrorKind,
1550 message: String,
1551}
1552
1553impl ProviderCandidateError {
1554 fn missing() -> Self {
1555 Self {
1556 kind: ProviderCandidateErrorKind::Missing,
1557 message: "provider section is missing".to_string(),
1558 }
1559 }
1560
1561 fn invalid(message: &str) -> Self {
1562 Self {
1563 kind: ProviderCandidateErrorKind::InvalidDocument,
1564 message: message.to_string(),
1565 }
1566 }
1567
1568 fn missing_section(message: &str) -> Self {
1569 Self {
1570 kind: ProviderCandidateErrorKind::Missing,
1571 message: message.to_string(),
1572 }
1573 }
1574
1575 fn runtime() -> Self {
1576 Self {
1577 kind: ProviderCandidateErrorKind::Runtime,
1578 message: "provider runtime initialization failed".to_string(),
1579 }
1580 }
1581}
1582
1583fn candidate_error_status(kind: &ProviderCandidateErrorKind) -> SectionStatus {
1584 match kind {
1585 ProviderCandidateErrorKind::Missing => SectionStatus::Missing,
1586 ProviderCandidateErrorKind::InvalidDocument => SectionStatus::Invalid,
1587 ProviderCandidateErrorKind::Runtime => SectionStatus::Degraded,
1588 }
1589}
1590
1591fn update_live_health(
1592 health: &std::sync::RwLock<ConfigLiveHealth>,
1593 status: SectionStatus,
1594 last_error: Option<String>,
1595 advance_revision: bool,
1596 source: Option<(PathBuf, SectionSourceKind)>,
1597) -> u64 {
1598 let mut health = health
1599 .write()
1600 .unwrap_or_else(|poisoned| poisoned.into_inner());
1601 if advance_revision {
1602 health.revision = health.revision.saturating_add(1);
1603 }
1604 health.loaded_at = Utc::now();
1605 health.status = status;
1606 health.last_error = last_error;
1607 if let Some((source_path, source_kind)) = source {
1608 health.source_path = source_path;
1609 health.source_kind = source_kind;
1610 }
1611 health.revision
1612}
1613
1614fn set_live_health_revision(
1615 health: &std::sync::RwLock<ConfigLiveHealth>,
1616 revision: u64,
1617 source: Option<(PathBuf, SectionSourceKind)>,
1618) -> u64 {
1619 let mut health = health
1620 .write()
1621 .unwrap_or_else(|poisoned| poisoned.into_inner());
1622 health.revision = revision;
1623 health.loaded_at = Utc::now();
1624 health.status = SectionStatus::Healthy;
1625 health.last_error = None;
1626 if let Some((source_path, source_kind)) = source {
1627 health.source_path = source_path;
1628 health.source_kind = source_kind;
1629 }
1630 revision
1631}
1632
1633#[derive(Debug)]
1634pub(crate) enum ConfigSectionMutationError {
1635 Store(ConfigStoreError),
1636 Invalid(String),
1637 Runtime(String),
1638}
1639
1640pub(crate) enum CredentialBackedResetCommit {
1641 Section(bamboo_config::SectionEnvelope<Value>),
1642 Cluster(Box<bamboo_server_tools::FabricCommitSnapshot>),
1643}
1644
1645impl AppState {
1646 #[cfg(test)]
1647 pub(crate) fn stop_config_watcher_for_test(&mut self) {
1648 self.config_watcher.stop.store(true, Ordering::Relaxed);
1649 if let Some(task) = self.config_watcher.apply_task.take() {
1650 task.abort();
1651 }
1652 if let Some(task) = self.config_watcher.watcher_task.take() {
1653 let _ = task.join();
1654 }
1655 }
1656
1657 #[cfg(test)]
1658 pub(crate) async fn reload_ordinary_section_for_test(&self, id: SectionId) {
1659 let _io = self.config_io_lock.lock().await;
1660 let facade = self
1661 .config_facade
1662 .as_ref()
1663 .expect("test ordinary reload requires the modular facade");
1664 reload_and_apply_ordinary_sections(
1665 &self.app_data_dir,
1666 &self.config,
1667 facade,
1668 &self.account_sink,
1669 std::iter::once(id),
1670 )
1671 .await;
1672 }
1673
1674 pub(crate) async fn read_exact_credential_section(
1679 &self,
1680 section: SectionId,
1681 ) -> Result<ExactCredentialSectionSnapshot, AppError> {
1682 let _io = self.config_io_lock.lock().await;
1683 let data_dir = self.app_data_dir.clone();
1684 let exact = tokio::task::spawn_blocking(move || {
1685 bamboo_config::read_exact_credential_section_snapshot(data_dir, section, None)
1686 })
1687 .await
1688 .map_err(|error| {
1689 AppError::InternalError(anyhow::anyhow!(
1690 "{} exact read snapshot task failed: {error}",
1691 section.descriptor().name
1692 ))
1693 })?
1694 .map_err(map_exact_credential_store_error)?;
1695 let envelope = exact.section.clone();
1696 let mut config = Config::default();
1697 let metadata = exact.install_into(&mut config);
1698 Ok(ExactCredentialSectionSnapshot {
1699 config,
1700 section: envelope,
1701 metadata,
1702 })
1703 }
1704
1705 pub(crate) async fn put_ordinary_section(
1709 &self,
1710 id: SectionId,
1711 expected_revision: u64,
1712 candidate: Value,
1713 ) -> Result<bamboo_config::SectionEnvelope<Value>, ConfigSectionMutationError> {
1714 if matches!(
1715 id,
1716 SectionId::Providers
1717 | SectionId::Mcp
1718 | SectionId::Credentials
1719 | SectionId::ClusterFabric
1720 ) {
1721 return Err(ConfigSectionMutationError::Invalid(
1722 "this section requires its dedicated endpoint".to_string(),
1723 ));
1724 }
1725 let _io = self.config_io_lock.lock().await;
1726 ensure_provider_mcp_migration_ready(&self.app_data_dir)
1727 .map_err(ConfigSectionMutationError::Store)?;
1728 let facade = self.config_facade.as_ref().ok_or_else(|| {
1729 ConfigSectionMutationError::Invalid(
1730 "typed section writes require the modular configuration facade".to_string(),
1731 )
1732 })?;
1733 let current = if id == SectionId::Core {
1734 let data_dir = self.app_data_dir.clone();
1735 let exact = tokio::task::spawn_blocking(move || {
1736 bamboo_config::read_exact_credential_section_snapshot(
1737 data_dir,
1738 SectionId::Core,
1739 Some(expected_revision),
1740 )
1741 })
1742 .await
1743 .map_err(|error| {
1744 ConfigSectionMutationError::Runtime(format!(
1745 "Core exact inventory snapshot task failed: {error}"
1746 ))
1747 })?
1748 .map_err(ConfigSectionMutationError::Store)?;
1749 if let Some(reference) = exact
1750 .section
1751 .data
1752 .get("proxy_auth_credential_ref")
1753 .and_then(Value::as_str)
1754 {
1755 let configured = exact
1756 .credential_statuses
1757 .iter()
1758 .any(|status| status.credential_ref.as_str() == reference && status.configured);
1759 if !configured {
1760 return Err(ConfigSectionMutationError::Invalid(
1761 "the active Core proxy credential is invalid; explicitly replace or clear it through the proxy-auth API"
1762 .to_string(),
1763 ));
1764 }
1765 }
1766 exact.section
1767 } else {
1768 facade
1769 .registry()
1770 .envelope_value(id)
1771 .map_err(ConfigSectionMutationError::Store)?
1772 };
1773 if credential_reference_inventory(¤t.data)
1774 != credential_reference_inventory(&candidate)
1775 {
1776 return Err(ConfigSectionMutationError::Invalid(
1777 "credential references are server-managed; use the credential or domain API"
1778 .to_string(),
1779 ));
1780 }
1781
1782 let (event, committed) = if id == SectionId::Core {
1783 bamboo_config::commit_core_metadata_from_durable_base(
1789 &self.app_data_dir,
1790 facade,
1791 expected_revision,
1792 candidate,
1793 )
1794 } else {
1795 facade
1796 .registry()
1797 .commit_value_with_envelope(id, expected_revision, candidate)
1798 }
1799 .map_err(ConfigSectionMutationError::Store)?;
1800 let materialized = materialize_facade_effective_config(facade, &self.app_data_dir);
1801 if materialized.failures.contains(&id) {
1802 let message =
1803 "configuration runtime hydration failed; retaining last-known-good runtime"
1804 .to_string();
1805 if let Some(invalid) = facade.registry().mark_runtime_degraded(id, message.clone()) {
1806 publish_registry_event(&self.account_sink, &invalid);
1807 }
1808 return Err(ConfigSectionMutationError::Runtime(message));
1809 }
1810
1811 let mut live = self.config.read().await.clone();
1812 let enforcement_newly_off = id == SectionId::ToolsSkills
1813 && !live.plugin_trust.enforcement_is_off()
1814 && materialized.config.plugin_trust.enforcement_is_off();
1815 apply_runtime_section(id, &materialized.config, &mut live);
1816 if id == SectionId::Env {
1817 live.publish_env_vars();
1818 }
1819 *self.config.write().await = live;
1820 if enforcement_newly_off {
1821 warn_plugin_trust_enforcement_off();
1822 }
1823 publish_registry_event(&self.account_sink, &event);
1824 Ok(committed)
1825 }
1826
1827 pub(crate) async fn put_provider_section(
1830 &self,
1831 expected_revision: u64,
1832 mut providers: ProviderConfigs,
1833 ) -> Result<u64, ConfigSectionMutationError> {
1834 let _io = self.config_io_lock.lock().await;
1835 ensure_provider_mcp_migration_ready(&self.app_data_dir)
1836 .map_err(ConfigSectionMutationError::Store)?;
1837 let current = self.config.read().await.clone();
1838 retain_provider_credentials(current.providers(), &mut providers);
1839 let mut candidate = current;
1840 *candidate.providers_mut() = providers.clone();
1841 let (candidate, registry, provider) =
1842 match prepare_provider_candidate(candidate, &self.app_data_dir).await {
1843 Ok(prepared) => prepared,
1844 Err(_) => {
1845 let message =
1846 "provider runtime initialization failed; retaining last-known-good runtime"
1847 .to_string();
1848 publish_section_failure(
1849 &self.config_live_health,
1850 &self.account_sink,
1851 "providers",
1852 SectionStatus::Degraded,
1853 message.clone(),
1854 );
1855 return Err(ConfigSectionMutationError::Runtime(message));
1856 }
1857 };
1858
1859 let durable_providers = provider_durable_document(&providers)?;
1860 let mut live_config = self.config.write().await;
1864 let mut live_provider = self.provider.write().await;
1865 let (revision, source_path) = if let Some(facade) = self.config_facade.as_ref() {
1866 let mut section = facade.registry().providers.snapshot().data.as_ref().clone();
1867 section.providers = durable_providers;
1868 let event = facade
1869 .registry()
1870 .providers
1871 .commit(expected_revision, section)
1872 .map_err(ConfigSectionMutationError::Store)?;
1873 let ConfigSectionEvent::Changed { revision, .. } = event else {
1874 unreachable!("a successful section commit is changed")
1875 };
1876 (
1877 revision,
1878 facade.registry().providers.snapshot().source_path.clone(),
1879 )
1880 } else {
1881 let store = AtomicJsonStore::new(self.app_data_dir.join("providers.json"), 1);
1882 let revision = store
1883 .commit_allowing_unversioned(
1884 expected_revision,
1885 durable_providers,
1886 validate_provider_config,
1887 )
1888 .map_err(ConfigSectionMutationError::Store)?;
1889 (revision, store.path().to_path_buf())
1890 };
1891
1892 candidate.publish_env_vars();
1893 *live_config = candidate;
1894 self.provider_registry.replace_with(registry);
1895 *live_provider = provider;
1896 publish_section_success(
1897 &self.config_live_health,
1898 &self.account_sink,
1899 "providers",
1900 source_path,
1901 section_is_unhealthy(&self.config_live_health),
1902 Some(revision),
1903 );
1904 Ok(revision)
1905 }
1906
1907 pub(crate) async fn put_provider_settings<F>(
1912 &self,
1913 expected_revision: u64,
1914 update: F,
1915 ) -> Result<u64, ConfigSectionMutationError>
1916 where
1917 F: FnOnce(
1918 &Config,
1919 &mut Config,
1920 )
1921 -> Result<(BTreeSet<String>, BTreeSet<String>), ConfigSectionMutationError>
1922 + Send
1923 + 'static,
1924 {
1925 let config_io_lock = self.config_io_lock.clone();
1926 let config = self.config.clone();
1927 let app_data_dir = self.app_data_dir.clone();
1928 let config_facade = self.config_facade.clone();
1929 let account_sink = self.account_sink.clone();
1930 let provider_registry = self.provider_registry.clone();
1931 let provider = self.provider.clone();
1932 let config_live_health = self.config_live_health.clone();
1933 let transaction = tokio::spawn(async move {
1934 let _io = config_io_lock.lock().await;
1935 ensure_provider_mcp_migration_ready(&app_data_dir)
1936 .map_err(ConfigSectionMutationError::Store)?;
1937 let facade = config_facade.as_ref().ok_or_else(|| {
1938 ConfigSectionMutationError::Invalid(
1939 "provider settings require the modular configuration facade".to_string(),
1940 )
1941 })?;
1942 let current = config.read().await.clone();
1943 let mut candidate = current.clone();
1944 let (provider_intents, provider_instance_intents) = update(¤t, &mut candidate)?;
1945
1946 let (candidate, registry, candidate_provider) =
1947 match prepare_provider_candidate(candidate, &app_data_dir).await {
1948 Ok(prepared) => prepared,
1949 Err(_) => {
1950 let message =
1951 "provider runtime initialization failed; retaining last-known-good runtime"
1952 .to_string();
1953 publish_section_failure(
1954 &config_live_health,
1955 &account_sink,
1956 "providers",
1957 SectionStatus::Degraded,
1958 message.clone(),
1959 );
1960 return Err(ConfigSectionMutationError::Runtime(message));
1961 }
1962 };
1963
1964 let mut live_config = config.write().await;
1969 let mut live_provider = provider.write().await;
1970 let transaction_dir = app_data_dir.clone();
1971 let commit_facade = facade.clone();
1972 let (mut committed, commit) = tokio::task::spawn_blocking(move || {
1973 let mut durable_candidate = candidate;
1974 let commit =
1975 bamboo_config::persist_provider_credential_transaction_at_revision_with_adoption(
1976 &transaction_dir,
1977 &mut durable_candidate,
1978 &provider_intents,
1979 &provider_instance_intents,
1980 expected_revision,
1981 commit_facade.as_ref(),
1982 )?;
1983 Ok::<_, ConfigStoreError>((durable_candidate, commit))
1984 })
1985 .await
1986 .map_err(|error| {
1987 ConfigSectionMutationError::Runtime(format!(
1988 "provider settings transaction task failed: {error}"
1989 ))
1990 })?
1991 .map_err(ConfigSectionMutationError::Store)?;
1992
1993 let installed = install_credential_section_commit(commit, &mut committed)
1994 .map_err(ConfigSectionMutationError::Store)?;
1995 committed.publish_env_vars();
1996 *live_config = committed;
1997 provider_registry.replace_with(registry);
1998 *live_provider = candidate_provider;
1999 publish_exact_facade_events(&account_sink, &installed.events)
2000 .map_err(|error| ConfigSectionMutationError::Runtime(error.to_string()))?;
2001 let provider_snapshot = facade.registry().providers.snapshot();
2002 set_live_health_revision(
2003 &config_live_health,
2004 provider_snapshot.revision,
2005 Some((
2006 provider_snapshot.source_path.clone(),
2007 SectionSourceKind::File,
2008 )),
2009 );
2010 Ok(provider_snapshot.revision)
2011 });
2012 transaction.await.map_err(|error| {
2013 ConfigSectionMutationError::Runtime(format!(
2014 "provider settings transaction task failed: {error}"
2015 ))
2016 })?
2017 }
2018
2019 pub(crate) async fn reset_provider_section(
2024 &self,
2025 expected_revision: u64,
2026 ) -> Result<u64, ConfigSectionMutationError> {
2027 let config_io_lock = self.config_io_lock.clone();
2028 let config = self.config.clone();
2029 let app_data_dir = self.app_data_dir.clone();
2030 let config_facade = self.config_facade.clone();
2031 let account_sink = self.account_sink.clone();
2032 let provider_registry = self.provider_registry.clone();
2033 let provider = self.provider.clone();
2034 let config_live_health = self.config_live_health.clone();
2035 let transaction = tokio::spawn(async move {
2036 let _io = config_io_lock.lock().await;
2037 ensure_provider_mcp_migration_ready(&app_data_dir)
2038 .map_err(ConfigSectionMutationError::Store)?;
2039 let facade = config_facade.as_ref().ok_or_else(|| {
2040 ConfigSectionMutationError::Invalid(
2041 "provider reset requires the modular configuration facade".to_string(),
2042 )
2043 })?;
2044 let current = config.read().await.clone();
2045 let provider_intents = BTreeSet::from([
2046 "openai".to_string(),
2047 "anthropic".to_string(),
2048 "gemini".to_string(),
2049 "bodhi".to_string(),
2050 ]);
2051 let provider_instance_intents = current.provider_instances.keys().cloned().collect();
2052 let mut candidate = current;
2053 apply_runtime_section(SectionId::Providers, &Config::default(), &mut candidate);
2054
2055 let transaction_dir = app_data_dir.clone();
2056 let commit_facade = facade.clone();
2057 let (mut candidate, commit) = tokio::task::spawn_blocking(move || {
2058 let commit =
2059 bamboo_config::persist_provider_reset_credential_transaction_at_revision_with_adoption(
2060 &transaction_dir,
2061 &mut candidate,
2062 &provider_intents,
2063 &provider_instance_intents,
2064 expected_revision,
2065 commit_facade.as_ref(),
2066 )?;
2067 Ok::<_, ConfigStoreError>((candidate, commit))
2068 })
2069 .await
2070 .map_err(|error| {
2071 ConfigSectionMutationError::Runtime(format!(
2072 "provider reset transaction task failed: {error}"
2073 ))
2074 })?
2075 .map_err(ConfigSectionMutationError::Store)?;
2076
2077 let installed = install_credential_section_commit(commit, &mut candidate)
2078 .map_err(ConfigSectionMutationError::Store)?;
2079 *config.write().await = candidate.clone();
2080 let provider_snapshot = facade.registry().providers.snapshot();
2081 set_live_health_revision(
2082 &config_live_health,
2083 provider_snapshot.revision,
2084 Some((
2085 provider_snapshot.source_path.clone(),
2086 SectionSourceKind::File,
2087 )),
2088 );
2089 publish_exact_facade_events(&account_sink, &installed.events)
2090 .map_err(|error| ConfigSectionMutationError::Runtime(error.to_string()))?;
2091
2092 let runtime_failure = match bamboo_llm::ProviderRegistry::from_config(
2093 &candidate,
2094 app_data_dir,
2095 )
2096 .await
2097 {
2098 Ok(registry) => {
2099 if let Some(candidate_provider) = registry.get_default() {
2100 provider_registry.replace_with(registry);
2101 *provider.write().await = candidate_provider;
2102 None
2103 } else {
2104 Some(
2105 "provider reset committed; default provider is not initialized"
2106 .to_string(),
2107 )
2108 }
2109 }
2110 Err(error) => {
2111 tracing::warn!(error = %error, "provider reset committed but runtime initialization failed");
2112 Some("provider reset committed; retaining last-known-good runtime".to_string())
2113 }
2114 };
2115 if let Some(message) = runtime_failure {
2116 publish_section_failure(
2117 &config_live_health,
2118 &account_sink,
2119 "providers",
2120 SectionStatus::Degraded,
2121 message,
2122 );
2123 }
2124 Ok(provider_snapshot.revision)
2125 });
2126 transaction.await.map_err(|error| {
2127 ConfigSectionMutationError::Runtime(format!(
2128 "provider reset transaction task failed: {error}"
2129 ))
2130 })?
2131 }
2132
2133 pub(crate) async fn put_mcp_section(
2137 &self,
2138 expected_revision: u64,
2139 mut candidate: McpConfig,
2140 ) -> Result<u64, ConfigSectionMutationError> {
2141 let _io = self.config_io_lock.lock().await;
2142 ensure_provider_mcp_migration_ready(&self.app_data_dir)
2143 .map_err(ConfigSectionMutationError::Store)?;
2144 retain_mcp_credentials(
2145 &self.config.read().await.mcp,
2146 &mut candidate,
2147 &BTreeSet::new(),
2148 );
2149 validate_mcp_config(&candidate).map_err(ConfigSectionMutationError::Invalid)?;
2150 let mut hydration_config = Config::default();
2151 hydration_config.mcp = candidate;
2152 hydration_config
2153 .hydrate_mcp_credentials_from_store(&self.app_data_dir)
2154 .map_err(|_| {
2155 ConfigSectionMutationError::Invalid(
2156 "referenced MCP credential is unavailable".to_string(),
2157 )
2158 })?;
2159 let candidate = hydration_config.mcp.clone();
2160 let mut revision = None;
2161 let mut store_error = None;
2162 let durable_candidate = credential_ref_mcp_document(&candidate)?;
2163 let mut next_config = candidate.clone();
2164 retain_mcp_credential_refs(&durable_candidate, &mut next_config);
2165 let result = self
2166 .mcp_manager
2167 .reconcile_from_config_transactional_after(&candidate, || async {
2168 let mut live_config = self.config.write().await;
2172 let commit = if let Some(facade) = self.config_facade.as_ref() {
2173 facade
2174 .registry()
2175 .mcp
2176 .commit(expected_revision, McpSection(durable_candidate))
2177 .map(|event| match event {
2178 ConfigSectionEvent::Changed { revision, .. } => revision,
2179 _ => unreachable!("a successful section commit is changed"),
2180 })
2181 } else {
2182 AtomicJsonStore::new(self.app_data_dir.join("mcp.json"), 1).commit(
2183 expected_revision,
2184 durable_candidate,
2185 validate_mcp_config,
2186 )
2187 };
2188 match commit {
2189 Ok(committed) => {
2190 live_config.mcp = next_config;
2191 revision = Some(committed);
2192 Ok(())
2193 }
2194 Err(error) => {
2195 store_error = Some(error);
2196 Err(bamboo_mcp::McpError::InvalidConfig(
2197 "MCP section durable commit failed".to_string(),
2198 ))
2199 }
2200 }
2201 })
2202 .await;
2203 if let Some(error) = store_error {
2204 return Err(ConfigSectionMutationError::Store(error));
2205 }
2206 if result.is_err() {
2207 let message =
2208 "MCP runtime initialization failed; retaining last-known-good runtime".to_string();
2209 publish_section_failure(
2210 &self.mcp_config_live_health,
2211 &self.account_sink,
2212 "mcp",
2213 SectionStatus::Degraded,
2214 message.clone(),
2215 );
2216 return Err(ConfigSectionMutationError::Runtime(message));
2217 }
2218 let revision = revision.expect("successful MCP reconcile commits a revision");
2219 publish_section_success(
2220 &self.mcp_config_live_health,
2221 &self.account_sink,
2222 "mcp",
2223 self.app_data_dir.join("mcp.json"),
2224 section_is_unhealthy(&self.mcp_config_live_health),
2225 Some(revision),
2226 );
2227 Ok(revision)
2228 }
2229
2230 pub(crate) async fn put_mcp_settings(
2234 &self,
2235 expected_revision: u64,
2236 candidate: McpConfig,
2237 credential_intents: BTreeSet<bamboo_config::CredentialRef>,
2238 ) -> Result<u64, ConfigSectionMutationError> {
2239 if credential_intents.is_empty() {
2240 return self.put_mcp_section(expected_revision, candidate).await;
2241 }
2242
2243 let config_io_lock = self.config_io_lock.clone();
2244 let config = self.config.clone();
2245 let app_data_dir = self.app_data_dir.clone();
2246 let config_facade = self.config_facade.clone();
2247 let account_sink = self.account_sink.clone();
2248 let mcp_manager = self.mcp_manager.clone();
2249 let mcp_config_live_health = self.mcp_config_live_health.clone();
2250 let transaction = tokio::spawn(async move {
2251 let _io = config_io_lock.lock().await;
2252 ensure_provider_mcp_migration_ready(&app_data_dir)
2253 .map_err(ConfigSectionMutationError::Store)?;
2254 let facade = config_facade.as_ref().ok_or_else(|| {
2255 ConfigSectionMutationError::Invalid(
2256 "MCP settings require the modular configuration facade".to_string(),
2257 )
2258 })?;
2259 let current = config.read().await.clone();
2260 let mut runtime_candidate = candidate;
2261 materialize_mcp_touched_replacements(&mut runtime_candidate, &credential_intents)
2262 .map_err(ConfigSectionMutationError::Invalid)?;
2263 retain_mcp_credentials(¤t.mcp, &mut runtime_candidate, &credential_intents);
2264 validate_mcp_config(&runtime_candidate).map_err(ConfigSectionMutationError::Invalid)?;
2265
2266 let mut transaction_error = None;
2267 let mut commit_events = Vec::new();
2268 let transaction_dir = app_data_dir.clone();
2269 let commit_facade = facade.clone();
2270 let mut durable_candidate = current;
2271 durable_candidate.mcp = runtime_candidate.clone();
2272 let result = mcp_manager
2273 .reconcile_from_config_transactional_after(&runtime_candidate, || async {
2274 let mut live_config = config.write().await;
2275 let commit = tokio::task::spawn_blocking(move || {
2276 let commit =
2277 bamboo_config::persist_mcp_credential_transaction_at_revision_with_adoption(
2278 &transaction_dir,
2279 &mut durable_candidate,
2280 &credential_intents,
2281 expected_revision,
2282 commit_facade.as_ref(),
2283 )?;
2284 Ok::<_, ConfigStoreError>((durable_candidate, commit))
2285 })
2286 .await;
2287 match commit {
2288 Ok(Ok((mut committed, commit))) => {
2289 let installed =
2290 match install_credential_section_commit(commit, &mut committed) {
2291 Ok(installed) => installed,
2292 Err(error) => {
2293 transaction_error =
2294 Some(ConfigSectionMutationError::Store(error));
2295 return Err(bamboo_mcp::McpError::InvalidConfig(
2296 "MCP settings process adoption failed".to_string(),
2297 ));
2298 }
2299 };
2300 *live_config = committed;
2301 commit_events = installed.events;
2302 Ok(())
2303 }
2304 Ok(Err(error)) => {
2305 transaction_error = Some(ConfigSectionMutationError::Store(error));
2306 Err(bamboo_mcp::McpError::InvalidConfig(
2307 "MCP settings durable transaction failed".to_string(),
2308 ))
2309 }
2310 Err(error) => {
2311 transaction_error = Some(ConfigSectionMutationError::Runtime(format!(
2312 "MCP settings transaction task failed: {error}"
2313 )));
2314 Err(bamboo_mcp::McpError::InvalidConfig(
2315 "MCP settings durable transaction failed".to_string(),
2316 ))
2317 }
2318 }
2319 })
2320 .await;
2321 if let Some(error) = transaction_error {
2322 return Err(error);
2323 }
2324 if result.is_err() {
2325 let message =
2326 "MCP runtime initialization failed; retaining last-known-good runtime"
2327 .to_string();
2328 publish_section_failure(
2329 &mcp_config_live_health,
2330 &account_sink,
2331 "mcp",
2332 SectionStatus::Degraded,
2333 message.clone(),
2334 );
2335 return Err(ConfigSectionMutationError::Runtime(message));
2336 }
2337
2338 publish_exact_facade_events(&account_sink, &commit_events)
2339 .map_err(|error| ConfigSectionMutationError::Runtime(error.to_string()))?;
2340 let snapshot = facade.registry().mcp.snapshot();
2341 set_live_health_revision(
2342 &mcp_config_live_health,
2343 snapshot.revision,
2344 Some((snapshot.source_path.clone(), SectionSourceKind::File)),
2345 );
2346 Ok(snapshot.revision)
2347 });
2348 transaction.await.map_err(|error| {
2349 ConfigSectionMutationError::Runtime(format!(
2350 "MCP settings transaction task failed: {error}"
2351 ))
2352 })?
2353 }
2354
2355 pub(crate) async fn reset_mcp_section(
2359 &self,
2360 expected_revision: u64,
2361 ) -> Result<u64, ConfigSectionMutationError> {
2362 let _io = self.config_io_lock.lock().await;
2363 ensure_provider_mcp_migration_ready(&self.app_data_dir)
2364 .map_err(ConfigSectionMutationError::Store)?;
2365 let facade = self.config_facade.as_ref().ok_or_else(|| {
2366 ConfigSectionMutationError::Invalid(
2367 "MCP reset requires the modular configuration facade".to_string(),
2368 )
2369 })?;
2370 let candidate_mcp = McpConfig::default();
2371 let mut candidate_config = self.config.read().await.clone();
2372 candidate_config.mcp = candidate_mcp.clone();
2373 let mut committed = false;
2374 let mut commit_events = Vec::new();
2375 let mut store_error = None;
2376 let data_dir = self.app_data_dir.clone();
2377 let result = self
2378 .mcp_manager
2379 .reconcile_from_config_transactional_after(&candidate_mcp, || async {
2380 let mut live_config = self.config.write().await;
2381 match bamboo_config::persist_mcp_reset_credential_transaction_at_revision_with_adoption(
2382 &data_dir,
2383 &mut candidate_config,
2384 expected_revision,
2385 facade.as_ref(),
2386 ) {
2387 Ok(commit) => {
2388 match install_credential_section_commit(commit, &mut candidate_config) {
2389 Ok(installed) => {
2390 *live_config = candidate_config.clone();
2391 commit_events = installed.events;
2392 committed = true;
2393 }
2394 Err(error) => {
2395 store_error = Some(error);
2396 return Err(bamboo_mcp::McpError::InvalidConfig(
2397 "MCP reset process adoption failed".to_string(),
2398 ));
2399 }
2400 }
2401 Ok(())
2402 }
2403 Err(error) => {
2404 store_error = Some(error);
2405 Err(bamboo_mcp::McpError::InvalidConfig(
2406 "MCP reset durable commit failed".to_string(),
2407 ))
2408 }
2409 }
2410 })
2411 .await;
2412 if let Some(error) = store_error {
2413 return Err(ConfigSectionMutationError::Store(error));
2414 }
2415 if result.is_err() || !committed {
2416 let message =
2417 "MCP reset runtime initialization failed; retaining last-known-good runtime"
2418 .to_string();
2419 publish_section_failure(
2420 &self.mcp_config_live_health,
2421 &self.account_sink,
2422 "mcp",
2423 SectionStatus::Degraded,
2424 message.clone(),
2425 );
2426 return Err(ConfigSectionMutationError::Runtime(message));
2427 }
2428 publish_exact_facade_events(&self.account_sink, &commit_events)
2429 .map_err(|error| ConfigSectionMutationError::Runtime(error.to_string()))?;
2430 let revision = facade.registry().mcp.snapshot().revision;
2431 publish_section_success(
2432 &self.mcp_config_live_health,
2433 &self.account_sink,
2434 "mcp",
2435 self.app_data_dir.join("mcp.json"),
2436 section_is_unhealthy(&self.mcp_config_live_health),
2437 Some(revision),
2438 );
2439 Ok(revision)
2440 }
2441
2442 pub(crate) async fn reset_credential_backed_section(
2447 &self,
2448 id: SectionId,
2449 expected_revision: u64,
2450 ) -> Result<CredentialBackedResetCommit, ConfigSectionMutationError> {
2451 if !matches!(
2452 id,
2453 SectionId::Core
2454 | SectionId::Notifications
2455 | SectionId::Connect
2456 | SectionId::Env
2457 | SectionId::ClusterFabric
2458 | SectionId::AccessControl
2459 ) {
2460 return Err(ConfigSectionMutationError::Invalid(
2461 "section is not a credential-backed reset domain".to_string(),
2462 ));
2463 }
2464 let config_io_lock = self.config_io_lock.clone();
2465 let config = self.config.clone();
2466 let app_data_dir = self.app_data_dir.clone();
2467 let config_facade = self.config_facade.clone();
2468 let account_sink = self.account_sink.clone();
2469 let provider_registry = self.provider_registry.clone();
2470 let provider = self.provider.clone();
2471 let mcp_manager = self.mcp_manager.clone();
2472 let deployed_registry = self.fabric_deployer.registry();
2473 let transaction = tokio::spawn(async move {
2474 let _io = config_io_lock.lock().await;
2475 if id == SectionId::ClusterFabric {
2476 let deployed = deployed_registry.lock().await;
2477 if let Some(node_id) = deployed.keys().find_map(|key| {
2478 let (source, node_id) = bamboo_server_tools::registry_keys::split(key);
2479 (source == "node").then(|| node_id.to_string())
2480 }) {
2481 return Err(ConfigSectionMutationError::Invalid(format!(
2482 "node '{node_id}' is deployed; stop it before resetting cluster-fabric"
2483 )));
2484 }
2485 }
2486 ensure_provider_mcp_migration_ready(&app_data_dir)
2487 .map_err(ConfigSectionMutationError::Store)?;
2488 let facade = config_facade.as_ref().ok_or_else(|| {
2489 ConfigSectionMutationError::Invalid(
2490 "section reset requires the modular configuration facade".to_string(),
2491 )
2492 })?;
2493 let mut candidate = config.read().await.clone();
2494 apply_runtime_section(id, &Config::default(), &mut candidate);
2495 let transaction_dir = app_data_dir.clone();
2496 let commit_facade = facade.clone();
2497 let (mut candidate, revision, cluster_commit, section_commit) =
2498 tokio::task::spawn_blocking(move || {
2499 if id == SectionId::ClusterFabric {
2500 let commit =
2501 bamboo_config::persist_cluster_fabric_reset_at_revision_with_adoption(
2502 &transaction_dir,
2503 &mut candidate,
2504 expected_revision,
2505 commit_facade.as_ref(),
2506 |_, _| {},
2507 )?;
2508 let revision = commit.revision;
2509 Ok::<_, ConfigStoreError>((candidate, revision, Some(commit), None))
2510 } else {
2511 let commit =
2512 bamboo_config::persist_credential_backed_section_reset_at_revision_with_adoption(
2513 &transaction_dir,
2514 &mut candidate,
2515 id,
2516 expected_revision,
2517 commit_facade.as_ref(),
2518 )?;
2519 let revision = commit.revision;
2520 Ok((candidate, revision, None, Some(commit)))
2521 }
2522 })
2523 .await
2524 .map_err(|error| {
2525 ConfigSectionMutationError::Runtime(format!(
2526 "section reset transaction task failed: {error}"
2527 ))
2528 })?
2529 .map_err(ConfigSectionMutationError::Store)?;
2530
2531 let cluster_runtime = match cluster_commit {
2532 Some(commit) => {
2533 let bamboo_config::ClusterFabricTransactionCommit {
2534 revision: _,
2535 adoption,
2536 credential_adoption,
2537 committed_recovery,
2538 runtime,
2539 } = commit;
2540 let runtime = match runtime {
2541 Ok(bamboo_config::ClusterFabricRuntimeSnapshot {
2542 cluster_fabric,
2543 credential_statuses,
2544 credential_health,
2545 }) => {
2546 candidate.cluster_fabric = cluster_fabric;
2547 Ok((credential_statuses, credential_health))
2548 }
2549 Err(error) if revision == expected_revision => {
2550 return Err(ConfigSectionMutationError::Store(error));
2553 }
2554 Err(error) => {
2555 candidate.clear_cluster_runtime_credentials();
2560 Err(error)
2561 }
2562 };
2563 Some((adoption, credential_adoption, committed_recovery, runtime))
2564 }
2565 None => None,
2566 };
2567 let (section_events, exact_section) = match section_commit {
2568 Some(commit) => {
2569 let installed = install_credential_section_commit(commit, &mut candidate)
2570 .map_err(ConfigSectionMutationError::Store)?;
2571 (installed.events, installed.section)
2572 }
2573 None => (Vec::new(), None),
2574 };
2575 if id == SectionId::Env {
2576 candidate.publish_env_vars();
2577 }
2578 *config.write().await = candidate.clone();
2579 if id != SectionId::ClusterFabric {
2580 publish_exact_facade_events(&account_sink, §ion_events)
2581 .map_err(|error| ConfigSectionMutationError::Runtime(error.to_string()))?;
2582 }
2583 let commit = if id == SectionId::ClusterFabric {
2584 let (cluster_adoption, credential_adoption, committed_recovery, cluster_runtime) =
2585 cluster_runtime.expect("cluster reset captures an exact runtime");
2586 let event = match cluster_adoption {
2587 Some(Ok(event)) => Some(event),
2588 Some(Err(error)) => {
2589 return Err(ConfigSectionMutationError::Runtime(format!(
2590 "cluster reset committed at revision {revision} but process adoption failed: {error}"
2591 )));
2592 }
2593 None if revision == expected_revision => None,
2594 None => {
2595 return Err(ConfigSectionMutationError::Runtime(format!(
2596 "cluster reset committed at revision {revision} without a process adoption result"
2597 )));
2598 }
2599 };
2600 let section = facade
2601 .registry()
2602 .envelope_value(SectionId::ClusterFabric)
2603 .map_err(|error| {
2604 if revision == expected_revision {
2605 ConfigSectionMutationError::Store(error)
2606 } else {
2607 ConfigSectionMutationError::Runtime(format!(
2608 "cluster reset committed at revision {revision} but its exact envelope is unavailable: {error}"
2609 ))
2610 }
2611 })?;
2612 if section.revision != revision {
2613 return Err(ConfigSectionMutationError::Runtime(format!(
2614 "cluster reset committed at revision {revision} but facade retained revision {}",
2615 section.revision
2616 )));
2617 }
2618 if let Some(event) = event.as_ref() {
2619 publish_registry_event(&account_sink, event);
2620 }
2621 if let Err(error) = committed_recovery {
2622 return Err(ConfigSectionMutationError::Runtime(format!(
2623 "cluster reset committed at revision {revision} but transaction recovery failed: {error}"
2624 )));
2625 }
2626 if let Some(Err(error)) = credential_adoption {
2627 return Err(ConfigSectionMutationError::Runtime(format!(
2628 "cluster reset committed at revision {revision} but credential facade adoption failed: {error}"
2629 )));
2630 }
2631 let (credential_statuses, credential_health) =
2632 cluster_runtime.map_err(|error| {
2633 ConfigSectionMutationError::Runtime(format!(
2634 "cluster reset committed at revision {revision} but could not materialize its exact runtime credentials: {error}"
2635 ))
2636 })?;
2637 CredentialBackedResetCommit::Cluster(Box::new(
2638 bamboo_server_tools::FabricCommitSnapshot {
2639 config: candidate.clone(),
2640 section,
2641 credential_statuses,
2642 credential_health,
2643 },
2644 ))
2645 } else {
2646 let section = exact_section.ok_or_else(|| {
2647 ConfigSectionMutationError::Runtime(format!(
2648 "{} reset committed at revision {revision} without its exact envelope",
2649 id.descriptor().name
2650 ))
2651 })?;
2652 if section.revision != revision {
2653 return Err(ConfigSectionMutationError::Runtime(format!(
2654 "{} reset committed at revision {revision} but captured revision {}",
2655 id.descriptor().name,
2656 section.revision
2657 )));
2658 }
2659 CredentialBackedResetCommit::Section(section)
2660 };
2661
2662 if id == SectionId::Core {
2663 match bamboo_llm::ProviderRegistry::from_config(&candidate, app_data_dir.clone())
2664 .await
2665 {
2666 Ok(registry) => {
2667 if let Some(candidate_provider) = registry.get_default() {
2668 provider_registry.replace_with(registry);
2669 *provider.write().await = candidate_provider;
2670 }
2671 }
2672 Err(error) => {
2673 tracing::warn!(error = %error, "core reset committed but provider reload failed");
2674 }
2675 }
2676 mcp_manager.reconcile_from_config(&candidate.mcp).await;
2677 }
2678
2679 Ok(commit)
2680 });
2681 transaction.await.map_err(|error| {
2682 ConfigSectionMutationError::Runtime(format!(
2683 "section reset transaction task failed: {error}"
2684 ))
2685 })?
2686 }
2687}
2688
2689fn credential_reference_inventory(value: &Value) -> std::collections::BTreeMap<String, Value> {
2690 fn collect(value: &Value, path: &str, output: &mut std::collections::BTreeMap<String, Value>) {
2691 match value {
2692 Value::Object(object) => {
2693 for (key, value) in object {
2694 let child_path =
2695 format!("{path}/{}", key.replace('~', "~0").replace('/', "~1"));
2696 let normalized = key
2697 .chars()
2698 .filter(|ch| ch.is_ascii_alphanumeric())
2699 .flat_map(char::to_lowercase)
2700 .collect::<String>();
2701 if normalized == "credentialref"
2702 || normalized.ends_with("credentialref")
2703 || normalized.ends_with("credentialrefs")
2704 {
2705 output.insert(child_path, value.clone());
2706 } else {
2707 collect(value, &child_path, output);
2708 }
2709 }
2710 }
2711 Value::Array(values) => {
2712 for (index, value) in values.iter().enumerate() {
2713 collect(value, &format!("{path}/{index}"), output);
2714 }
2715 }
2716 _ => {}
2717 }
2718 }
2719
2720 let mut output = std::collections::BTreeMap::new();
2721 collect(value, "", &mut output);
2722 output
2723}
2724
2725fn provider_durable_document(
2726 providers: &ProviderConfigs,
2727) -> Result<ProviderConfigs, ConfigSectionMutationError> {
2728 let mut document = providers.clone();
2729 macro_rules! sanitize {
2730 ($field:ident) => {
2731 if let Some(provider) = document.$field.as_mut() {
2732 provider.api_key.clear();
2733 provider.api_key_encrypted = None;
2734 }
2735 };
2736 }
2737 sanitize!(openai);
2738 sanitize!(anthropic);
2739 sanitize!(gemini);
2740 if let Some(provider) = document.bodhi.as_mut() {
2741 provider.api_key.clear();
2742 provider.api_key_encrypted = None;
2743 }
2744 validate_provider_config(&document).map_err(ConfigSectionMutationError::Invalid)?;
2745 Ok(document)
2746}
2747
2748fn retain_provider_credentials(current: &ProviderConfigs, candidate: &mut ProviderConfigs) {
2749 candidate.extra = current.extra.clone();
2750 macro_rules! retain {
2751 ($field:ident) => {
2752 if let (Some(current), Some(candidate)) = (¤t.$field, &mut candidate.$field) {
2753 candidate.api_key = current.api_key.clone();
2754 candidate.api_key_encrypted = current.api_key_encrypted.clone();
2755 if candidate.credential_ref.is_none() {
2756 candidate.credential_ref = current.credential_ref.clone();
2757 }
2758 if candidate.credential_ref != current.credential_ref {
2759 candidate.api_key.clear();
2760 candidate.api_key_encrypted = None;
2761 }
2762 candidate.api_key_from_env = current.api_key_from_env;
2763 candidate.request_overrides = current.request_overrides.clone();
2764 candidate.extra = current.extra.clone();
2765 }
2766 };
2767 }
2768 retain!(openai);
2769 retain!(anthropic);
2770 retain!(gemini);
2771 if let (Some(current), Some(candidate)) = (¤t.bodhi, &mut candidate.bodhi) {
2772 candidate.api_key = current.api_key.clone();
2773 candidate.api_key_encrypted = current.api_key_encrypted.clone();
2774 if candidate.credential_ref.is_none() {
2775 candidate.credential_ref = current.credential_ref.clone();
2776 }
2777 if candidate.credential_ref != current.credential_ref {
2778 candidate.api_key.clear();
2779 candidate.api_key_encrypted = None;
2780 }
2781 candidate.extra = current.extra.clone();
2782 }
2783 if let (Some(current), Some(candidate)) = (¤t.copilot, &mut candidate.copilot) {
2784 candidate.request_overrides = current.request_overrides.clone();
2785 candidate.extra = current.extra.clone();
2786 }
2787}
2788
2789fn retain_mcp_credentials(
2790 current: &McpConfig,
2791 candidate: &mut McpConfig,
2792 touched: &BTreeSet<bamboo_config::CredentialRef>,
2793) {
2794 for candidate_server in &mut candidate.servers {
2795 let Some(current_server) = current
2796 .servers
2797 .iter()
2798 .find(|server| server.id == candidate_server.id)
2799 else {
2800 continue;
2801 };
2802 if let (TransportConfig::Stdio(current), TransportConfig::Stdio(candidate)) =
2803 (¤t_server.transport, &mut candidate_server.transport)
2804 {
2805 if candidate.env.is_empty()
2806 && candidate.env_encrypted.is_empty()
2807 && candidate.env_credential_refs.is_empty()
2808 {
2809 for (name, reference) in ¤t.env_credential_refs {
2810 if mcp_credential_ref_is_touched(Some(reference), touched) {
2811 continue;
2812 }
2813 candidate
2814 .env_credential_refs
2815 .insert(name.clone(), reference.clone());
2816 if let Some(value) = current.env.get(name) {
2817 candidate.env.insert(name.clone(), value.clone());
2818 }
2819 if let Some(value) = current.env_encrypted.get(name) {
2820 candidate.env_encrypted.insert(name.clone(), value.clone());
2821 }
2822 }
2823 } else {
2824 for (name, reference) in ¤t.env_credential_refs {
2825 if candidate.env_credential_refs.get(name) != Some(reference) {
2826 continue;
2827 }
2828 if candidate.env.get(name).is_none_or(|value| value.is_empty()) {
2829 if let Some(value) = current.env.get(name) {
2830 candidate.env.insert(name.clone(), value.clone());
2831 }
2832 }
2833 }
2834 }
2835 }
2836 match (¤t_server.transport, &mut candidate_server.transport) {
2837 (TransportConfig::Sse(current), TransportConfig::Sse(candidate)) => {
2838 retain_mcp_header_credentials(¤t.headers, &mut candidate.headers)
2839 }
2840 (
2841 TransportConfig::StreamableHttp(current),
2842 TransportConfig::StreamableHttp(candidate),
2843 ) => retain_mcp_header_credentials(¤t.headers, &mut candidate.headers),
2844 _ => {}
2845 }
2846 }
2847}
2848
2849fn materialize_mcp_touched_replacements(
2850 candidate: &mut McpConfig,
2851 touched: &BTreeSet<bamboo_config::CredentialRef>,
2852) -> Result<(), String> {
2853 let mut replacements = BTreeMap::<bamboo_config::CredentialRef, String>::new();
2854 for server in &candidate.servers {
2855 match &server.transport {
2856 TransportConfig::Stdio(stdio) => {
2857 for (name, raw_reference) in &stdio.env_credential_refs {
2858 let reference = bamboo_config::CredentialRef::parse(raw_reference.clone())
2859 .map_err(|_| "MCP credential reference is invalid".to_string())?;
2860 if let Some(value) = stdio
2861 .env
2862 .get(name)
2863 .filter(|value| touched.contains(&reference) && !value.is_empty())
2864 {
2865 insert_mcp_replacement(&mut replacements, reference, value)?;
2866 }
2867 }
2868 }
2869 TransportConfig::Sse(http) => {
2870 collect_mcp_header_replacements(&http.headers, touched, &mut replacements)?
2871 }
2872 TransportConfig::StreamableHttp(http) => {
2873 collect_mcp_header_replacements(&http.headers, touched, &mut replacements)?
2874 }
2875 }
2876 }
2877 if replacements.is_empty() {
2878 return Ok(());
2879 }
2880 for server in &mut candidate.servers {
2881 match &mut server.transport {
2882 TransportConfig::Stdio(stdio) => {
2883 for (name, raw_reference) in &stdio.env_credential_refs {
2884 let reference = bamboo_config::CredentialRef::parse(raw_reference.clone())
2885 .map_err(|_| "MCP credential reference is invalid".to_string())?;
2886 if let Some(value) = replacements.get(&reference) {
2887 stdio.env.insert(name.clone(), value.clone());
2888 }
2889 }
2890 }
2891 TransportConfig::Sse(http) => {
2892 apply_mcp_header_replacements(&mut http.headers, &replacements)?
2893 }
2894 TransportConfig::StreamableHttp(http) => {
2895 apply_mcp_header_replacements(&mut http.headers, &replacements)?
2896 }
2897 }
2898 }
2899 Ok(())
2900}
2901
2902fn collect_mcp_header_replacements(
2903 headers: &[bamboo_mcp::HeaderConfig],
2904 touched: &BTreeSet<bamboo_config::CredentialRef>,
2905 replacements: &mut BTreeMap<bamboo_config::CredentialRef, String>,
2906) -> Result<(), String> {
2907 for header in headers {
2908 let Some(raw_reference) = header.credential_ref.as_ref() else {
2909 continue;
2910 };
2911 let reference = bamboo_config::CredentialRef::parse(raw_reference.clone())
2912 .map_err(|_| "MCP credential reference is invalid".to_string())?;
2913 if touched.contains(&reference) && !header.value.is_empty() {
2914 insert_mcp_replacement(replacements, reference, &header.value)?;
2915 }
2916 }
2917 Ok(())
2918}
2919
2920fn insert_mcp_replacement(
2921 replacements: &mut BTreeMap<bamboo_config::CredentialRef, String>,
2922 reference: bamboo_config::CredentialRef,
2923 value: &str,
2924) -> Result<(), String> {
2925 match replacements.get(&reference) {
2926 Some(existing) if existing != value => {
2927 Err("MCP updates assign conflicting values to one credential reference".to_string())
2928 }
2929 Some(_) => Ok(()),
2930 None => {
2931 replacements.insert(reference, value.to_string());
2932 Ok(())
2933 }
2934 }
2935}
2936
2937fn apply_mcp_header_replacements(
2938 headers: &mut [bamboo_mcp::HeaderConfig],
2939 replacements: &BTreeMap<bamboo_config::CredentialRef, String>,
2940) -> Result<(), String> {
2941 for header in headers {
2942 let Some(raw_reference) = header.credential_ref.as_ref() else {
2943 continue;
2944 };
2945 let reference = bamboo_config::CredentialRef::parse(raw_reference.clone())
2946 .map_err(|_| "MCP credential reference is invalid".to_string())?;
2947 if let Some(value) = replacements.get(&reference) {
2948 header.value = value.clone();
2949 }
2950 }
2951 Ok(())
2952}
2953
2954fn mcp_credential_ref_is_touched(
2955 raw_reference: Option<&String>,
2956 touched: &BTreeSet<bamboo_config::CredentialRef>,
2957) -> bool {
2958 raw_reference
2959 .and_then(|raw| bamboo_config::CredentialRef::parse(raw.clone()).ok())
2960 .is_some_and(|reference| touched.contains(&reference))
2961}
2962
2963fn retain_mcp_header_credentials(
2964 current: &[bamboo_mcp::HeaderConfig],
2965 candidate: &mut [bamboo_mcp::HeaderConfig],
2966) {
2967 for candidate_header in candidate {
2968 let Some(current_header) = current
2969 .iter()
2970 .find(|header| header.name == candidate_header.name)
2971 else {
2972 continue;
2973 };
2974 if candidate_header.credential_ref == current_header.credential_ref
2975 && candidate_header.value.is_empty()
2976 {
2977 candidate_header.value = current_header.value.clone();
2978 candidate_header.value_encrypted = current_header.value_encrypted.clone();
2979 }
2980 }
2981}
2982
2983fn credential_ref_mcp_document(
2984 runtime: &McpConfig,
2985) -> Result<McpConfig, ConfigSectionMutationError> {
2986 let mut document = runtime.clone();
2987 for server in &mut document.servers {
2988 match &mut server.transport {
2989 TransportConfig::Stdio(config) => {
2990 config.env_encrypted.clear();
2991 config.env.retain(|name, value| {
2992 !(value.is_empty() || config.env_credential_refs.contains_key(name))
2993 });
2994 if !config.env.is_empty() {
2995 return Err(ConfigSectionMutationError::Invalid(
2996 "MCP secret requires a credential reference".to_string(),
2997 ));
2998 }
2999 }
3000 TransportConfig::Sse(config) => reference_headers(&mut config.headers)?,
3001 TransportConfig::StreamableHttp(config) => reference_headers(&mut config.headers)?,
3002 }
3003 }
3004 Ok(document)
3005}
3006
3007fn retain_mcp_credential_refs(document: &McpConfig, runtime: &mut McpConfig) {
3008 for runtime_server in &mut runtime.servers {
3009 let Some(document_server) = document
3010 .servers
3011 .iter()
3012 .find(|server| server.id == runtime_server.id)
3013 else {
3014 continue;
3015 };
3016 match (&document_server.transport, &mut runtime_server.transport) {
3017 (TransportConfig::Stdio(document), TransportConfig::Stdio(runtime)) => {
3018 runtime.env_encrypted.clear();
3019 runtime.env_credential_refs = document.env_credential_refs.clone();
3020 }
3021 (TransportConfig::Sse(document), TransportConfig::Sse(runtime)) => {
3022 copy_header_ciphertext(&document.headers, &mut runtime.headers);
3023 }
3024 (
3025 TransportConfig::StreamableHttp(document),
3026 TransportConfig::StreamableHttp(runtime),
3027 ) => copy_header_ciphertext(&document.headers, &mut runtime.headers),
3028 _ => {}
3029 }
3030 }
3031}
3032
3033fn copy_header_ciphertext(
3034 document: &[bamboo_mcp::HeaderConfig],
3035 runtime: &mut [bamboo_mcp::HeaderConfig],
3036) {
3037 for runtime_header in runtime {
3038 if let Some(document_header) = document
3039 .iter()
3040 .find(|header| header.name == runtime_header.name)
3041 {
3042 runtime_header.value_encrypted = None;
3043 runtime_header.credential_ref = document_header.credential_ref.clone();
3044 }
3045 }
3046}
3047
3048fn reference_headers(
3049 headers: &mut [bamboo_mcp::HeaderConfig],
3050) -> Result<(), ConfigSectionMutationError> {
3051 for header in headers {
3052 if !header.value.is_empty() && header.credential_ref.is_none() {
3053 return Err(ConfigSectionMutationError::Invalid(
3054 "MCP secret requires a credential reference".to_string(),
3055 ));
3056 }
3057 header.value.clear();
3058 header.value_encrypted = None;
3059 }
3060 Ok(())
3061}
3062
3063impl AppState {
3064 pub async fn reload_provider(&self) -> Result<(), bamboo_llm::LLMError> {
3096 let config = self.config.read().await.clone();
3097 let candidate_registry =
3098 bamboo_llm::ProviderRegistry::from_config(&config, self.app_data_dir.clone()).await?;
3099 let default_provider_name = candidate_registry.default_provider_name();
3100 tracing::info!(
3101 default_provider = %default_provider_name,
3102 legacy_provider = %config.provider,
3103 has_provider_instances = config.has_provider_instances(),
3104 "Reloading provider runtime from current config"
3105 );
3106
3107 let new_provider = candidate_registry.get_default().ok_or_else(|| {
3108 let message = if config.has_provider_instances() {
3109 format!(
3110 "Default provider instance '{}' is not available or failed to initialize",
3111 default_provider_name
3112 )
3113 } else {
3114 format!(
3115 "Provider '{}' is not available or failed to initialize",
3116 config.provider
3117 )
3118 };
3119 bamboo_llm::LLMError::Auth(message)
3120 })?;
3121
3122 let mut provider = self.provider.write().await;
3123 self.provider_registry.replace_with(candidate_registry);
3124 *provider = new_provider;
3125
3126 tracing::info!(
3127 default_provider = %default_provider_name,
3128 "Provider reloaded successfully"
3129 );
3130 Ok(())
3131 }
3132
3133 pub async fn reload_config(&self) -> Config {
3163 let _io = self.config_io_lock.lock().await;
3168 let mut config = self.config.write().await;
3169 let new_config = self
3170 .config_facade
3171 .as_ref()
3172 .map(|facade| load_facade_effective_config(facade, &self.app_data_dir))
3173 .unwrap_or_else(|| {
3174 Config::from_data_dir_without_publish(Some(self.app_data_dir.clone()))
3175 });
3176 new_config.publish_env_vars();
3177 *config = new_config.clone();
3178 new_config
3179 }
3180
3181 async fn persist_config_snapshot(
3182 data_dir: PathBuf,
3183 config_facade: Option<Arc<bamboo_config::ConfigFacade>>,
3184 config: Config,
3185 ) -> Result<Option<bamboo_config::FacadeConfigCommit>, AppError> {
3186 if let Some(facade) = config_facade {
3187 tokio::task::spawn_blocking(move || {
3188 let result = bamboo_config::persist_facade_effective_config_with_adoption(
3189 &data_dir,
3190 &config,
3191 facade.as_ref(),
3192 );
3193 #[cfg(test)]
3194 if result.is_ok() {
3195 run_generic_before_event_test_hook(&data_dir);
3196 }
3197 result
3198 })
3199 .await
3200 .map_err(|error| {
3201 AppError::InternalError(anyhow::anyhow!("Config save task failed: {error}"))
3202 })?
3203 .map(Some)
3204 .map_err(map_exact_credential_store_error)
3205 } else {
3206 tokio::task::spawn_blocking(move || {
3207 let result = config.save_to_dir(data_dir.clone());
3208 #[cfg(test)]
3209 if result.is_ok() {
3210 run_generic_before_event_test_hook(&data_dir);
3211 }
3212 result
3213 })
3214 .await
3215 .map_err(|error| {
3216 AppError::InternalError(anyhow::anyhow!("Config save task failed: {error}"))
3217 })?
3218 .map_err(|error| {
3219 AppError::InternalError(anyhow::anyhow!("Failed to save config: {error}"))
3220 })?;
3221 Ok(None)
3222 }
3223 }
3224
3225 pub async fn update_config<F>(
3232 &self,
3233 update: F,
3234 effects: ConfigUpdateEffects,
3235 ) -> Result<Config, AppError>
3236 where
3237 F: FnOnce(&mut Config) -> Result<(), AppError>,
3238 {
3239 let io = self.config_io_lock.clone().lock_owned().await;
3243 let (mut snapshot, live_base, enforcement_newly_off) = {
3244 let cfg = self.config.read().await;
3245 reject_if_recovery_pending(&cfg)?;
3251 let was_off = cfg.plugin_trust.enforcement_is_off();
3252 let live_base = cfg.clone();
3253 let mut candidate = cfg.clone();
3254 restore_authoritative_cluster_fabric(self.config_facade.as_ref(), &mut candidate);
3255 update(&mut candidate)?;
3256 restore_authoritative_cluster_fabric(self.config_facade.as_ref(), &mut candidate);
3258 if self.config_facade.is_none() {
3259 candidate.assign_connect_platform_ids();
3260 candidate.refresh_encrypted_secrets().map_err(|e| {
3261 AppError::InternalError(anyhow::anyhow!(
3262 "Failed to refresh encrypted secrets: {e}"
3263 ))
3264 })?;
3265 }
3266 let newly_off = !was_off && candidate.plugin_trust.enforcement_is_off();
3267 (candidate, live_base, newly_off)
3268 };
3269 if enforcement_newly_off {
3270 warn_plugin_trust_enforcement_off();
3271 }
3272 let config = self.config.clone();
3273 let app_data_dir = self.app_data_dir.clone();
3274 let config_facade = self.config_facade.clone();
3275 let account_sink = self.account_sink.clone();
3276 let provider_registry = self.provider_registry.clone();
3277 let provider = self.provider.clone();
3278 let mcp_manager = self.mcp_manager.clone();
3279 let transaction = tokio::spawn(async move {
3283 let snapshot = {
3290 let _io = io;
3291 let commit = Self::persist_config_snapshot(
3292 app_data_dir.clone(),
3293 config_facade.clone(),
3294 snapshot.clone(),
3295 )
3296 .await?;
3297 let events = match commit {
3298 Some(commit) => {
3299 let mut published = live_base;
3300 let events =
3301 install_facade_config_commit(commit, &mut published).map_err(|e| {
3302 AppError::InternalError(anyhow::anyhow!(
3303 "failed to install committed configuration section: {e}"
3304 ))
3305 })?;
3306 snapshot = published;
3307 events
3308 }
3309 None => Vec::new(),
3310 };
3311 {
3312 let mut cfg = config.write().await;
3313 snapshot.publish_env_vars();
3314 *cfg = snapshot.clone();
3315 }
3316 publish_exact_facade_events(&account_sink, &events)?;
3320 Self::apply_config_effects_owned(
3321 snapshot.clone(),
3322 effects,
3323 app_data_dir,
3324 config,
3325 provider_registry,
3326 provider,
3327 mcp_manager,
3328 )
3329 .await?;
3330 snapshot
3331 };
3332 Ok::<_, AppError>(snapshot)
3333 });
3334 transaction.await.map_err(|error| {
3335 AppError::InternalError(anyhow::anyhow!(
3336 "config update transaction task failed: {error}"
3337 ))
3338 })?
3339 }
3340
3341 pub async fn update_config_with_provider_credentials<F>(
3344 &self,
3345 update: F,
3346 provider_intents: std::collections::BTreeSet<String>,
3347 provider_instance_intents: std::collections::BTreeSet<String>,
3348 effects: ConfigUpdateEffects,
3349 ) -> Result<Config, AppError>
3350 where
3351 F: FnOnce(&mut Config) -> Result<(), AppError>,
3352 {
3353 if provider_intents.is_empty() && provider_instance_intents.is_empty() {
3354 return self.update_config(update, effects).await;
3355 }
3356 let io = self.config_io_lock.clone().lock_owned().await;
3357 let config_facade = self.config_facade.clone();
3358 let (mut candidate, live_base, enforcement_newly_off) = {
3359 let cfg = self.config.read().await;
3360 reject_if_recovery_pending(&cfg)?;
3361 let was_off = cfg.plugin_trust.enforcement_is_off();
3362 let live_base = cfg.clone();
3363 let mut candidate = cfg.clone();
3364 restore_authoritative_cluster_fabric(config_facade.as_ref(), &mut candidate);
3365 update(&mut candidate)?;
3366 restore_authoritative_cluster_fabric(config_facade.as_ref(), &mut candidate);
3368 let mut non_provider_candidate = candidate.clone();
3373 apply_runtime_section(SectionId::Providers, &cfg, &mut non_provider_candidate);
3374 let mut comparison_base = cfg.clone();
3375 restore_authoritative_cluster_fabric(config_facade.as_ref(), &mut comparison_base);
3376 let mut changed =
3377 bamboo_config::changed_facade_sections(&comparison_base, &non_provider_candidate)
3378 .map_err(|_| {
3379 AppError::InternalError(anyhow::anyhow!(
3380 "failed to compare modular configuration sections"
3381 ))
3382 })?;
3383 if serde_json::to_value(cfg.subagents()).ok()
3384 == serde_json::to_value(candidate.subagents()).ok()
3385 {
3386 changed.retain(|section| *section != SectionId::Subagents);
3387 }
3388 if let Some(other) = changed
3389 .into_iter()
3390 .find(|section| *section != SectionId::Providers)
3391 {
3392 return Err(AppError::BadRequest(format!(
3393 "provider credential updates cannot be combined with {} changes; split the request",
3394 other.descriptor().name
3395 )));
3396 }
3397 if config_facade.is_none() {
3398 candidate.assign_connect_platform_ids();
3399 candidate.refresh_encrypted_secrets().map_err(|error| {
3400 AppError::InternalError(anyhow::anyhow!(
3401 "Failed to refresh encrypted secrets: {error}"
3402 ))
3403 })?;
3404 }
3405 let newly_off = !was_off && candidate.plugin_trust.enforcement_is_off();
3406 (candidate, live_base, newly_off)
3407 };
3408 let config = self.config.clone();
3409 let app_data_dir = self.app_data_dir.clone();
3410 let account_sink = self.account_sink.clone();
3411 let provider_registry = self.provider_registry.clone();
3412 let provider = self.provider.clone();
3413 let mcp_manager = self.mcp_manager.clone();
3414 let transaction = tokio::spawn(async move {
3415 let snapshot = {
3416 let _io = io;
3417 let data_dir = app_data_dir.clone();
3418 let commit_facade = config_facade.clone();
3419 let (candidate, commit) = tokio::task::spawn_blocking(move || {
3420 let result = if let Some(facade) = commit_facade {
3421 let commit =
3422 bamboo_config::persist_provider_instance_credential_transaction_with_adoption(
3423 &data_dir,
3424 &mut candidate,
3425 &provider_intents,
3426 &provider_instance_intents,
3427 facade.as_ref(),
3428 )?;
3429 Ok::<_, ConfigStoreError>((candidate, Some(commit)))
3430 } else {
3431 bamboo_config::persist_provider_instance_credential_transaction(
3432 &data_dir,
3433 &mut candidate,
3434 &provider_intents,
3435 &provider_instance_intents,
3436 )?;
3437 Ok((load_committed_effective_config(&data_dir)?, None))
3438 };
3439 #[cfg(test)]
3440 run_generic_before_event_test_hook(&data_dir);
3441 result
3442 })
3443 .await
3444 .map_err(|error| {
3445 AppError::InternalError(anyhow::anyhow!(
3446 "provider credential transaction task failed: {error}"
3447 ))
3448 })?
3449 .map_err(|error| match error {
3450 ConfigStoreError::Conflict { expected, actual } => {
3451 AppError::ConfigConflict { expected, actual }
3452 }
3453 ConfigStoreError::Validation(message) => AppError::BadRequest(message),
3454 ConfigStoreError::CommitIndeterminate(message) => AppError::InternalError(
3455 anyhow::anyhow!("configuration commit outcome is indeterminate: {message}"),
3456 ),
3457 ConfigStoreError::Io(error) => AppError::StorageError(error),
3458 ConfigStoreError::Json(_) => {
3459 AppError::BadRequest("configuration document is invalid".to_string())
3460 }
3461 ConfigStoreError::Watch(error) => AppError::InternalError(anyhow::anyhow!(
3462 "configuration watch failed: {error}"
3463 )),
3464 })?;
3465 let (snapshot, events) = match commit {
3466 Some(commit) => {
3467 let mut published = live_base;
3468 let installed = install_credential_section_commit(commit, &mut published)
3469 .map_err(|error| {
3470 AppError::InternalError(anyhow::anyhow!(
3471 "provider process adoption failed: {error}"
3472 ))
3473 })?;
3474 (published, installed.events)
3475 }
3476 None => (candidate, Vec::new()),
3477 };
3478 {
3479 let mut cfg = config.write().await;
3480 snapshot.publish_env_vars();
3481 *cfg = snapshot.clone();
3482 }
3483 publish_exact_facade_events(&account_sink, &events)?;
3484 if enforcement_newly_off {
3485 warn_plugin_trust_enforcement_off();
3486 }
3487 Self::apply_config_effects_owned(
3488 snapshot.clone(),
3489 effects,
3490 app_data_dir,
3491 config,
3492 provider_registry,
3493 provider,
3494 mcp_manager,
3495 )
3496 .await?;
3497 snapshot
3498 };
3499 Ok::<_, AppError>(snapshot)
3500 });
3501 transaction.await.map_err(|error| {
3502 AppError::InternalError(anyhow::anyhow!(
3503 "provider config transaction task failed: {error}"
3504 ))
3505 })?
3506 }
3507
3508 pub async fn update_env_var_credentials<F>(
3512 &self,
3513 expected_revision: u64,
3514 mut env_intents: std::collections::BTreeSet<String>,
3515 full_replace: bool,
3516 update: F,
3517 ) -> Result<
3518 (
3519 Config,
3520 u64,
3521 bamboo_config::CredentialSectionRuntimeMetadata,
3522 Option<bamboo_config::SectionEnvelope<Value>>,
3523 ),
3524 AppError,
3525 >
3526 where
3527 F: FnOnce(&mut Config) -> Result<(), AppError> + Send + 'static,
3528 {
3529 let config_io_lock = self.config_io_lock.clone();
3530 let config = self.config.clone();
3531 let app_data_dir = self.app_data_dir.clone();
3532 let account_sink = self.account_sink.clone();
3533 let config_facade = self.config_facade.clone();
3534 let transaction = tokio::spawn(async move {
3535 let _io = config_io_lock.lock().await;
3536 let live_base = {
3537 let current = config.read().await;
3538 reject_if_recovery_pending(¤t)?;
3539 current.clone()
3540 };
3541 let mut candidate = live_base.clone();
3542 if config_facade.is_some() {
3543 install_exact_credential_section_mutation_base(
3544 app_data_dir.clone(),
3545 SectionId::Env,
3546 expected_revision,
3547 &mut candidate,
3548 )
3549 .await?;
3550 }
3551 if full_replace {
3552 env_intents.extend(candidate.env_vars.iter().map(|entry| entry.name.clone()));
3553 }
3554 update(&mut candidate)?;
3555 if config_facade.is_none() {
3556 candidate.assign_connect_platform_ids();
3557 }
3558 let transaction_dir = app_data_dir.clone();
3559 let commit_facade = config_facade.clone();
3560 let (candidate, revision, commit) = tokio::task::spawn_blocking(move || {
3561 if let Some(facade) = commit_facade {
3562 let commit =
3563 bamboo_config::persist_env_var_credential_transaction_at_revision_with_adoption(
3564 &transaction_dir,
3565 &mut candidate,
3566 &env_intents,
3567 expected_revision,
3568 facade.as_ref(),
3569 )?;
3570 #[cfg(test)]
3571 run_credential_after_commit_before_live_test_hook(
3572 &transaction_dir,
3573 SectionId::Env,
3574 );
3575 let revision = commit.revision;
3576 Ok::<_, ConfigStoreError>((candidate, revision, Some(commit)))
3577 } else {
3578 let revision =
3579 bamboo_config::persist_env_var_credential_transaction_at_revision(
3580 &transaction_dir,
3581 &mut candidate,
3582 &env_intents,
3583 expected_revision,
3584 )?;
3585 Ok((
3586 load_committed_effective_config(&transaction_dir)?,
3587 revision,
3588 None,
3589 ))
3590 }
3591 })
3592 .await
3593 .map_err(|error| {
3594 AppError::InternalError(anyhow::anyhow!(
3595 "env credential transaction task failed: {error}"
3596 ))
3597 })?
3598 .map_err(|error| match error {
3599 ConfigStoreError::Conflict { expected, actual } => {
3600 AppError::ConfigConflict { expected, actual }
3601 }
3602 ConfigStoreError::Validation(message) => AppError::BadRequest(message),
3603 ConfigStoreError::CommitIndeterminate(message) => AppError::InternalError(
3604 anyhow::anyhow!("configuration commit outcome is indeterminate: {message}"),
3605 ),
3606 ConfigStoreError::Io(error) => AppError::StorageError(error),
3607 ConfigStoreError::Json(_) => {
3608 AppError::BadRequest("configuration document is invalid".to_string())
3609 }
3610 ConfigStoreError::Watch(error) => {
3611 AppError::InternalError(anyhow::anyhow!("configuration watch failed: {error}"))
3612 }
3613 })?;
3614 let (published, installed) = match commit {
3615 Some(commit) => {
3616 let mut published = live_base;
3617 let installed = install_credential_section_commit(commit, &mut published)
3618 .map_err(|error| {
3619 AppError::InternalError(anyhow::anyhow!(
3620 "env process adoption failed: {error}"
3621 ))
3622 })?;
3623 (published, installed)
3624 }
3625 None => (
3626 candidate,
3627 InstalledCredentialSectionCommit {
3628 events: Vec::new(),
3629 metadata: read_credential_runtime_metadata(&app_data_dir).map_err(
3630 |error| {
3631 AppError::InternalError(anyhow::anyhow!(
3632 "env credential status unavailable after commit: {error}"
3633 ))
3634 },
3635 )?,
3636 section: None,
3637 },
3638 ),
3639 };
3640 published.publish_env_vars();
3641 *config.write().await = published.clone();
3642 publish_exact_facade_events(&account_sink, &installed.events)?;
3643 let section = installed.section;
3644 Ok::<_, AppError>((published, revision, installed.metadata, section))
3645 });
3646 transaction.await.map_err(|error| {
3647 AppError::InternalError(anyhow::anyhow!(
3648 "env credential transaction task failed: {error}"
3649 ))
3650 })?
3651 }
3652
3653 pub async fn update_notification_credentials<F>(
3658 &self,
3659 expected_revision: u64,
3660 secret_intents: std::collections::BTreeSet<String>,
3661 reset_domain: bool,
3662 update: F,
3663 ) -> Result<
3664 (
3665 Config,
3666 u64,
3667 bamboo_config::CredentialSectionRuntimeMetadata,
3668 Option<bamboo_config::SectionEnvelope<Value>>,
3669 ),
3670 AppError,
3671 >
3672 where
3673 F: FnOnce(&mut Config) -> Result<(), AppError> + Send + 'static,
3674 {
3675 let config_io_lock = self.config_io_lock.clone();
3676 let config = self.config.clone();
3677 let app_data_dir = self.app_data_dir.clone();
3678 let account_sink = self.account_sink.clone();
3679 let config_facade = self.config_facade.clone();
3680 let transaction = tokio::spawn(async move {
3681 let _io = config_io_lock.lock().await;
3682 let live_base = {
3683 let current = config.read().await;
3684 reject_if_recovery_pending(¤t)?;
3685 current.clone()
3686 };
3687 let mut candidate = live_base.clone();
3688 if config_facade.is_some() {
3689 install_exact_credential_section_mutation_base(
3690 app_data_dir.clone(),
3691 SectionId::Notifications,
3692 expected_revision,
3693 &mut candidate,
3694 )
3695 .await?;
3696 }
3697 update(&mut candidate)?;
3698 let transaction_dir = app_data_dir.clone();
3699 let commit_facade = config_facade.clone();
3700 let (candidate, revision, commit) = tokio::task::spawn_blocking(move || {
3701 if let Some(facade) = commit_facade {
3702 let commit =
3703 bamboo_config::persist_notification_credential_transaction_at_revision_with_reset_and_adoption(
3704 &transaction_dir,
3705 &mut candidate,
3706 &secret_intents,
3707 reset_domain,
3708 expected_revision,
3709 facade.as_ref(),
3710 )?;
3711 let revision = commit.revision;
3712 Ok::<_, ConfigStoreError>((candidate, revision, Some(commit)))
3713 } else {
3714 let revision =
3715 bamboo_config::persist_notification_credential_transaction_at_revision_with_reset(
3716 &transaction_dir,
3717 &mut candidate,
3718 &secret_intents,
3719 reset_domain,
3720 expected_revision,
3721 )?;
3722 Ok((
3723 load_committed_effective_config(&transaction_dir)?,
3724 revision,
3725 None,
3726 ))
3727 }
3728 })
3729 .await
3730 .map_err(|error| {
3731 AppError::InternalError(anyhow::anyhow!(
3732 "notification credential transaction task failed: {error}"
3733 ))
3734 })?
3735 .map_err(|error| match error {
3736 ConfigStoreError::Conflict { expected, actual } => {
3737 AppError::ConfigConflict { expected, actual }
3738 }
3739 ConfigStoreError::Validation(message) => AppError::BadRequest(message),
3740 ConfigStoreError::CommitIndeterminate(message) => {
3741 AppError::InternalError(anyhow::anyhow!(
3742 "configuration commit outcome is indeterminate: {message}"
3743 ))
3744 }
3745 ConfigStoreError::Io(error) => AppError::StorageError(error),
3746 ConfigStoreError::Json(_) => {
3747 AppError::BadRequest("configuration document is invalid".to_string())
3748 }
3749 ConfigStoreError::Watch(error) => {
3750 AppError::InternalError(anyhow::anyhow!("configuration watch failed: {error}"))
3751 }
3752 })?;
3753 let (published, installed) = match commit {
3754 Some(commit) => {
3755 let mut published = live_base;
3756 let installed = install_credential_section_commit(commit, &mut published)
3757 .map_err(|error| {
3758 AppError::InternalError(anyhow::anyhow!(
3759 "notification process adoption failed: {error}"
3760 ))
3761 })?;
3762 (published, installed)
3763 }
3764 None => (
3765 candidate,
3766 InstalledCredentialSectionCommit {
3767 events: Vec::new(),
3768 metadata: read_credential_runtime_metadata(&app_data_dir).map_err(
3769 |error| {
3770 AppError::InternalError(anyhow::anyhow!(
3771 "notification credential status unavailable after commit: {error}"
3772 ))
3773 },
3774 )?,
3775 section: None,
3776 },
3777 ),
3778 };
3779 *config.write().await = published.clone();
3780 publish_exact_facade_events(&account_sink, &installed.events)?;
3781 let section = installed.section;
3782 Ok::<_, AppError>((published, revision, installed.metadata, section))
3783 });
3784 transaction.await.map_err(|error| {
3785 AppError::InternalError(anyhow::anyhow!(
3786 "notification credential transaction task failed: {error}"
3787 ))
3788 })?
3789 }
3790
3791 pub async fn update_connect_credentials<F>(
3796 &self,
3797 expected_revision: u64,
3798 secret_intents: bamboo_config::patch::ConnectSecretIntents,
3799 update: F,
3800 ) -> Result<
3801 (
3802 Config,
3803 u64,
3804 bamboo_config::CredentialSectionRuntimeMetadata,
3805 Option<bamboo_config::SectionEnvelope<Value>>,
3806 ),
3807 AppError,
3808 >
3809 where
3810 F: FnOnce(&mut Config) -> Result<(), AppError> + Send + 'static,
3811 {
3812 let config_io_lock = self.config_io_lock.clone();
3813 let config = self.config.clone();
3814 let app_data_dir = self.app_data_dir.clone();
3815 let account_sink = self.account_sink.clone();
3816 let config_facade = self.config_facade.clone();
3817 let transaction = tokio::spawn(async move {
3818 let _io = config_io_lock.lock().await;
3819 let live_base = {
3820 let current = config.read().await;
3821 reject_if_recovery_pending(¤t)?;
3822 current.clone()
3823 };
3824 let mut candidate = live_base.clone();
3825 if config_facade.is_some() {
3826 install_exact_credential_section_mutation_base(
3827 app_data_dir.clone(),
3828 SectionId::Connect,
3829 expected_revision,
3830 &mut candidate,
3831 )
3832 .await?;
3833 }
3834 update(&mut candidate)?;
3835 candidate.assign_connect_platform_ids();
3836 let transaction_dir = app_data_dir.clone();
3837 let commit_facade = config_facade.clone();
3838 let (candidate, revision, commit) = tokio::task::spawn_blocking(move || {
3839 if let Some(facade) = commit_facade {
3840 let commit =
3841 bamboo_config::persist_connect_credential_transaction_at_revision_with_adoption(
3842 &transaction_dir,
3843 &mut candidate,
3844 &secret_intents,
3845 expected_revision,
3846 facade.as_ref(),
3847 )?;
3848 let revision = commit.revision;
3849 Ok::<_, ConfigStoreError>((candidate, revision, Some(commit)))
3850 } else {
3851 let revision =
3852 bamboo_config::persist_connect_credential_transaction_at_revision(
3853 &transaction_dir,
3854 &mut candidate,
3855 &secret_intents,
3856 expected_revision,
3857 )?;
3858 Ok((
3859 load_committed_effective_config(&transaction_dir)?,
3860 revision,
3861 None,
3862 ))
3863 }
3864 })
3865 .await
3866 .map_err(|error| {
3867 AppError::InternalError(anyhow::anyhow!(
3868 "connect credential transaction task failed: {error}"
3869 ))
3870 })?
3871 .map_err(|error| match error {
3872 ConfigStoreError::Conflict { expected, actual } => {
3873 AppError::ConfigConflict { expected, actual }
3874 }
3875 ConfigStoreError::Validation(message) => AppError::BadRequest(message),
3876 ConfigStoreError::CommitIndeterminate(message) => AppError::InternalError(
3877 anyhow::anyhow!("configuration commit outcome is indeterminate: {message}"),
3878 ),
3879 ConfigStoreError::Io(error) => AppError::StorageError(error),
3880 ConfigStoreError::Json(_) => {
3881 AppError::BadRequest("configuration document is invalid".to_string())
3882 }
3883 ConfigStoreError::Watch(error) => {
3884 AppError::InternalError(anyhow::anyhow!("configuration watch failed: {error}"))
3885 }
3886 })?;
3887 let (published, installed) = match commit {
3888 Some(commit) => {
3889 let mut published = live_base;
3890 let installed = install_credential_section_commit(commit, &mut published)
3891 .map_err(|error| {
3892 AppError::InternalError(anyhow::anyhow!(
3893 "connect process adoption failed: {error}"
3894 ))
3895 })?;
3896 (published, installed)
3897 }
3898 None => (
3899 candidate,
3900 InstalledCredentialSectionCommit {
3901 events: Vec::new(),
3902 metadata: read_credential_runtime_metadata(&app_data_dir).map_err(
3903 |error| {
3904 AppError::InternalError(anyhow::anyhow!(
3905 "connect credential status unavailable after commit: {error}"
3906 ))
3907 },
3908 )?,
3909 section: None,
3910 },
3911 ),
3912 };
3913 *config.write().await = published.clone();
3914 publish_exact_facade_events(&account_sink, &installed.events)?;
3915 let section = installed.section;
3916 Ok::<_, AppError>((published, revision, installed.metadata, section))
3917 });
3918 transaction.await.map_err(|error| {
3919 AppError::InternalError(anyhow::anyhow!(
3920 "connect credential transaction task failed: {error}"
3921 ))
3922 })?
3923 }
3924
3925 pub async fn update_access_control_credentials<F>(
3928 &self,
3929 expected_revision: u64,
3930 password_intent: bool,
3931 device_intents: std::collections::BTreeSet<String>,
3932 update: F,
3933 ) -> Result<
3934 (
3935 Config,
3936 u64,
3937 bamboo_config::CredentialSectionRuntimeMetadata,
3938 Option<bamboo_config::SectionEnvelope<Value>>,
3939 ),
3940 AppError,
3941 >
3942 where
3943 F: FnOnce(&mut Config) -> Result<(), AppError> + Send + 'static,
3944 {
3945 let config_io_lock = self.config_io_lock.clone();
3946 let config = self.config.clone();
3947 let app_data_dir = self.app_data_dir.clone();
3948 let account_sink = self.account_sink.clone();
3949 let config_facade = self.config_facade.clone();
3950 let transaction = tokio::spawn(async move {
3951 let _io = config_io_lock.lock().await;
3952 let live_base = {
3953 let current = config.read().await;
3954 reject_if_recovery_pending(¤t)?;
3955 current.clone()
3956 };
3957 let mut candidate = live_base.clone();
3958 if config_facade.is_some() {
3959 install_exact_credential_section_mutation_base(
3960 app_data_dir.clone(),
3961 SectionId::AccessControl,
3962 expected_revision,
3963 &mut candidate,
3964 )
3965 .await?;
3966 }
3967 update(&mut candidate)?;
3968 let transaction_dir = app_data_dir.clone();
3969 let commit_facade = config_facade.clone();
3970 let (candidate, revision, commit) = tokio::task::spawn_blocking(move || {
3971 if let Some(facade) = commit_facade {
3972 let commit =
3973 bamboo_config::persist_access_control_credential_transaction_at_revision_with_adoption(
3974 &transaction_dir,
3975 &mut candidate,
3976 password_intent,
3977 &device_intents,
3978 expected_revision,
3979 facade.as_ref(),
3980 )?;
3981 let revision = commit.revision;
3982 Ok::<_, ConfigStoreError>((candidate, revision, Some(commit)))
3983 } else {
3984 let revision =
3985 bamboo_config::persist_access_control_credential_transaction_at_revision(
3986 &transaction_dir,
3987 &mut candidate,
3988 password_intent,
3989 &device_intents,
3990 expected_revision,
3991 )?;
3992 Ok((
3993 load_committed_effective_config(&transaction_dir)?,
3994 revision,
3995 None,
3996 ))
3997 }
3998 })
3999 .await
4000 .map_err(|error| {
4001 AppError::InternalError(anyhow::anyhow!(
4002 "access-control credential transaction task failed: {error}"
4003 ))
4004 })?
4005 .map_err(|error| match error {
4006 ConfigStoreError::Conflict { expected, actual } => {
4007 AppError::ConfigConflict { expected, actual }
4008 }
4009 ConfigStoreError::Validation(message) => AppError::BadRequest(message),
4010 ConfigStoreError::CommitIndeterminate(message) => AppError::InternalError(
4011 anyhow::anyhow!("configuration commit outcome is indeterminate: {message}"),
4012 ),
4013 ConfigStoreError::Io(error) => AppError::StorageError(error),
4014 ConfigStoreError::Json(_) => {
4015 AppError::BadRequest("configuration document is invalid".to_string())
4016 }
4017 ConfigStoreError::Watch(error) => {
4018 AppError::InternalError(anyhow::anyhow!("configuration watch failed: {error}"))
4019 }
4020 })?;
4021 let (published, installed) = match commit {
4022 Some(commit) => {
4023 let mut published = live_base;
4024 let installed = install_credential_section_commit(commit, &mut published)
4025 .map_err(|error| {
4026 AppError::InternalError(anyhow::anyhow!(
4027 "access-control process adoption failed: {error}"
4028 ))
4029 })?;
4030 (published, installed)
4031 }
4032 None => (
4033 candidate,
4034 InstalledCredentialSectionCommit {
4035 events: Vec::new(),
4036 metadata: read_credential_runtime_metadata(&app_data_dir).map_err(
4037 |error| {
4038 AppError::InternalError(anyhow::anyhow!(
4039 "access-control credential status unavailable after commit: {error}"
4040 ))
4041 },
4042 )?,
4043 section: None,
4044 },
4045 ),
4046 };
4047 *config.write().await = published.clone();
4048 publish_exact_facade_events(&account_sink, &installed.events)?;
4049 let section = installed.section;
4050 Ok::<_, AppError>((published, revision, installed.metadata, section))
4051 });
4052 transaction.await.map_err(|error| {
4053 AppError::InternalError(anyhow::anyhow!(
4054 "access-control credential transaction task failed: {error}"
4055 ))
4056 })?
4057 }
4058
4059 pub async fn update_cluster_fabric_credentials<F>(
4064 &self,
4065 expected_revision: u64,
4066 node_intents: std::collections::BTreeMap<
4067 String,
4068 bamboo_config::ClusterNodeCredentialIntents,
4069 >,
4070 update: F,
4071 ) -> Result<bamboo_server_tools::FabricCommitSnapshot, AppError>
4072 where
4073 F: FnOnce(&mut Config) -> Result<(), AppError> + Send + 'static,
4074 {
4075 self.update_cluster_fabric_credentials_guarded(
4076 expected_revision,
4077 node_intents,
4078 None,
4079 update,
4080 )
4081 .await
4082 }
4083
4084 pub(crate) async fn delete_cluster_node_credentials<F>(
4089 &self,
4090 expected_revision: u64,
4091 node_id: String,
4092 node_intents: std::collections::BTreeMap<
4093 String,
4094 bamboo_config::ClusterNodeCredentialIntents,
4095 >,
4096 update: F,
4097 ) -> Result<bamboo_server_tools::FabricCommitSnapshot, AppError>
4098 where
4099 F: FnOnce(&mut Config) -> Result<(), AppError> + Send + 'static,
4100 {
4101 self.update_cluster_fabric_credentials_guarded(
4102 expected_revision,
4103 node_intents,
4104 Some(node_id),
4105 update,
4106 )
4107 .await
4108 }
4109
4110 async fn update_cluster_fabric_credentials_guarded<F>(
4111 &self,
4112 expected_revision: u64,
4113 node_intents: std::collections::BTreeMap<
4114 String,
4115 bamboo_config::ClusterNodeCredentialIntents,
4116 >,
4117 required_stopped_node: Option<String>,
4118 update: F,
4119 ) -> Result<bamboo_server_tools::FabricCommitSnapshot, AppError>
4120 where
4121 F: FnOnce(&mut Config) -> Result<(), AppError> + Send + 'static,
4122 {
4123 let config_io_lock = self.config_io_lock.clone();
4124 let config = self.config.clone();
4125 let app_data_dir = self.app_data_dir.clone();
4126 let account_sink = self.account_sink.clone();
4127 let config_facade = self.config_facade.clone();
4128 let deployed_registry = self.fabric_deployer.registry();
4129 let transaction = tokio::spawn(async move {
4130 let _io = config_io_lock.lock().await;
4131 if let Some(node_id) = required_stopped_node.as_deref() {
4132 let deployed = deployed_registry.lock().await;
4133 if deployed.contains_key(&bamboo_server_tools::registry_keys::node_key(node_id)) {
4134 return Err(AppError::BadRequest(format!(
4135 "node '{node_id}' is deployed; stop it before deleting it"
4136 )));
4137 }
4138 }
4139 let facade = config_facade.as_ref().ok_or_else(|| {
4140 AppError::BadRequest(
4141 "cluster mutations require the modular configuration facade".to_string(),
4142 )
4143 })?;
4144 let mut candidate = {
4145 let current = config.read().await;
4146 reject_if_recovery_pending(¤t)?;
4147 current.clone()
4148 };
4149 let snapshot_dir = app_data_dir.clone();
4162 let exact = tokio::task::spawn_blocking(move || {
4163 bamboo_config::read_exact_cluster_fabric_snapshot(&snapshot_dir, None)
4164 })
4165 .await
4166 .map_err(|error| {
4167 AppError::InternalError(anyhow::anyhow!("cluster snapshot task failed: {error}"))
4168 })?
4169 .map_err(|error| match error {
4170 ConfigStoreError::Conflict { expected, actual } => {
4171 AppError::ConfigConflict { expected, actual }
4172 }
4173 ConfigStoreError::Validation(message) => AppError::BadRequest(message),
4174 ConfigStoreError::CommitIndeterminate(message) => AppError::InternalError(
4175 anyhow::anyhow!("configuration commit outcome is indeterminate: {message}"),
4176 ),
4177 ConfigStoreError::Io(error) => AppError::StorageError(error),
4178 ConfigStoreError::Json(_) => {
4179 AppError::BadRequest("configuration document is invalid".to_string())
4180 }
4181 ConfigStoreError::Watch(error) => {
4182 AppError::InternalError(anyhow::anyhow!("configuration watch failed: {error}"))
4183 }
4184 })?;
4185 if exact.section.revision != expected_revision {
4186 return Err(AppError::ConfigConflict {
4187 expected: expected_revision,
4188 actual: exact.section.revision,
4189 });
4190 }
4191 if exact.section.status != SectionStatus::Healthy
4192 || exact.section.source_kind != SectionSourceKind::File
4193 || exact.credential_health.status == SectionStatus::Degraded
4194 {
4195 return Err(AppError::BadRequest(
4196 "revision-bound cluster mutations require healthy primary authorities"
4197 .to_string(),
4198 ));
4199 }
4200 candidate.cluster_fabric = exact.cluster_fabric;
4201 update(&mut candidate)?;
4202 let transaction_dir = app_data_dir.clone();
4203 let commit_facade = facade.clone();
4204 let (mut candidate, commit) = tokio::task::spawn_blocking(move || {
4205 let commit =
4206 bamboo_config::persist_cluster_fabric_credential_transaction_with_adoption(
4207 &transaction_dir,
4208 &mut candidate,
4209 &node_intents,
4210 expected_revision,
4211 commit_facade.as_ref(),
4212 |_, _| {
4213 #[cfg(test)]
4214 run_cluster_after_commit_before_adoption_test_hook(
4215 &transaction_dir,
4216 expected_revision,
4217 );
4218 },
4219 )?;
4220 Ok::<_, ConfigStoreError>((candidate, commit))
4221 })
4222 .await
4223 .map_err(|error| {
4224 AppError::InternalError(anyhow::anyhow!(
4225 "cluster credential transaction task failed: {error}"
4226 ))
4227 })?
4228 .map_err(|error| match error {
4229 ConfigStoreError::Conflict { expected, actual } => {
4230 AppError::ConfigConflict { expected, actual }
4231 }
4232 ConfigStoreError::Validation(message) => AppError::BadRequest(message),
4233 ConfigStoreError::CommitIndeterminate(message) => AppError::InternalError(
4234 anyhow::anyhow!("configuration commit outcome is indeterminate: {message}"),
4235 ),
4236 ConfigStoreError::Io(error) => AppError::StorageError(error),
4237 ConfigStoreError::Json(_) => {
4238 AppError::BadRequest("configuration document is invalid".to_string())
4239 }
4240 ConfigStoreError::Watch(error) => {
4241 AppError::InternalError(anyhow::anyhow!("configuration watch failed: {error}"))
4242 }
4243 })?;
4244 let bamboo_config::ClusterFabricTransactionCommit {
4245 revision,
4246 adoption,
4247 credential_adoption,
4248 committed_recovery,
4249 runtime,
4250 } = commit;
4251 let runtime = match runtime {
4252 Ok(bamboo_config::ClusterFabricRuntimeSnapshot {
4253 cluster_fabric,
4254 credential_statuses,
4255 credential_health,
4256 }) => {
4257 candidate.cluster_fabric = cluster_fabric;
4258 Ok((credential_statuses, credential_health))
4259 }
4260 Err(error) if revision == expected_revision => {
4261 return Err(AppError::InternalError(anyhow::anyhow!(
4262 "cluster configuration at revision {revision} could not materialize its exact runtime credentials: {error}"
4263 )));
4264 }
4265 Err(error) => {
4266 candidate.clear_cluster_runtime_credentials();
4267 Err(error)
4268 }
4269 };
4270 *config.write().await = candidate.clone();
4271 let event = match adoption {
4272 Some(Ok(event)) => Some(event),
4273 Some(Err(error)) => {
4274 return Err(AppError::InternalError(anyhow::anyhow!(
4275 "cluster configuration committed at revision {} but process adoption failed: {error}",
4276 revision
4277 )));
4278 }
4279 None if revision == expected_revision => None,
4280 None => {
4281 return Err(AppError::InternalError(anyhow::anyhow!(
4282 "cluster configuration committed at revision {} without a process adoption result",
4283 revision
4284 )));
4285 }
4286 };
4287 let section = facade
4288 .registry()
4289 .envelope_value(SectionId::ClusterFabric)
4290 .map_err(|error| {
4291 AppError::InternalError(anyhow::anyhow!(
4292 "committed cluster section envelope is unavailable: {error}"
4293 ))
4294 })?;
4295 if section.revision != revision {
4296 return Err(AppError::InternalError(anyhow::anyhow!(
4297 "cluster configuration committed at revision {} but facade retained revision {}",
4298 revision,
4299 section.revision
4300 )));
4301 }
4302 if let Some(event) = event.as_ref() {
4303 publish_registry_event(&account_sink, event);
4304 }
4305 if let Err(error) = committed_recovery {
4306 return Err(AppError::InternalError(anyhow::anyhow!(
4307 "cluster configuration committed at revision {revision} but transaction recovery failed: {error}"
4308 )));
4309 }
4310 if let Some(Err(error)) = credential_adoption {
4311 return Err(AppError::InternalError(anyhow::anyhow!(
4312 "cluster configuration committed at revision {revision} but credential facade adoption failed: {error}"
4313 )));
4314 }
4315 let (credential_statuses, credential_health) = runtime.map_err(|error| {
4316 AppError::InternalError(anyhow::anyhow!(
4317 "cluster configuration committed at revision {revision} but could not materialize its exact runtime credentials: {error}"
4318 ))
4319 })?;
4320 Ok::<_, AppError>(bamboo_server_tools::FabricCommitSnapshot {
4321 config: candidate,
4322 section,
4323 credential_statuses,
4324 credential_health,
4325 })
4326 });
4327 transaction.await.map_err(|error| {
4328 AppError::InternalError(anyhow::anyhow!(
4329 "cluster credential transaction task failed: {error}"
4330 ))
4331 })?
4332 }
4333
4334 pub async fn update_proxy_auth_credential(
4338 &self,
4339 auth: Option<bamboo_config::ProxyAuth>,
4340 expected_revision: u64,
4341 effects: ConfigUpdateEffects,
4342 ) -> Result<
4343 (
4344 Config,
4345 u64,
4346 bamboo_config::CredentialStatus,
4347 bamboo_config::CredentialStoreHealth,
4348 Option<bamboo_config::SectionEnvelope<Value>>,
4349 ),
4350 AppError,
4351 > {
4352 self.update_core_with_proxy_credential(expected_revision, effects, move |candidate| {
4353 candidate.proxy_auth = auth;
4354 })
4355 .await
4356 }
4357
4358 async fn update_core_with_proxy_credential<F>(
4359 &self,
4360 expected_revision: u64,
4361 effects: ConfigUpdateEffects,
4362 update: F,
4363 ) -> Result<
4364 (
4365 Config,
4366 u64,
4367 bamboo_config::CredentialStatus,
4368 bamboo_config::CredentialStoreHealth,
4369 Option<bamboo_config::SectionEnvelope<Value>>,
4370 ),
4371 AppError,
4372 >
4373 where
4374 F: FnOnce(&mut Config) + Send + 'static,
4375 {
4376 let config_io_lock = self.config_io_lock.clone();
4377 let config = self.config.clone();
4378 let app_data_dir = self.app_data_dir.clone();
4379 let credential_store = self.credential_store.clone();
4380 let provider_registry = self.provider_registry.clone();
4381 let provider = self.provider.clone();
4382 let mcp_manager = self.mcp_manager.clone();
4383 let config_facade = self.config_facade.clone();
4384 let account_sink = self.account_sink.clone();
4385
4386 let transaction = tokio::spawn(async move {
4391 let _io = config_io_lock.lock().await;
4392 let live_base = {
4393 let cfg = config.read().await;
4394 reject_if_recovery_pending(&cfg)?;
4395 cfg.clone()
4396 };
4397 let mut candidate = live_base.clone();
4398 if config_facade.is_some() {
4399 install_exact_credential_section_mutation_base(
4400 app_data_dir.clone(),
4401 SectionId::Core,
4402 expected_revision,
4403 &mut candidate,
4404 )
4405 .await?;
4406 }
4407 update(&mut candidate);
4408 if config_facade.is_none() {
4409 candidate.assign_connect_platform_ids();
4410 candidate.refresh_encrypted_secrets().map_err(|error| {
4411 AppError::InternalError(anyhow::anyhow!(
4412 "Failed to refresh encrypted secrets: {error}"
4413 ))
4414 })?;
4415 }
4416 let transaction_dir = app_data_dir.clone();
4417 let status_reference =
4418 candidate
4419 .proxy_auth_credential_ref
4420 .clone()
4421 .unwrap_or_else(|| {
4422 bamboo_config::CredentialRef::parse("proxy.default.auth")
4423 .expect("canonical proxy credential reference is valid")
4424 });
4425 let commit_facade = config_facade.clone();
4426 let (candidate, revision, reference, commit) =
4427 tokio::task::spawn_blocking(move || {
4428 if let Some(facade) = commit_facade {
4429 let commit =
4430 bamboo_config::persist_proxy_auth_credential_transaction_at_revision_with_adoption(
4431 &transaction_dir,
4432 &mut candidate,
4433 expected_revision,
4434 facade.as_ref(),
4435 )?;
4436 let revision = commit.revision;
4437 Ok::<_, ConfigStoreError>((
4438 candidate,
4439 revision,
4440 status_reference,
4441 Some(commit),
4442 ))
4443 } else {
4444 let revision =
4445 bamboo_config::persist_proxy_auth_credential_transaction_at_revision(
4446 &transaction_dir,
4447 &mut candidate,
4448 expected_revision,
4449 )?;
4450 Ok((
4451 load_committed_effective_config(&transaction_dir)?,
4452 revision,
4453 status_reference,
4454 None,
4455 ))
4456 }
4457 })
4458 .await
4459 .map_err(|error| {
4460 AppError::InternalError(anyhow::anyhow!(
4461 "proxy credential transaction task failed: {error}"
4462 ))
4463 })?
4464 .map_err(|error| match error {
4465 ConfigStoreError::Conflict { expected, actual } => {
4466 AppError::ConfigConflict { expected, actual }
4467 }
4468 ConfigStoreError::Validation(message) => AppError::BadRequest(message),
4469 ConfigStoreError::CommitIndeterminate(message) => AppError::InternalError(
4470 anyhow::anyhow!("configuration commit outcome is indeterminate: {message}"),
4471 ),
4472 ConfigStoreError::Io(error) => AppError::StorageError(error),
4473 ConfigStoreError::Json(_) => {
4474 AppError::BadRequest("configuration document is invalid".to_string())
4475 }
4476 ConfigStoreError::Watch(error) => {
4477 AppError::InternalError(anyhow::anyhow!("configuration watch failed: {error}"))
4478 }
4479 })?;
4480 let (published, installed) = match commit {
4481 Some(commit) => {
4482 let mut published = live_base;
4483 let installed = install_credential_section_commit(commit, &mut published)
4484 .map_err(|error| {
4485 AppError::InternalError(anyhow::anyhow!(
4486 "proxy process adoption failed: {error}"
4487 ))
4488 })?;
4489 (published, Some(installed))
4490 }
4491 None => (candidate, None),
4492 };
4493 let section = installed
4494 .as_ref()
4495 .and_then(|installed| installed.section.clone());
4496
4497 published.publish_env_vars();
4501 *config.write().await = published.clone();
4502
4503 if let Some(installed) = installed.as_ref() {
4504 publish_exact_facade_events(&account_sink, &installed.events)?;
4505 }
4506
4507 if effects.reload_provider {
4508 match bamboo_llm::ProviderRegistry::from_config(&published, app_data_dir.clone())
4509 .await
4510 {
4511 Ok(candidate_registry) => {
4512 if let Some(candidate_provider) = candidate_registry.get_default() {
4513 let mut live_provider = provider.write().await;
4514 provider_registry.replace_with(candidate_registry);
4515 *live_provider = candidate_provider;
4516 } else {
4517 tracing::warn!(
4518 "proxy auth committed but provider reload had no default provider"
4519 );
4520 }
4521 }
4522 Err(error) => {
4523 tracing::warn!(
4524 error = %error,
4525 "proxy auth committed but provider reload failed"
4526 );
4527 }
4528 }
4529 }
4530
4531 if effects.reconcile_mcp {
4532 mcp_manager.reconcile_from_config(&published.mcp).await;
4533 }
4534
4535 let (status, health) = if let Some(installed) = installed {
4536 (
4537 installed.metadata.status(&reference),
4538 installed.metadata.credential_health,
4539 )
4540 } else {
4541 credential_store
4542 .status_with_health(&reference)
4543 .map_err(|error| match error {
4544 ConfigStoreError::Conflict { expected, actual } => {
4545 AppError::ConfigConflict { expected, actual }
4546 }
4547 ConfigStoreError::Validation(_)
4548 | ConfigStoreError::CommitIndeterminate(_)
4549 | ConfigStoreError::Json(_) => AppError::InternalError(anyhow::anyhow!(
4550 "credential store validation failed"
4551 )),
4552 ConfigStoreError::Io(error) => AppError::StorageError(error),
4553 ConfigStoreError::Watch(error) => AppError::InternalError(anyhow::anyhow!(
4554 "configuration watch failed: {error}"
4555 )),
4556 })?
4557 };
4558 Ok::<_, AppError>((published, revision, status, health, section))
4559 });
4560 transaction.await.map_err(|error| {
4561 AppError::InternalError(anyhow::anyhow!(
4562 "proxy credential mutation task failed: {error}"
4563 ))
4564 })?
4565 }
4566
4567 pub async fn replace_config(
4569 &self,
4570 mut new_config: Config,
4571 effects: ConfigUpdateEffects,
4572 ) -> Result<Config, AppError> {
4573 if self.config_facade.is_none() {
4579 new_config.assign_connect_platform_ids();
4580 new_config.refresh_encrypted_secrets().map_err(|e| {
4584 AppError::InternalError(anyhow::anyhow!("Failed to refresh encrypted secrets: {e}"))
4585 })?;
4586 }
4587
4588 let io = self.config_io_lock.clone().lock_owned().await;
4589 restore_authoritative_cluster_fabric(self.config_facade.as_ref(), &mut new_config);
4590 let (was_off, live_base) = {
4591 let cfg = self.config.read().await;
4592 reject_if_recovery_pending(&cfg)?;
4595 (cfg.plugin_trust.enforcement_is_off(), cfg.clone())
4596 };
4597 let config = self.config.clone();
4598 let app_data_dir = self.app_data_dir.clone();
4599 let config_facade = self.config_facade.clone();
4600 let account_sink = self.account_sink.clone();
4601 let provider_registry = self.provider_registry.clone();
4602 let provider = self.provider.clone();
4603 let mcp_manager = self.mcp_manager.clone();
4604 let transaction = tokio::spawn(async move {
4605 let new_config = {
4610 let _io = io;
4611 let commit = Self::persist_config_snapshot(
4612 app_data_dir.clone(),
4613 config_facade.clone(),
4614 new_config.clone(),
4615 )
4616 .await?;
4617 let mut published = if commit.is_some() {
4618 live_base
4619 } else {
4620 new_config
4621 };
4622 let events = match commit {
4623 Some(commit) => {
4624 install_facade_config_commit(commit, &mut published).map_err(|error| {
4625 AppError::InternalError(anyhow::anyhow!(
4626 "failed to install committed configuration section: {error}"
4627 ))
4628 })?
4629 }
4630 None => Vec::new(),
4631 };
4632 let enforcement_newly_off = !was_off && published.plugin_trust.enforcement_is_off();
4633 published.publish_env_vars();
4634 *config.write().await = published.clone();
4635 if enforcement_newly_off {
4638 warn_plugin_trust_enforcement_off();
4639 }
4640 publish_exact_facade_events(&account_sink, &events)?;
4641 Self::apply_config_effects_owned(
4642 published.clone(),
4643 effects,
4644 app_data_dir,
4645 config,
4646 provider_registry,
4647 provider,
4648 mcp_manager,
4649 )
4650 .await?;
4651 published
4652 };
4653 Ok::<_, AppError>(new_config)
4654 });
4655 transaction.await.map_err(|error| {
4656 AppError::InternalError(anyhow::anyhow!(
4657 "config replacement transaction task failed: {error}"
4658 ))
4659 })?
4660 }
4661
4662 async fn apply_config_effects_owned(
4663 new_config: Config,
4664 effects: ConfigUpdateEffects,
4665 app_data_dir: PathBuf,
4666 config: Arc<RwLock<Config>>,
4667 provider_registry: Arc<bamboo_llm::ProviderRegistry>,
4668 provider: Arc<RwLock<Arc<dyn LLMProvider>>>,
4669 mcp_manager: Arc<McpServerManager>,
4670 ) -> Result<(), AppError> {
4671 if effects.reload_provider {
4672 let live_config = config.read().await.clone();
4676 let candidate_registry =
4677 bamboo_llm::ProviderRegistry::from_config(&live_config, app_data_dir.clone())
4678 .await
4679 .map_err(|e| {
4680 AppError::InternalError(anyhow::anyhow!(
4681 "Failed to reload provider after updating config: {e}"
4682 ))
4683 })?;
4684 let default_provider_name = candidate_registry.default_provider_name();
4685 let candidate_provider = candidate_registry.get_default().ok_or_else(|| {
4686 let message = if live_config.has_provider_instances() {
4687 format!(
4688 "Default provider instance '{}' is not available or failed to initialize",
4689 default_provider_name
4690 )
4691 } else {
4692 format!(
4693 "Provider '{}' is not available or failed to initialize",
4694 live_config.provider
4695 )
4696 };
4697 AppError::InternalError(anyhow::anyhow!(
4698 "Failed to reload provider after updating config: {}",
4699 bamboo_llm::LLMError::Auth(message)
4700 ))
4701 })?;
4702 #[cfg(test)]
4703 run_generic_before_provider_publish_test_hook(&app_data_dir);
4704 let mut live_provider = provider.write().await;
4705 provider_registry.replace_with(candidate_registry);
4706 *live_provider = candidate_provider;
4707 tracing::info!(
4708 default_provider = %default_provider_name,
4709 "Provider reloaded successfully"
4710 );
4711 }
4712
4713 if effects.reconcile_mcp {
4714 mcp_manager.reconcile_from_config(&new_config.mcp).await;
4715 }
4716
4717 Ok(())
4718 }
4719
4720 pub async fn confirm_config_recovery(&self, accept: bool) -> Result<Config, AppError> {
4736 let _io = self.config_io_lock.lock().await;
4737
4738 if !accept {
4739 let cfg = self.config.read().await;
4740 return match cfg.recovery_status() {
4741 Some(_) => Ok(cfg.clone()),
4742 None => Err(AppError::BadRequest(
4743 "No pending config-corruption recovery to resolve".to_string(),
4744 )),
4745 };
4746 }
4747
4748 let mut candidate = {
4749 let cfg = self.config.read().await;
4750 match cfg.recovery_status() {
4751 Some(_) => cfg.clone(),
4752 None => {
4753 return Err(AppError::BadRequest(
4754 "No pending config-corruption recovery to resolve".to_string(),
4755 ))
4756 }
4757 }
4758 };
4759
4760 let data_dir = self.app_data_dir.clone();
4761 candidate = tokio::task::spawn_blocking(move || {
4762 candidate
4763 .confirm_recovery_and_save_to_dir(data_dir)
4764 .map(|_| candidate)
4765 })
4766 .await
4767 .map_err(|e| {
4768 AppError::InternalError(anyhow::anyhow!("Config recovery-confirm task failed: {e}"))
4769 })?
4770 .map_err(|e| {
4771 AppError::InternalError(anyhow::anyhow!("Failed to save recovered config: {e}"))
4772 })?;
4773
4774 {
4775 let mut cfg = self.config.write().await;
4776 *cfg = candidate.clone();
4777 cfg.publish_env_vars();
4778 }
4779
4780 Ok(candidate)
4781 }
4782}
4783
4784fn reject_if_recovery_pending(cfg: &Config) -> Result<(), AppError> {
4792 if let Some(status) = cfg.recovery_status() {
4793 if !status.confirmed {
4794 return Err(AppError::ConfigRecoveryPending(format!(
4795 "config.json was recovered from corruption ({:?}) and is awaiting \
4796 confirmation; confirm or reject the recovery (see /bamboo/config/recovery-status \
4797 and /bamboo/config/recovery/confirm) before changing settings",
4798 status.source
4799 )));
4800 }
4801 }
4802 Ok(())
4803}
4804
4805pub(crate) fn warn_plugin_trust_enforcement_off() {
4815 tracing::warn!(
4816 "plugin_trust.enforcement is OFF — plugin installs from ANY URL are accepted \
4817 without host/signature/checksum verification (config.json plugin_trust.enforcement)"
4818 );
4819}
4820
4821#[cfg(test)]
4822mod live_reload_tests {
4823 use super::*;
4824 use bamboo_agent_core::{Message, ToolSchema};
4825 use bamboo_llm::{LLMError, LLMStream};
4826 use bamboo_mcp::{McpServerConfig, ReconnectConfig, StdioConfig};
4827
4828 struct WorkingProvider;
4829
4830 fn stop_config_watcher(state: &mut AppState) {
4831 state.config_watcher.stop.store(true, Ordering::Relaxed);
4832 if let Some(task) = state.config_watcher.apply_task.take() {
4833 task.abort();
4834 }
4835 if let Some(task) = state.config_watcher.watcher_task.take() {
4836 task.join().unwrap();
4837 }
4838 }
4839
4840 async fn insert_registry_worker(state: &AppState, key: String, worker_id: &str) {
4841 #[cfg(unix)]
4842 let child = tokio::process::Command::new("/bin/sleep")
4843 .arg("30")
4844 .spawn()
4845 .unwrap();
4846 #[cfg(windows)]
4847 let child = tokio::process::Command::new("cmd")
4848 .args(["/C", "timeout", "/T", "30", "/NOBREAK"])
4849 .spawn()
4850 .unwrap();
4851 state.fabric_deployer.registry().lock().await.insert(
4852 key,
4853 bamboo_server_tools::Deployed {
4854 env: "test".to_string(),
4855 handle: bamboo_broker::DeployedAgent::from_parts(worker_id, child, None),
4856 },
4857 );
4858 }
4859
4860 fn disabled_mcp_config(id: &str) -> McpConfig {
4861 McpConfig {
4862 version: 1,
4863 servers: vec![McpServerConfig {
4864 id: id.to_string(),
4865 name: None,
4866 enabled: false,
4867 transport: TransportConfig::Stdio(StdioConfig {
4868 command: "unused-disabled-command".to_string(),
4869 args: vec![],
4870 cwd: None,
4871 env: std::collections::HashMap::new(),
4872 env_encrypted: std::collections::HashMap::new(),
4873 env_credential_refs: std::collections::HashMap::new(),
4874 startup_timeout_ms: 100,
4875 }),
4876 request_timeout_ms: 100,
4877 healthcheck_interval_ms: 100,
4878 reconnect: ReconnectConfig::default(),
4879 allowed_tools: vec![],
4880 denied_tools: vec![],
4881 }],
4882 }
4883 }
4884
4885 fn mcp_document_bytes(revision: u64, config: &McpConfig) -> Vec<u8> {
4886 serde_json::to_vec_pretty(&serde_json::json!({
4887 "schema_version": 1,
4888 "revision": revision,
4889 "data": config,
4890 }))
4891 .unwrap()
4892 }
4893
4894 #[test]
4895 fn touched_shared_mcp_refs_stage_replacements_and_preserve_surviving_clears() {
4896 let shared =
4897 bamboo_config::CredentialRef::parse("mcp.shared.env_token".to_string()).unwrap();
4898 let mut current = disabled_mcp_config("first");
4899 let mut second = current.servers[0].clone();
4900 second.id = "second".to_string();
4901 current.servers.push(second);
4902 for server in &mut current.servers {
4903 let TransportConfig::Stdio(stdio) = &mut server.transport else {
4904 unreachable!()
4905 };
4906 stdio
4907 .env
4908 .insert("TOKEN".to_string(), "old-shared-secret".to_string());
4909 stdio
4910 .env_credential_refs
4911 .insert("TOKEN".to_string(), shared.as_str().to_string());
4912 }
4913 let touched = BTreeSet::from([shared]);
4914
4915 let mut replace = current.clone();
4916 for server in &mut replace.servers {
4917 let TransportConfig::Stdio(stdio) = &mut server.transport else {
4918 unreachable!()
4919 };
4920 stdio.env.get_mut("TOKEN").unwrap().clear();
4921 }
4922 let TransportConfig::Stdio(first) = &mut replace.servers[0].transport else {
4923 unreachable!()
4924 };
4925 first
4926 .env
4927 .insert("TOKEN".to_string(), "new-shared-secret".to_string());
4928 materialize_mcp_touched_replacements(&mut replace, &touched).unwrap();
4929 retain_mcp_credentials(¤t, &mut replace, &touched);
4930 for server in &replace.servers {
4931 let TransportConfig::Stdio(stdio) = &server.transport else {
4932 unreachable!()
4933 };
4934 assert_eq!(stdio.env["TOKEN"], "new-shared-secret");
4935 }
4936
4937 let mut clear_one = current.clone();
4938 for server in &mut clear_one.servers {
4939 let TransportConfig::Stdio(stdio) = &mut server.transport else {
4940 unreachable!()
4941 };
4942 stdio.env.get_mut("TOKEN").unwrap().clear();
4943 }
4944 let TransportConfig::Stdio(first) = &mut clear_one.servers[0].transport else {
4945 unreachable!()
4946 };
4947 first.env.remove("TOKEN");
4948 first.env_credential_refs.remove("TOKEN");
4949 materialize_mcp_touched_replacements(&mut clear_one, &touched).unwrap();
4950 retain_mcp_credentials(¤t, &mut clear_one, &touched);
4951 let TransportConfig::Stdio(first) = &clear_one.servers[0].transport else {
4952 unreachable!()
4953 };
4954 assert!(!first.env.contains_key("TOKEN"));
4955 assert!(!first.env_credential_refs.contains_key("TOKEN"));
4956 let TransportConfig::Stdio(second) = &clear_one.servers[1].transport else {
4957 unreachable!()
4958 };
4959 assert_eq!(second.env["TOKEN"], "old-shared-secret");
4960 assert_eq!(second.env_credential_refs["TOKEN"], "mcp.shared.env_token");
4961
4962 let header_ref =
4963 bamboo_config::CredentialRef::parse("mcp.shared.header_token".to_string()).unwrap();
4964 let current_http = McpConfig {
4965 version: 1,
4966 servers: vec![McpServerConfig {
4967 id: "http".to_string(),
4968 name: None,
4969 enabled: false,
4970 transport: TransportConfig::Sse(bamboo_mcp::SseConfig {
4971 url: "https://example.test/sse".to_string(),
4972 headers: vec![bamboo_mcp::HeaderConfig {
4973 name: "Authorization".to_string(),
4974 value: "old-header-secret".to_string(),
4975 value_encrypted: None,
4976 credential_ref: Some(header_ref.as_str().to_string()),
4977 }],
4978 connect_timeout_ms: 100,
4979 }),
4980 request_timeout_ms: 100,
4981 healthcheck_interval_ms: 100,
4982 reconnect: ReconnectConfig::default(),
4983 allowed_tools: vec![],
4984 denied_tools: vec![],
4985 }],
4986 };
4987 let mut delete_all_headers = current_http.clone();
4988 let TransportConfig::Sse(candidate) = &mut delete_all_headers.servers[0].transport else {
4989 unreachable!()
4990 };
4991 candidate.headers.clear();
4992 let touched = BTreeSet::from([header_ref]);
4993 materialize_mcp_touched_replacements(&mut delete_all_headers, &touched).unwrap();
4994 retain_mcp_credentials(¤t_http, &mut delete_all_headers, &touched);
4995 let TransportConfig::Sse(candidate) = &delete_all_headers.servers[0].transport else {
4996 unreachable!()
4997 };
4998 assert!(candidate.headers.is_empty());
4999 }
5000
5001 fn install_unrecoverable_pending_provider_migration(dir: &Path) {
5002 let transaction_id = uuid::Uuid::new_v4().to_string();
5003 std::fs::write(
5004 dir.join("config.json"),
5005 br#"{"providers":{"openai":{"model":"root-lkg"}}}"#,
5006 )
5007 .unwrap();
5008 std::fs::write(
5009 dir.join("providers.json"),
5010 br#"{"schema_version":1,"revision":2,"data":{"openai":{"model":"partial-must-not-load","credential_ref":"provider.openai.api_key"}}}"#,
5011 )
5012 .unwrap();
5013 std::fs::write(
5014 dir.join("config-credential-migration.json"),
5015 serde_json::to_vec_pretty(&serde_json::json!({
5016 "version": 1,
5017 "transaction_id": transaction_id.clone(),
5018 "stage_dir": format!(".config-credential-stage-v1-{transaction_id}"),
5019 "state": "pending",
5020 "files": [
5021 {
5022 "name": "credentials.json",
5023 "staged_name": "credentials.json",
5024 "sha256": "0".repeat(64),
5025 "sensitive": true
5026 },
5027 {
5028 "name": "providers.json",
5029 "staged_name": "providers.json",
5030 "sha256": "1".repeat(64),
5031 "original_sha256": "2".repeat(64),
5032 "migration_generation": 2,
5033 "sensitive": false
5034 }
5035 ]
5036 }))
5037 .unwrap(),
5038 )
5039 .unwrap();
5040 }
5041
5042 async fn wait_for_mcp_health(
5043 state: &AppState,
5044 status: SectionStatus,
5045 minimum_revision: u64,
5046 ) -> ConfigLiveHealth {
5047 match tokio::time::timeout(Duration::from_secs(4), async {
5048 loop {
5049 let health = state
5050 .mcp_config_live_health
5051 .read()
5052 .unwrap_or_else(|poisoned| poisoned.into_inner())
5053 .clone();
5054 if health.status == status && health.revision >= minimum_revision {
5055 break health;
5056 }
5057 tokio::time::sleep(Duration::from_millis(20)).await;
5058 }
5059 })
5060 .await
5061 {
5062 Ok(health) => health,
5063 Err(_) => panic!(
5064 "MCP health transition timed out: {:?}",
5065 state
5066 .mcp_config_live_health
5067 .read()
5068 .unwrap_or_else(|poisoned| poisoned.into_inner())
5069 .clone()
5070 ),
5071 }
5072 }
5073
5074 async fn next_config_event(
5075 feed: &mut tokio::sync::broadcast::Receiver<Arc<bamboo_engine::events::ChangeEvent>>,
5076 expected_section: &str,
5077 ) -> AgentEvent {
5078 tokio::time::timeout(Duration::from_secs(3), async {
5079 loop {
5080 let envelope = feed.recv().await.expect("account feed remains open");
5081 match &envelope.event {
5082 AgentEvent::ConfigChanged { section, .. }
5083 | AgentEvent::ConfigInvalid { section, .. }
5084 | AgentEvent::ConfigRecovered { section, .. }
5085 if section == expected_section =>
5086 {
5087 break envelope.event.clone();
5088 }
5089 _ => {}
5090 }
5091 }
5092 })
5093 .await
5094 .expect("config event timed out")
5095 }
5096
5097 async fn next_mcp_config_event(
5098 feed: &mut tokio::sync::broadcast::Receiver<Arc<bamboo_engine::events::ChangeEvent>>,
5099 ) -> AgentEvent {
5100 next_config_event(feed, "mcp").await
5101 }
5102
5103 #[tokio::test]
5104 async fn compatibility_update_cannot_reintroduce_an_unrevisioned_cluster_mutation() {
5105 let _key = bamboo_config::encryption::set_test_encryption_key([0x70; 32]);
5106 let dir = tempfile::tempdir().unwrap();
5107 let state = AppState::new(dir.path().to_path_buf()).await.unwrap();
5108 state
5109 .update_cluster_fabric_credentials(
5110 0,
5111 std::collections::BTreeMap::from([(
5112 "owned-node".to_string(),
5113 bamboo_config::ClusterNodeCredentialIntents::clear_all(),
5114 )]),
5115 |config| {
5116 config.cluster_fabric.nodes.push(bamboo_config::Node {
5117 id: "owned-node".to_string(),
5118 label: "revisioned-label".to_string(),
5119 placement: bamboo_config::NodePlacement::Local,
5120 trust_level: bamboo_config::TrustLevel::Trusted,
5121 deploy: bamboo_config::DeployProfile::default(),
5122 state: None,
5123 enabled: true,
5124 });
5125 Ok(())
5126 },
5127 )
5128 .await
5129 .unwrap();
5130 let cluster_path = dir.path().join("cluster-fabric.json");
5131 let cluster_before = std::fs::read(&cluster_path).unwrap();
5132
5133 let updated = state
5134 .update_config(
5135 |config| {
5136 config.server.port = 21_000;
5137 config.cluster_fabric.node_mut("owned-node").unwrap().label =
5138 "unrevisioned-label".to_string();
5139 Ok(())
5140 },
5141 ConfigUpdateEffects::default(),
5142 )
5143 .await
5144 .unwrap();
5145
5146 assert_eq!(updated.server.port, 21_000);
5147 assert_eq!(
5148 updated.cluster_fabric.node("owned-node").unwrap().label,
5149 "revisioned-label"
5150 );
5151 assert_eq!(
5152 state
5153 .config_facade
5154 .as_ref()
5155 .unwrap()
5156 .registry()
5157 .cluster_fabric
5158 .snapshot()
5159 .revision,
5160 1
5161 );
5162 assert_eq!(std::fs::read(cluster_path).unwrap(), cluster_before);
5163 }
5164
5165 #[tokio::test]
5166 async fn stopped_watcher_compatibility_writers_install_only_their_owned_section() {
5167 let _key = bamboo_config::encryption::set_test_encryption_key([0x72; 32]);
5168 let dir = tempfile::tempdir().unwrap();
5169 let mut state = AppState::new(dir.path().to_path_buf()).await.unwrap();
5170 state
5171 .update_cluster_fabric_credentials(
5172 0,
5173 BTreeMap::from([(
5174 "shared-node".to_string(),
5175 bamboo_config::ClusterNodeCredentialIntents::clear_all(),
5176 )]),
5177 |config| {
5178 config.cluster_fabric.nodes.push(bamboo_config::Node {
5179 id: "shared-node".to_string(),
5180 label: "generation-one".to_string(),
5181 placement: bamboo_config::NodePlacement::Local,
5182 trust_level: bamboo_config::TrustLevel::Trusted,
5183 deploy: bamboo_config::DeployProfile::default(),
5184 state: None,
5185 enabled: true,
5186 });
5187 Ok(())
5188 },
5189 )
5190 .await
5191 .unwrap();
5192 stop_config_watcher(&mut state);
5193 let state = Arc::new(state);
5194
5195 let external = bamboo_config::ConfigFacade::open(dir.path()).unwrap();
5196 let mut external_candidate = external.effective_config();
5197 external_candidate
5198 .cluster_fabric
5199 .node_mut("shared-node")
5200 .unwrap()
5201 .label = "external-generation-two".to_string();
5202 assert_eq!(
5203 bamboo_config::persist_cluster_fabric_credential_transaction_at_revision(
5204 dir.path(),
5205 &mut external_candidate,
5206 &BTreeMap::new(),
5207 1,
5208 )
5209 .unwrap(),
5210 2
5211 );
5212 let cluster_path = dir.path().join("cluster-fabric.json");
5213 let cluster_r2 = std::fs::read(&cluster_path).unwrap();
5214 assert_eq!(
5215 state
5216 .config_facade
5217 .as_ref()
5218 .unwrap()
5219 .registry()
5220 .cluster_fabric
5221 .snapshot()
5222 .revision,
5223 1
5224 );
5225 assert_eq!(
5226 state
5227 .config
5228 .read()
5229 .await
5230 .cluster_fabric
5231 .node("shared-node")
5232 .unwrap()
5233 .label,
5234 "generation-one"
5235 );
5236
5237 let baseline_seq = state.account_sink.latest_seq();
5238 let mut core_feed = state.account_sink.subscribe();
5239 let mut cluster_feed = state.account_sink.subscribe();
5240 let stale_runtime = state.config.read().await;
5241 let updating = {
5242 let state = state.clone();
5243 tokio::spawn(async move {
5244 state
5245 .update_config(
5246 |config| {
5247 config.server.port = 23_332;
5248 Ok(())
5249 },
5250 ConfigUpdateEffects::default(),
5251 )
5252 .await
5253 })
5254 };
5255 assert!(
5256 tokio::time::timeout(
5257 Duration::from_millis(100),
5258 next_config_event(&mut core_feed, "core"),
5259 )
5260 .await
5261 .is_err(),
5262 "core event became observable while the old AppState snapshot was held"
5263 );
5264 assert_ne!(stale_runtime.server.port, 23_332);
5265 drop(stale_runtime);
5266 let published = updating.await.unwrap().unwrap();
5267 assert!(matches!(
5268 next_config_event(&mut core_feed, "core").await,
5269 AgentEvent::ConfigChanged { section, .. } if section == "core"
5270 ));
5271 assert_eq!(state.config.read().await.server.port, 23_332);
5272 assert!(
5273 tokio::time::timeout(
5274 Duration::from_millis(300),
5275 next_config_event(&mut cluster_feed, "cluster-fabric"),
5276 )
5277 .await
5278 .is_err(),
5279 "an unrelated compatibility update published a cluster event"
5280 );
5281 assert_eq!(
5282 published.cluster_fabric.node("shared-node").unwrap().label,
5283 "generation-one"
5284 );
5285 assert_eq!(std::fs::read(&cluster_path).unwrap(), cluster_r2);
5286 assert_eq!(
5287 state
5288 .config_facade
5289 .as_ref()
5290 .unwrap()
5291 .registry()
5292 .cluster_fabric
5293 .snapshot()
5294 .revision,
5295 1,
5296 "an unrelated compatibility update must not catch up cluster"
5297 );
5298 assert_eq!(
5299 state
5300 .config
5301 .read()
5302 .await
5303 .cluster_fabric
5304 .node("shared-node")
5305 .unwrap()
5306 .label,
5307 "generation-one"
5308 );
5309 tokio::time::sleep(Duration::from_millis(100)).await;
5310 let events = bamboo_engine::events::journal::read_since(
5311 state.account_sink.events_dir(),
5312 baseline_seq,
5313 )
5314 .unwrap();
5315 assert_eq!(
5316 events
5317 .iter()
5318 .filter(|event| matches!(
5319 &event.event,
5320 AgentEvent::ConfigChanged { section, .. } if section == "core"
5321 ))
5322 .count(),
5323 1
5324 );
5325 assert!(!events.iter().any(|event| matches!(
5326 &event.event,
5327 AgentEvent::ConfigChanged { section, .. } if section == "cluster-fabric"
5328 )));
5329
5330 let external = bamboo_config::ConfigFacade::open(dir.path()).unwrap();
5331 let mut external_candidate = external.effective_config();
5332 external_candidate
5333 .cluster_fabric
5334 .node_mut("shared-node")
5335 .unwrap()
5336 .label = "external-generation-three".to_string();
5337 assert_eq!(
5338 bamboo_config::persist_cluster_fabric_credential_transaction_at_revision(
5339 dir.path(),
5340 &mut external_candidate,
5341 &BTreeMap::new(),
5342 2,
5343 )
5344 .unwrap(),
5345 3
5346 );
5347 let cluster_r3 = std::fs::read(&cluster_path).unwrap();
5348 assert_eq!(
5349 state
5350 .config_facade
5351 .as_ref()
5352 .unwrap()
5353 .registry()
5354 .cluster_fabric
5355 .snapshot()
5356 .revision,
5357 1,
5358 "the stopped watcher must remain stale before replace_config"
5359 );
5360
5361 let mut replacement = state.config.read().await.clone();
5362 replacement.server.port = 23_333;
5363 let baseline_seq = state.account_sink.latest_seq();
5364 let mut core_feed = state.account_sink.subscribe();
5365 let mut cluster_feed = state.account_sink.subscribe();
5366 let stale_runtime = state.config.read().await;
5367 let replacing = {
5368 let state = state.clone();
5369 tokio::spawn(async move {
5370 state
5371 .replace_config(replacement, ConfigUpdateEffects::default())
5372 .await
5373 })
5374 };
5375 assert!(
5376 tokio::time::timeout(
5377 Duration::from_millis(100),
5378 next_config_event(&mut core_feed, "core"),
5379 )
5380 .await
5381 .is_err(),
5382 "replacement event became observable while the old AppState snapshot was held"
5383 );
5384 assert_ne!(stale_runtime.server.port, 23_333);
5385 drop(stale_runtime);
5386 let published = replacing.await.unwrap().unwrap();
5387 assert!(matches!(
5388 next_config_event(&mut core_feed, "core").await,
5389 AgentEvent::ConfigChanged { section, .. } if section == "core"
5390 ));
5391 assert_eq!(state.config.read().await.server.port, 23_333);
5392 assert!(
5393 tokio::time::timeout(
5394 Duration::from_millis(300),
5395 next_config_event(&mut cluster_feed, "cluster-fabric"),
5396 )
5397 .await
5398 .is_err(),
5399 "an unrelated compatibility replacement published a cluster event"
5400 );
5401 assert_eq!(published.server.port, 23_333);
5402 assert_eq!(
5403 published.cluster_fabric.node("shared-node").unwrap().label,
5404 "generation-one"
5405 );
5406 assert_eq!(std::fs::read(&cluster_path).unwrap(), cluster_r3);
5407 assert_eq!(
5408 state
5409 .config_facade
5410 .as_ref()
5411 .unwrap()
5412 .registry()
5413 .cluster_fabric
5414 .snapshot()
5415 .revision,
5416 1
5417 );
5418 assert_eq!(
5419 state
5420 .config
5421 .read()
5422 .await
5423 .cluster_fabric
5424 .node("shared-node")
5425 .unwrap()
5426 .label,
5427 "generation-one"
5428 );
5429 tokio::time::sleep(Duration::from_millis(100)).await;
5430 let events = bamboo_engine::events::journal::read_since(
5431 state.account_sink.events_dir(),
5432 baseline_seq,
5433 )
5434 .unwrap();
5435 assert_eq!(
5436 events
5437 .iter()
5438 .filter(|event| matches!(
5439 &event.event,
5440 AgentEvent::ConfigChanged { section, .. } if section == "core"
5441 ))
5442 .count(),
5443 1
5444 );
5445 assert!(!events.iter().any(|event| matches!(
5446 &event.event,
5447 AgentEvent::ConfigChanged { section, .. } if section == "cluster-fabric"
5448 )));
5449 }
5450
5451 #[tokio::test]
5452 async fn exact_notification_publication_installs_only_its_owned_runtime_section() {
5453 let dir = tempfile::tempdir().unwrap();
5454 let state = AppState::new(dir.path().to_path_buf()).await.unwrap();
5455 {
5456 let mut live = state.config.write().await;
5457 live.connect
5458 .platforms
5459 .push(bamboo_config::ConnectPlatformConfig {
5460 id: None,
5461 project_id: None,
5462 platform_type: "runtime-sentinel".to_string(),
5463 token: None,
5464 token_encrypted: None,
5465 token_credential_ref: None,
5466 token_configured: false,
5467 app_id: None,
5468 app_secret: None,
5469 app_secret_encrypted: None,
5470 app_secret_credential_ref: None,
5471 app_secret_configured: false,
5472 domain: None,
5473 allow_from: Vec::new(),
5474 admin_from: Vec::new(),
5475 });
5476 live.providers_mut().openai = Some(bamboo_config::OpenAIConfig {
5477 api_key: "runtime-provider-sentinel".to_string(),
5478 ..Default::default()
5479 });
5480 }
5481 let connect_before = std::fs::read(dir.path().join("connect.json")).unwrap();
5482 let (published, revision, _, section) = state
5483 .update_notification_credentials(0, BTreeSet::new(), false, |candidate| {
5484 candidate.notifications.ntfy.enabled = true;
5485 candidate.notifications.ntfy.topic = "owned-notification".to_string();
5486 candidate.assign_connect_platform_ids();
5490 candidate
5491 .providers_mut()
5492 .openai
5493 .as_mut()
5494 .unwrap()
5495 .api_key
5496 .clear();
5497 Ok(())
5498 })
5499 .await
5500 .unwrap();
5501
5502 assert_eq!(revision, 1);
5503 assert_eq!(section.unwrap().revision, 1);
5504 assert_eq!(published.notifications.ntfy.topic, "owned-notification");
5505 assert!(published.connect.platforms[0].id.is_none());
5506 assert_eq!(
5507 published.providers().openai.as_ref().unwrap().api_key,
5508 "runtime-provider-sentinel"
5509 );
5510 let live = state.config.read().await;
5511 assert!(live.connect.platforms[0].id.is_none());
5512 assert_eq!(
5513 live.providers().openai.as_ref().unwrap().api_key,
5514 "runtime-provider-sentinel"
5515 );
5516 drop(live);
5517 assert_eq!(
5518 std::fs::read(dir.path().join("connect.json")).unwrap(),
5519 connect_before
5520 );
5521 assert_eq!(
5522 bamboo_config::ConfigFacade::open(dir.path())
5523 .unwrap()
5524 .registry()
5525 .connect
5526 .snapshot()
5527 .revision,
5528 0
5529 );
5530 }
5531
5532 #[tokio::test]
5533 async fn generic_update_cannot_forge_exact_core_credential_binding() {
5534 let dir = tempfile::tempdir().unwrap();
5535 let state = AppState::new(dir.path().to_path_buf()).await.unwrap();
5536 let core_before = std::fs::read(dir.path().join("core.json")).unwrap();
5537 let error = state
5538 .update_config(
5539 |candidate| {
5540 candidate.proxy_auth_credential_ref =
5541 Some(bamboo_config::CredentialRef::parse("proxy.default.auth").unwrap());
5542 Ok(())
5543 },
5544 ConfigUpdateEffects::default(),
5545 )
5546 .await
5547 .unwrap_err();
5548 assert!(matches!(error, AppError::BadRequest(_)));
5549 assert!(error.to_string().contains("credential bindings"));
5550 assert_eq!(
5551 std::fs::read(dir.path().join("core.json")).unwrap(),
5552 core_before
5553 );
5554 assert!(state
5555 .config
5556 .read()
5557 .await
5558 .proxy_auth_credential_ref
5559 .is_none());
5560 assert_eq!(
5561 state
5562 .config_facade
5563 .as_ref()
5564 .unwrap()
5565 .registry()
5566 .core
5567 .snapshot()
5568 .revision,
5569 0
5570 );
5571 }
5572
5573 #[tokio::test]
5574 async fn env_credential_commit_installs_owned_runtime_before_exact_events() {
5575 let _key = bamboo_config::encryption::set_test_encryption_key([0x73; 32]);
5576 let dir = tempfile::tempdir().unwrap();
5577 let mut state = AppState::new(dir.path().to_path_buf()).await.unwrap();
5578 state
5579 .update_cluster_fabric_credentials(
5580 0,
5581 BTreeMap::from([(
5582 "shared-node".to_string(),
5583 bamboo_config::ClusterNodeCredentialIntents::clear_all(),
5584 )]),
5585 |config| {
5586 config.cluster_fabric.nodes.push(bamboo_config::Node {
5587 id: "shared-node".to_string(),
5588 label: "generation-one".to_string(),
5589 placement: bamboo_config::NodePlacement::Local,
5590 trust_level: bamboo_config::TrustLevel::Trusted,
5591 deploy: bamboo_config::DeployProfile::default(),
5592 state: None,
5593 enabled: true,
5594 });
5595 Ok(())
5596 },
5597 )
5598 .await
5599 .unwrap();
5600 stop_config_watcher(&mut state);
5601 let state = Arc::new(state);
5602
5603 let external = bamboo_config::ConfigFacade::open(dir.path()).unwrap();
5604 let mut external_candidate = external.effective_config();
5605 external_candidate
5606 .cluster_fabric
5607 .node_mut("shared-node")
5608 .unwrap()
5609 .label = "external-generation-two".to_string();
5610 bamboo_config::persist_cluster_fabric_credential_transaction_at_revision(
5611 dir.path(),
5612 &mut external_candidate,
5613 &BTreeMap::new(),
5614 1,
5615 )
5616 .unwrap();
5617 let cluster_path = dir.path().join("cluster-fabric.json");
5618 let cluster_r2 = std::fs::read(&cluster_path).unwrap();
5619 let expected_revision = state
5620 .config_facade
5621 .as_ref()
5622 .unwrap()
5623 .registry()
5624 .env
5625 .snapshot()
5626 .revision;
5627
5628 let (reached_tx, reached_rx) = std::sync::mpsc::channel();
5629 let (release_tx, release_rx) = std::sync::mpsc::channel();
5630 set_credential_after_commit_before_live_test_hook(dir.path(), SectionId::Env, move || {
5631 reached_tx.send(()).unwrap();
5632 release_rx.recv().unwrap();
5633 });
5634 let baseline_seq = state.account_sink.latest_seq();
5635 let mut credential_feed = state.account_sink.subscribe();
5636 let mut env_feed = state.account_sink.subscribe();
5637 let mut cluster_feed = state.account_sink.subscribe();
5638 let updating = {
5639 let state = state.clone();
5640 tokio::spawn(async move {
5641 state
5642 .update_env_var_credentials(
5643 expected_revision,
5644 BTreeSet::from(["TOKEN".to_string()]),
5645 false,
5646 |config| {
5647 config.env_vars.push(bamboo_config::EnvVarEntry {
5648 name: "TOKEN".to_string(),
5649 value: "exact-secret".to_string(),
5650 secret: true,
5651 value_encrypted: None,
5652 credential_ref: None,
5653 configured: true,
5654 description: None,
5655 });
5656 Ok(())
5657 },
5658 )
5659 .await
5660 })
5661 };
5662 tokio::task::spawn_blocking(move || reached_rx.recv().unwrap())
5663 .await
5664 .unwrap();
5665 let stale_runtime = state.config.read().await;
5666 release_tx.send(()).unwrap();
5667 for (feed, section) in [
5668 (&mut credential_feed, "credentials"),
5669 (&mut env_feed, "env"),
5670 (&mut cluster_feed, "cluster-fabric"),
5671 ] {
5672 assert!(
5673 tokio::time::timeout(Duration::from_millis(100), next_config_event(feed, section),)
5674 .await
5675 .is_err(),
5676 "{section} event became observable before the owned runtime install"
5677 );
5678 }
5679 assert!(
5680 stale_runtime
5681 .env_vars
5682 .iter()
5683 .all(|entry| entry.name != "TOKEN"),
5684 "the held runtime must still be the pre-commit env generation"
5685 );
5686 assert_eq!(
5687 stale_runtime
5688 .cluster_fabric
5689 .node("shared-node")
5690 .unwrap()
5691 .label,
5692 "generation-one"
5693 );
5694 drop(stale_runtime);
5695
5696 let (published, revision, _, _) = updating.await.unwrap().unwrap();
5697 assert!(revision > expected_revision);
5698 assert!(published
5699 .env_vars
5700 .iter()
5701 .any(|entry| entry.name == "TOKEN" && entry.value == "exact-secret"));
5702 assert_eq!(
5703 published.cluster_fabric.node("shared-node").unwrap().label,
5704 "generation-one"
5705 );
5706 assert!(matches!(
5707 next_config_event(&mut credential_feed, "credentials").await,
5708 AgentEvent::ConfigChanged { section, .. } if section == "credentials"
5709 ));
5710 assert!(matches!(
5711 next_config_event(&mut env_feed, "env").await,
5712 AgentEvent::ConfigChanged { section, .. } if section == "env"
5713 ));
5714 assert!(tokio::time::timeout(
5715 Duration::from_millis(300),
5716 next_config_event(&mut cluster_feed, "cluster-fabric"),
5717 )
5718 .await
5719 .is_err());
5720 assert_eq!(std::fs::read(cluster_path).unwrap(), cluster_r2);
5721 assert_eq!(
5722 state
5723 .config_facade
5724 .as_ref()
5725 .unwrap()
5726 .registry()
5727 .cluster_fabric
5728 .snapshot()
5729 .revision,
5730 1
5731 );
5732 tokio::time::sleep(Duration::from_millis(100)).await;
5733 let events = bamboo_engine::events::journal::read_since(
5734 state.account_sink.events_dir(),
5735 baseline_seq,
5736 )
5737 .unwrap();
5738 assert_eq!(
5739 events
5740 .iter()
5741 .filter(|event| matches!(
5742 &event.event,
5743 AgentEvent::ConfigChanged { section, .. } if section == "credentials"
5744 ))
5745 .count(),
5746 1
5747 );
5748 assert_eq!(
5749 events
5750 .iter()
5751 .filter(|event| matches!(
5752 &event.event,
5753 AgentEvent::ConfigChanged { section, .. } if section == "env"
5754 ))
5755 .count(),
5756 1
5757 );
5758 assert!(!events.iter().any(|event| matches!(
5759 &event.event,
5760 AgentEvent::ConfigChanged { section, .. } if section == "cluster-fabric"
5761 )));
5762 }
5763
5764 #[tokio::test]
5765 async fn env_mutation_returns_its_captured_envelope_after_a_later_section_commit() {
5766 let _key = bamboo_config::encryption::set_test_encryption_key([0x74; 32]);
5767 let dir = tempfile::tempdir().unwrap();
5768 let mut state = AppState::new(dir.path().to_path_buf()).await.unwrap();
5769 stop_config_watcher(&mut state);
5770 let state = Arc::new(state);
5771
5772 let (reached_tx, reached_rx) = std::sync::mpsc::channel();
5773 let (release_tx, release_rx) = std::sync::mpsc::channel();
5774 set_credential_after_commit_before_live_test_hook(dir.path(), SectionId::Env, move || {
5775 reached_tx.send(()).unwrap();
5776 release_rx.recv().unwrap();
5777 });
5778 let updating = {
5779 let state = state.clone();
5780 tokio::spawn(async move {
5781 state
5782 .update_env_var_credentials(
5783 0,
5784 BTreeSet::from(["TOKEN".to_string()]),
5785 false,
5786 |config| {
5787 config.env_vars.push(bamboo_config::EnvVarEntry {
5788 name: "TOKEN".to_string(),
5789 value: "first-secret".to_string(),
5790 secret: true,
5791 value_encrypted: None,
5792 credential_ref: None,
5793 configured: true,
5794 description: Some("first generation".to_string()),
5795 });
5796 Ok(())
5797 },
5798 )
5799 .await
5800 })
5801 };
5802 tokio::task::spawn_blocking(move || reached_rx.recv().unwrap())
5803 .await
5804 .unwrap();
5805
5806 let external_dir = dir.path().to_path_buf();
5807 let process_facade = state.config_facade.clone().unwrap();
5808 let later = tokio::task::spawn_blocking(move || {
5809 let external = bamboo_config::ConfigFacade::open(&external_dir).unwrap();
5810 let mut candidate = external.effective_config();
5811 candidate.env_vars[0].description = Some("later generation".to_string());
5812 bamboo_config::persist_env_var_credential_transaction_at_revision_with_adoption(
5813 &external_dir,
5814 &mut candidate,
5815 &BTreeSet::from(["TOKEN".to_string()]),
5816 1,
5817 process_facade.as_ref(),
5818 )
5819 .unwrap()
5820 })
5821 .await
5822 .unwrap();
5823 assert_eq!(later.revision, 2);
5824 assert_eq!(later.section.unwrap().revision, 2);
5825 release_tx.send(()).unwrap();
5826
5827 let (_, revision, _, section) = updating.await.unwrap().unwrap();
5828 let section = section.expect("modular mutation returns its exact section");
5829 assert_eq!(revision, 1);
5830 assert_eq!(section.revision, 1);
5831 assert_eq!(section.data[0]["description"], "first generation");
5832 assert_eq!(
5833 state
5834 .config_facade
5835 .as_ref()
5836 .unwrap()
5837 .registry()
5838 .env
5839 .snapshot()
5840 .revision,
5841 2,
5842 "the process facade advanced, but the response retained its own commit"
5843 );
5844 }
5845
5846 #[tokio::test]
5847 async fn cluster_commit_installs_runtime_before_one_authoritative_event() {
5848 let _key = bamboo_config::encryption::set_test_encryption_key([0x71; 32]);
5849 let dir = tempfile::tempdir().unwrap();
5850 let state = AppState::new(dir.path().to_path_buf()).await.unwrap();
5851 let revision = state
5852 .config_facade
5853 .as_ref()
5854 .unwrap()
5855 .registry()
5856 .cluster_fabric
5857 .snapshot()
5858 .revision;
5859 let baseline_seq = state.account_sink.latest_seq();
5860 let mut feed = state.account_sink.subscribe();
5861 let runtime = state.config.clone();
5862 let observer = tokio::spawn(async move {
5863 tokio::time::timeout(Duration::from_secs(3), async move {
5864 loop {
5865 let event = feed.recv().await.unwrap();
5866 match &event.event {
5867 AgentEvent::ConfigChanged { section, .. } if section == "credentials" => {
5868 panic!("cluster mutation published an internal credential event")
5869 }
5870 AgentEvent::ConfigChanged { section, revision }
5871 if section == "cluster-fabric" =>
5872 {
5873 assert!(
5874 runtime
5875 .read()
5876 .await
5877 .cluster_fabric
5878 .node("event-node")
5879 .is_some(),
5880 "event observer saw the old runtime snapshot"
5881 );
5882 return *revision;
5883 }
5884 _ => {}
5885 }
5886 }
5887 })
5888 .await
5889 .expect("cluster event timed out")
5890 });
5891
5892 let node = bamboo_config::Node {
5893 id: "event-node".to_string(),
5894 label: "event-node".to_string(),
5895 placement: bamboo_config::NodePlacement::Local,
5896 trust_level: bamboo_config::TrustLevel::Trusted,
5897 deploy: bamboo_config::DeployProfile::default(),
5898 state: None,
5899 enabled: true,
5900 };
5901 let committed = state
5902 .update_cluster_fabric_credentials(
5903 revision,
5904 BTreeMap::from([(
5905 "event-node".to_string(),
5906 bamboo_config::ClusterNodeCredentialIntents::clear_all(),
5907 )]),
5908 move |config| {
5909 config.cluster_fabric.nodes.push(node);
5910 Ok(())
5911 },
5912 )
5913 .await
5914 .unwrap();
5915 let committed = committed.section.revision;
5916 assert_eq!(committed, revision + 1);
5917 assert_eq!(observer.await.unwrap(), committed);
5918
5919 tokio::time::sleep(Duration::from_millis(100)).await;
5920 let events = bamboo_engine::events::journal::read_since(
5921 state.account_sink.events_dir(),
5922 baseline_seq,
5923 )
5924 .unwrap();
5925 let cluster_events = events
5926 .iter()
5927 .filter(|event| {
5928 matches!(
5929 &event.event,
5930 AgentEvent::ConfigChanged { section, revision: event_revision }
5931 if section == "cluster-fabric" && *event_revision == committed
5932 )
5933 })
5934 .count();
5935 let credential_events = events
5936 .iter()
5937 .filter(|event| {
5938 matches!(
5939 &event.event,
5940 AgentEvent::ConfigChanged { section, .. } if section == "credentials"
5941 )
5942 })
5943 .count();
5944 assert_eq!(cluster_events, 1);
5945 assert_eq!(credential_events, 0);
5946 }
5947
5948 #[tokio::test]
5949 async fn stale_process_cluster_candidate_rebases_on_exact_durable_client_generation() {
5950 let dir = tempfile::tempdir().unwrap();
5951 let mut state = AppState::new(dir.path().to_path_buf()).await.unwrap();
5952 state
5953 .update_cluster_fabric_credentials(
5954 0,
5955 BTreeMap::from([(
5956 "shared-node".to_string(),
5957 bamboo_config::ClusterNodeCredentialIntents::clear_all(),
5958 )]),
5959 |config| {
5960 config.cluster_fabric.nodes.push(bamboo_config::Node {
5961 id: "shared-node".to_string(),
5962 label: "generation-one".to_string(),
5963 placement: bamboo_config::NodePlacement::Local,
5964 trust_level: bamboo_config::TrustLevel::Trusted,
5965 deploy: bamboo_config::DeployProfile::default(),
5966 state: None,
5967 enabled: true,
5968 });
5969 Ok(())
5970 },
5971 )
5972 .await
5973 .unwrap();
5974 stop_config_watcher(&mut state);
5975
5976 let external = bamboo_config::ConfigFacade::open(dir.path()).unwrap();
5977 let mut external_candidate = external.effective_config();
5978 external_candidate
5979 .cluster_fabric
5980 .clusters
5981 .push(bamboo_config::Cluster {
5982 name: "external-cluster".to_string(),
5983 description: Some("durable-r2-field".to_string()),
5984 node_ids: vec!["shared-node".to_string()],
5985 });
5986 assert_eq!(
5987 bamboo_config::persist_cluster_fabric_credential_transaction_at_revision(
5988 dir.path(),
5989 &mut external_candidate,
5990 &BTreeMap::new(),
5991 1,
5992 )
5993 .unwrap(),
5994 2
5995 );
5996 assert_eq!(
5997 state
5998 .config_facade
5999 .as_ref()
6000 .unwrap()
6001 .registry()
6002 .cluster_fabric
6003 .snapshot()
6004 .revision,
6005 1
6006 );
6007 assert!(
6008 state
6009 .config
6010 .read()
6011 .await
6012 .cluster_fabric
6013 .cluster("external-cluster")
6014 .is_none(),
6015 "the process runtime is intentionally stale at r1"
6016 );
6017
6018 let committed = state
6019 .update_cluster_fabric_credentials(2, BTreeMap::new(), |config| {
6020 config.cluster_fabric.node_mut("shared-node").unwrap().label =
6021 "client-r3-edit".to_string();
6022 Ok(())
6023 })
6024 .await
6025 .unwrap();
6026 assert_eq!(committed.section.revision, 3);
6027 assert_eq!(
6028 committed
6029 .config
6030 .cluster_fabric
6031 .node("shared-node")
6032 .unwrap()
6033 .label,
6034 "client-r3-edit"
6035 );
6036 assert_eq!(
6037 committed
6038 .config
6039 .cluster_fabric
6040 .cluster("external-cluster")
6041 .unwrap()
6042 .description
6043 .as_deref(),
6044 Some("durable-r2-field")
6045 );
6046 assert_eq!(
6047 state
6048 .config_facade
6049 .as_ref()
6050 .unwrap()
6051 .registry()
6052 .cluster_fabric
6053 .snapshot()
6054 .revision,
6055 3,
6056 "compound adoption must safely catch the stale r1 facade up to r3"
6057 );
6058
6059 let runtime_before_conflict = state.config.read().await.cluster_fabric.clone();
6060 let conflict = state
6061 .update_cluster_fabric_credentials(2, BTreeMap::new(), |config| {
6062 config.cluster_fabric.nodes.clear();
6063 config.cluster_fabric.clusters.clear();
6064 Ok(())
6065 })
6066 .await;
6067 assert!(matches!(
6068 conflict,
6069 Err(AppError::ConfigConflict {
6070 expected: 2,
6071 actual: 3
6072 })
6073 ));
6074 assert_eq!(
6075 state.config.read().await.cluster_fabric,
6076 runtime_before_conflict,
6077 "a durable CAS conflict must not overwrite the process runtime"
6078 );
6079 }
6080
6081 #[tokio::test]
6082 async fn stale_process_cluster_noop_catches_up_exact_durable_generation() {
6083 let dir = tempfile::tempdir().unwrap();
6084 let mut state = AppState::new(dir.path().to_path_buf()).await.unwrap();
6085 stop_config_watcher(&mut state);
6086
6087 let external = bamboo_config::ConfigFacade::open(dir.path()).unwrap();
6088 let mut external_candidate = external.effective_config();
6089 external_candidate
6090 .cluster_fabric
6091 .nodes
6092 .push(bamboo_config::Node {
6093 id: "external-node".to_string(),
6094 label: "external-r1".to_string(),
6095 placement: bamboo_config::NodePlacement::Local,
6096 trust_level: bamboo_config::TrustLevel::Trusted,
6097 deploy: bamboo_config::DeployProfile::default(),
6098 state: None,
6099 enabled: true,
6100 });
6101 assert_eq!(
6102 bamboo_config::persist_cluster_fabric_credential_transaction_at_revision(
6103 dir.path(),
6104 &mut external_candidate,
6105 &BTreeMap::from([(
6106 "external-node".to_string(),
6107 bamboo_config::ClusterNodeCredentialIntents::clear_all(),
6108 )]),
6109 0,
6110 )
6111 .unwrap(),
6112 1
6113 );
6114 assert_eq!(
6115 state
6116 .config_facade
6117 .as_ref()
6118 .unwrap()
6119 .registry()
6120 .cluster_fabric
6121 .snapshot()
6122 .revision,
6123 0
6124 );
6125 let baseline_seq = state.account_sink.latest_seq();
6126
6127 let committed = state
6128 .update_cluster_fabric_credentials(1, BTreeMap::new(), |_| Ok(()))
6129 .await
6130 .unwrap();
6131 assert_eq!(committed.section.revision, 1);
6132 assert_eq!(
6133 committed
6134 .config
6135 .cluster_fabric
6136 .node("external-node")
6137 .unwrap()
6138 .label,
6139 "external-r1"
6140 );
6141 assert_eq!(
6142 state
6143 .config_facade
6144 .as_ref()
6145 .unwrap()
6146 .registry()
6147 .cluster_fabric
6148 .snapshot()
6149 .revision,
6150 1
6151 );
6152 assert_eq!(
6153 state
6154 .config
6155 .read()
6156 .await
6157 .cluster_fabric
6158 .node("external-node")
6159 .unwrap()
6160 .label,
6161 "external-r1"
6162 );
6163
6164 tokio::time::sleep(Duration::from_millis(100)).await;
6165 let events = bamboo_engine::events::journal::read_since(
6166 state.account_sink.events_dir(),
6167 baseline_seq,
6168 )
6169 .unwrap();
6170 let revisions = events
6171 .iter()
6172 .filter_map(|event| match &event.event {
6173 AgentEvent::ConfigChanged { section, revision } if section == "cluster-fabric" => {
6174 Some(*revision)
6175 }
6176 _ => None,
6177 })
6178 .collect::<Vec<_>>();
6179 assert_eq!(revisions, vec![1]);
6180 }
6181
6182 #[tokio::test]
6183 async fn stale_process_cluster_reset_noop_catches_up_exact_durable_generation() {
6184 let dir = tempfile::tempdir().unwrap();
6185 let mut state = AppState::new(dir.path().to_path_buf()).await.unwrap();
6186 stop_config_watcher(&mut state);
6187
6188 let external = bamboo_config::ConfigFacade::open(dir.path()).unwrap();
6189 let mut external_candidate = external.effective_config();
6190 external_candidate
6191 .cluster_fabric
6192 .nodes
6193 .push(bamboo_config::Node {
6194 id: "reset-node".to_string(),
6195 label: "reset-node".to_string(),
6196 placement: bamboo_config::NodePlacement::Local,
6197 trust_level: bamboo_config::TrustLevel::Trusted,
6198 deploy: bamboo_config::DeployProfile::default(),
6199 state: None,
6200 enabled: true,
6201 });
6202 assert_eq!(
6203 bamboo_config::persist_cluster_fabric_credential_transaction_at_revision(
6204 dir.path(),
6205 &mut external_candidate,
6206 &BTreeMap::from([(
6207 "reset-node".to_string(),
6208 bamboo_config::ClusterNodeCredentialIntents::clear_all(),
6209 )]),
6210 0,
6211 )
6212 .unwrap(),
6213 1
6214 );
6215 let reset_facade = bamboo_config::ConfigFacade::open(dir.path()).unwrap();
6216 let mut reset_candidate = reset_facade.effective_config();
6217 reset_candidate.cluster_fabric = bamboo_config::ClusterFabricConfig::default();
6218 let external_reset = bamboo_config::persist_cluster_fabric_reset_at_revision_with_adoption(
6219 dir.path(),
6220 &mut reset_candidate,
6221 1,
6222 &reset_facade,
6223 |_, _| {},
6224 )
6225 .unwrap();
6226 assert_eq!(external_reset.revision, 2);
6227 assert_eq!(
6228 state
6229 .config_facade
6230 .as_ref()
6231 .unwrap()
6232 .registry()
6233 .cluster_fabric
6234 .snapshot()
6235 .revision,
6236 0
6237 );
6238 let baseline_seq = state.account_sink.latest_seq();
6239
6240 let committed = state
6241 .reset_credential_backed_section(SectionId::ClusterFabric, 2)
6242 .await
6243 .unwrap();
6244 let CredentialBackedResetCommit::Cluster(committed) = committed else {
6245 panic!("cluster reset must return its exact snapshot")
6246 };
6247 assert_eq!(committed.section.revision, 2);
6248 assert!(committed.config.cluster_fabric.nodes.is_empty());
6249 assert_eq!(
6250 state
6251 .config_facade
6252 .as_ref()
6253 .unwrap()
6254 .registry()
6255 .cluster_fabric
6256 .snapshot()
6257 .revision,
6258 2
6259 );
6260 assert!(state.config.read().await.cluster_fabric.nodes.is_empty());
6261
6262 tokio::time::sleep(Duration::from_millis(100)).await;
6263 let events = bamboo_engine::events::journal::read_since(
6264 state.account_sink.events_dir(),
6265 baseline_seq,
6266 )
6267 .unwrap();
6268 let revisions = events
6269 .iter()
6270 .filter_map(|event| match &event.event {
6271 AgentEvent::ConfigChanged { section, revision } if section == "cluster-fabric" => {
6272 Some(*revision)
6273 }
6274 _ => None,
6275 })
6276 .collect::<Vec<_>>();
6277 assert_eq!(revisions, vec![2]);
6278 }
6279
6280 #[tokio::test(flavor = "multi_thread", worker_threads = 4)]
6281 async fn generic_events_follow_serialized_local_commit_order() {
6282 let dir = tempfile::tempdir().unwrap();
6283 let mut state = AppState::new(dir.path().to_path_buf()).await.unwrap();
6284 stop_config_watcher(&mut state);
6285 let state = Arc::new(state);
6286 let baseline_seq = state.account_sink.latest_seq();
6287 let (reached_tx, reached_rx) = std::sync::mpsc::sync_channel(0);
6288 let (release_tx, release_rx) = std::sync::mpsc::sync_channel(0);
6289 set_generic_before_event_test_hook(dir.path(), move || {
6290 reached_tx.send(()).unwrap();
6291 release_rx.recv().unwrap();
6292 });
6293
6294 let first = {
6295 let state = state.clone();
6296 tokio::spawn(async move {
6297 state
6298 .update_config(
6299 |config| {
6300 config.server.port = 22_231;
6301 Ok(())
6302 },
6303 ConfigUpdateEffects::default(),
6304 )
6305 .await
6306 })
6307 };
6308 tokio::task::spawn_blocking(move || reached_rx.recv().unwrap())
6309 .await
6310 .unwrap();
6311 let second = {
6312 let state = state.clone();
6313 tokio::spawn(async move {
6314 state
6315 .update_config(
6316 |config| {
6317 config.server.port = 22_232;
6318 Ok(())
6319 },
6320 ConfigUpdateEffects::default(),
6321 )
6322 .await
6323 })
6324 };
6325 tokio::time::sleep(Duration::from_millis(100)).await;
6326 assert!(
6327 !second.is_finished(),
6328 "the later writer must remain behind the first writer's event"
6329 );
6330 let events = bamboo_engine::events::journal::read_since(
6331 state.account_sink.events_dir(),
6332 baseline_seq,
6333 )
6334 .unwrap();
6335 assert!(
6336 events.iter().all(|event| !matches!(
6337 &event.event,
6338 AgentEvent::ConfigChanged { section, .. } if section == "core"
6339 )),
6340 "neither local commit can publish while the first owns config_io_lock"
6341 );
6342
6343 release_tx.send(()).unwrap();
6344 assert_eq!(first.await.unwrap().unwrap().server.port, 22_231);
6345 assert_eq!(second.await.unwrap().unwrap().server.port, 22_232);
6346 tokio::time::timeout(Duration::from_secs(3), async {
6347 loop {
6348 let events = bamboo_engine::events::journal::read_since(
6349 state.account_sink.events_dir(),
6350 baseline_seq,
6351 )
6352 .unwrap();
6353 let revisions = events
6354 .iter()
6355 .filter_map(|event| match &event.event {
6356 AgentEvent::ConfigChanged { section, revision } if section == "core" => {
6357 Some(*revision)
6358 }
6359 _ => None,
6360 })
6361 .collect::<Vec<_>>();
6362 if revisions.len() == 2 {
6363 break revisions;
6364 }
6365 tokio::time::sleep(Duration::from_millis(20)).await;
6366 }
6367 })
6368 .await
6369 .map(|revisions| assert_eq!(revisions, vec![1, 2]))
6370 .expect("both serialized core events must reach the journal");
6371 }
6372
6373 #[tokio::test(flavor = "multi_thread", worker_threads = 4)]
6374 async fn generic_runtime_effects_finish_before_later_config_writer() {
6375 let dir = tempfile::tempdir().unwrap();
6376 let script = dir.path().join("mcp-fixture.py");
6377 std::fs::write(
6378 &script,
6379 r#"import json
6380import sys
6381
6382for line in sys.stdin:
6383 request = json.loads(line)
6384 request_id = request.get("id")
6385 if request_id is None:
6386 continue
6387 if request.get("method") == "server/discover":
6388 print(json.dumps({
6389 "jsonrpc": "2.0",
6390 "id": request_id,
6391 "error": {"code": -32601, "message": "Method not found"},
6392 }), flush=True)
6393 continue
6394 if request.get("method") == "initialize":
6395 result = {
6396 "protocolVersion": "2024-11-05",
6397 "capabilities": {"tools": {"listChanged": False}},
6398 "serverInfo": {"name": "config-order-fixture", "version": "1.0.0"},
6399 }
6400 elif request.get("method") == "tools/list":
6401 result = {"tools": []}
6402 else:
6403 result = {}
6404 print(json.dumps({"jsonrpc": "2.0", "id": request_id, "result": result}), flush=True)
6405"#,
6406 )
6407 .unwrap();
6408 let python = ["python3", "python"]
6409 .into_iter()
6410 .find(|command| {
6411 std::process::Command::new(command)
6412 .arg("--version")
6413 .output()
6414 .is_ok_and(|output| output.status.success())
6415 })
6416 .expect("a Python interpreter is required for the MCP ordering fixture");
6417
6418 let mut state = AppState::new(dir.path().to_path_buf()).await.unwrap();
6419 stop_config_watcher(&mut state);
6420 let state = Arc::new(state);
6421 let held_provider = state.provider.write().await;
6422 let (provider_ready_tx, provider_ready_rx) = std::sync::mpsc::channel();
6423 set_generic_before_provider_publish_test_hook(dir.path(), move || {
6424 provider_ready_tx.send(()).unwrap();
6425 });
6426
6427 let first = {
6428 let state = state.clone();
6429 tokio::spawn(async move {
6430 state
6431 .update_config(
6432 |config| {
6433 config.provider = "copilot".to_string();
6434 Ok(())
6435 },
6436 ConfigUpdateEffects {
6437 reload_provider: true,
6438 reconcile_mcp: true,
6439 },
6440 )
6441 .await
6442 })
6443 };
6444 tokio::task::spawn_blocking(move || provider_ready_rx.recv().unwrap())
6445 .await
6446 .unwrap();
6447 assert!(
6448 state.config_io_lock.try_lock().is_err(),
6449 "the first writer must retain config_io_lock until its runtime effects finish"
6450 );
6451 assert!(
6452 !first.is_finished(),
6453 "the first writer must still be waiting to publish its provider"
6454 );
6455
6456 let later_mcp = McpConfig {
6457 version: 1,
6458 servers: vec![McpServerConfig {
6459 id: "later-winner".to_string(),
6460 name: None,
6461 enabled: true,
6462 transport: TransportConfig::Stdio(StdioConfig {
6463 command: python.to_string(),
6464 args: vec![script.to_string_lossy().into_owned()],
6465 cwd: None,
6466 env: std::collections::HashMap::new(),
6467 env_encrypted: std::collections::HashMap::new(),
6468 env_credential_refs: std::collections::HashMap::new(),
6469 startup_timeout_ms: 2_000,
6470 }),
6471 request_timeout_ms: 2_000,
6472 healthcheck_interval_ms: 10_000,
6473 reconnect: ReconnectConfig {
6474 enabled: false,
6475 ..Default::default()
6476 },
6477 allowed_tools: vec![],
6478 denied_tools: vec![],
6479 }],
6480 };
6481 let second = {
6482 let state = state.clone();
6483 tokio::spawn(async move {
6484 state
6485 .update_config(
6486 move |config| {
6487 config.mcp = later_mcp;
6488 Ok(())
6489 },
6490 ConfigUpdateEffects {
6491 reload_provider: false,
6492 reconcile_mcp: true,
6493 },
6494 )
6495 .await
6496 })
6497 };
6498
6499 drop(held_provider);
6500 first.await.unwrap().unwrap();
6501 let published = second.await.unwrap().unwrap();
6502 assert_eq!(published.mcp.servers[0].id, "later-winner");
6503 assert_eq!(state.config.read().await.mcp.servers[0].id, "later-winner");
6504 assert_eq!(
6505 state.mcp_manager.list_servers(),
6506 vec!["later-winner".to_string()],
6507 "the later durable config generation must remain the final runtime generation"
6508 );
6509 state.mcp_manager.shutdown_all().await;
6510 }
6511
6512 #[tokio::test(flavor = "multi_thread", worker_threads = 4)]
6513 async fn generic_update_cancellation_after_commit_finishes_publication() {
6514 let dir = tempfile::tempdir().unwrap();
6515 let mut state = AppState::new(dir.path().to_path_buf()).await.unwrap();
6516 stop_config_watcher(&mut state);
6517 let state = Arc::new(state);
6518 let mut feed = state.account_sink.subscribe();
6519 let (reached_tx, reached_rx) = std::sync::mpsc::sync_channel(0);
6520 let (release_tx, release_rx) = std::sync::mpsc::sync_channel(0);
6521 set_generic_before_event_test_hook(dir.path(), move || {
6522 reached_tx.send(()).unwrap();
6523 release_rx.recv().unwrap();
6524 });
6525
6526 let operation = {
6527 let state = state.clone();
6528 tokio::spawn(async move {
6529 state
6530 .update_config(
6531 |config| {
6532 config.server.port = 22_240;
6533 Ok(())
6534 },
6535 ConfigUpdateEffects::default(),
6536 )
6537 .await
6538 })
6539 };
6540 tokio::task::spawn_blocking(move || reached_rx.recv().unwrap())
6541 .await
6542 .unwrap();
6543 assert_eq!(
6544 state
6545 .config_facade
6546 .as_ref()
6547 .unwrap()
6548 .registry()
6549 .core
6550 .snapshot()
6551 .revision,
6552 1,
6553 "the abort boundary must follow durable commit and facade adoption"
6554 );
6555 operation.abort();
6556 assert!(operation.await.unwrap_err().is_cancelled());
6557 release_tx.send(()).unwrap();
6558 let converged = tokio::time::timeout(Duration::from_secs(5), state.config_io_lock.lock())
6559 .await
6560 .expect("detached generic update must finish live publication");
6561 drop(converged);
6562
6563 assert_eq!(state.config.read().await.server.port, 22_240);
6564 assert_eq!(
6565 bamboo_config::ConfigFacade::open(dir.path())
6566 .unwrap()
6567 .effective_config()
6568 .server
6569 .port,
6570 22_240
6571 );
6572 assert!(matches!(
6573 next_config_event(&mut feed, "core").await,
6574 AgentEvent::ConfigChanged {
6575 section,
6576 revision: 1
6577 } if section == "core"
6578 ));
6579 }
6580
6581 #[tokio::test(flavor = "multi_thread", worker_threads = 4)]
6582 async fn provider_update_cancellation_after_commit_finishes_publication() {
6583 let _key = bamboo_config::encryption::set_test_encryption_key([0x7d; 32]);
6584 let dir = tempfile::tempdir().unwrap();
6585 let mut state = AppState::new(dir.path().to_path_buf()).await.unwrap();
6586 stop_config_watcher(&mut state);
6587 let state = Arc::new(state);
6588 let mut feed = state.account_sink.subscribe();
6589 let (reached_tx, reached_rx) = std::sync::mpsc::sync_channel(0);
6590 let (release_tx, release_rx) = std::sync::mpsc::sync_channel(0);
6591 set_generic_before_event_test_hook(dir.path(), move || {
6592 reached_tx.send(()).unwrap();
6593 release_rx.recv().unwrap();
6594 });
6595
6596 let operation = {
6597 let state = state.clone();
6598 tokio::spawn(async move {
6599 state
6600 .update_config_with_provider_credentials(
6601 |config| {
6602 config.provider = "openai".to_string();
6603 config.providers_mut().openai = Some(bamboo_config::OpenAIConfig {
6604 api_key: "cancellation-secret".to_string(),
6605 model: Some("cancellation-model".to_string()),
6606 ..Default::default()
6607 });
6608 Ok(())
6609 },
6610 BTreeSet::from(["openai".to_string()]),
6611 BTreeSet::new(),
6612 ConfigUpdateEffects::default(),
6613 )
6614 .await
6615 })
6616 };
6617 tokio::task::spawn_blocking(move || reached_rx.recv().unwrap())
6618 .await
6619 .unwrap();
6620 assert_eq!(
6621 state
6622 .config_facade
6623 .as_ref()
6624 .unwrap()
6625 .registry()
6626 .providers
6627 .snapshot()
6628 .revision,
6629 1,
6630 "the abort boundary must follow provider durable/facade adoption"
6631 );
6632 operation.abort();
6633 assert!(operation.await.unwrap_err().is_cancelled());
6634 release_tx.send(()).unwrap();
6635 let converged = tokio::time::timeout(Duration::from_secs(5), state.config_io_lock.lock())
6636 .await
6637 .expect("detached provider update must finish live publication");
6638 drop(converged);
6639
6640 assert_eq!(state.config.read().await.provider, "openai");
6641 let durable = bamboo_config::ConfigFacade::open(dir.path())
6642 .unwrap()
6643 .effective_config();
6644 assert_eq!(durable.provider, "openai");
6645 assert_eq!(
6646 durable
6647 .providers()
6648 .openai
6649 .as_ref()
6650 .unwrap()
6651 .model
6652 .as_deref(),
6653 Some("cancellation-model")
6654 );
6655 assert!(matches!(
6656 next_config_event(&mut feed, "providers").await,
6657 AgentEvent::ConfigChanged {
6658 section,
6659 revision: 1
6660 } if section == "providers"
6661 ));
6662 for file in ["providers.json", "credentials.json"] {
6663 assert!(
6664 !std::fs::read_to_string(dir.path().join(file))
6665 .unwrap()
6666 .contains("cancellation-secret"),
6667 "{file} must remain secret-free"
6668 );
6669 }
6670 }
6671
6672 #[tokio::test(flavor = "multi_thread", worker_threads = 4)]
6673 async fn replace_config_cancellation_after_commit_finishes_publication() {
6674 let dir = tempfile::tempdir().unwrap();
6675 let mut state = AppState::new(dir.path().to_path_buf()).await.unwrap();
6676 stop_config_watcher(&mut state);
6677 let state = Arc::new(state);
6678 let mut replacement = state.config.read().await.clone();
6679 replacement.server.port = 22_241;
6680 let mut feed = state.account_sink.subscribe();
6681 let (reached_tx, reached_rx) = std::sync::mpsc::sync_channel(0);
6682 let (release_tx, release_rx) = std::sync::mpsc::sync_channel(0);
6683 set_generic_before_event_test_hook(dir.path(), move || {
6684 reached_tx.send(()).unwrap();
6685 release_rx.recv().unwrap();
6686 });
6687
6688 let operation = {
6689 let state = state.clone();
6690 tokio::spawn(async move {
6691 state
6692 .replace_config(replacement, ConfigUpdateEffects::default())
6693 .await
6694 })
6695 };
6696 tokio::task::spawn_blocking(move || reached_rx.recv().unwrap())
6697 .await
6698 .unwrap();
6699 assert_eq!(
6700 state
6701 .config_facade
6702 .as_ref()
6703 .unwrap()
6704 .registry()
6705 .core
6706 .snapshot()
6707 .revision,
6708 1,
6709 "the abort boundary must follow replacement durable/facade adoption"
6710 );
6711 operation.abort();
6712 assert!(operation.await.unwrap_err().is_cancelled());
6713 release_tx.send(()).unwrap();
6714 let converged = tokio::time::timeout(Duration::from_secs(5), state.config_io_lock.lock())
6715 .await
6716 .expect("detached replacement must finish live publication");
6717 drop(converged);
6718
6719 assert_eq!(state.config.read().await.server.port, 22_241);
6720 assert_eq!(
6721 bamboo_config::ConfigFacade::open(dir.path())
6722 .unwrap()
6723 .effective_config()
6724 .server
6725 .port,
6726 22_241
6727 );
6728 assert!(matches!(
6729 next_config_event(&mut feed, "core").await,
6730 AgentEvent::ConfigChanged {
6731 section,
6732 revision: 1
6733 } if section == "core"
6734 ));
6735 }
6736
6737 #[tokio::test]
6738 async fn deployed_node_delete_and_cluster_reset_reject_before_commit_and_remain_stoppable() {
6739 let dir = tempfile::tempdir().unwrap();
6740 let state = AppState::new(dir.path().to_path_buf()).await.unwrap();
6741 state
6742 .update_cluster_fabric_credentials(
6743 0,
6744 BTreeMap::from([(
6745 "live-node".to_string(),
6746 bamboo_config::ClusterNodeCredentialIntents::clear_all(),
6747 )]),
6748 |config| {
6749 config.cluster_fabric.nodes.push(bamboo_config::Node {
6750 id: "live-node".to_string(),
6751 label: "live-node".to_string(),
6752 placement: bamboo_config::NodePlacement::Local,
6753 trust_level: bamboo_config::TrustLevel::Trusted,
6754 deploy: bamboo_config::DeployProfile::default(),
6755 state: Some(bamboo_config::NodeState {
6756 status: bamboo_config::NodeStatus::Running,
6757 worker_id: Some("live-worker".to_string()),
6758 ..Default::default()
6759 }),
6760 enabled: true,
6761 });
6762 Ok(())
6763 },
6764 )
6765 .await
6766 .unwrap();
6767 insert_registry_worker(
6768 &state,
6769 bamboo_server_tools::registry_keys::node_key("live-node"),
6770 "live-worker",
6771 )
6772 .await;
6773 let transaction_marker = dir.path().join("config-credential-migration.json");
6774 let marker_before_guard = std::fs::read(&transaction_marker).ok();
6775 bamboo_config::set_cluster_exact_commit_test_fault(
6776 dir.path().to_path_buf(),
6777 bamboo_config::ClusterExactCommitTestFault::AfterManifestRecoveryFailure,
6778 );
6779
6780 let delete = state
6781 .delete_cluster_node_credentials(
6782 1,
6783 "live-node".to_string(),
6784 BTreeMap::from([(
6785 "live-node".to_string(),
6786 bamboo_config::ClusterNodeCredentialIntents::clear_all(),
6787 )]),
6788 |config| {
6789 config
6790 .cluster_fabric
6791 .nodes
6792 .retain(|node| node.id != "live-node");
6793 Ok(())
6794 },
6795 )
6796 .await;
6797 assert!(matches!(delete, Err(AppError::BadRequest(_))));
6798 let reset = state
6799 .reset_credential_backed_section(SectionId::ClusterFabric, 1)
6800 .await;
6801 assert!(matches!(reset, Err(ConfigSectionMutationError::Invalid(_))));
6802 assert_eq!(
6803 std::fs::read(&transaction_marker).ok(),
6804 marker_before_guard,
6805 "registry guards must reject before opening a new durable transaction"
6806 );
6807 assert_eq!(
6808 state
6809 .config_facade
6810 .as_ref()
6811 .unwrap()
6812 .registry()
6813 .cluster_fabric
6814 .snapshot()
6815 .revision,
6816 1
6817 );
6818 assert!(state
6819 .config
6820 .read()
6821 .await
6822 .cluster_fabric
6823 .node("live-node")
6824 .is_some());
6825 assert!(state
6826 .fabric_deployer
6827 .registry()
6828 .lock()
6829 .await
6830 .contains_key(&bamboo_server_tools::registry_keys::node_key("live-node")));
6831
6832 bamboo_config::clear_cluster_exact_commit_test_fault(dir.path());
6833 let stopped = state
6834 .fabric_deployer
6835 .stop_at_revision("live-node", 1)
6836 .await
6837 .unwrap();
6838 assert_eq!(stopped.snapshot.section.revision, 2);
6839 assert!(!state
6840 .fabric_deployer
6841 .registry()
6842 .lock()
6843 .await
6844 .contains_key(&bamboo_server_tools::registry_keys::node_key("live-node")));
6845 let deleted = state
6846 .delete_cluster_node_credentials(
6847 2,
6848 "live-node".to_string(),
6849 BTreeMap::from([(
6850 "live-node".to_string(),
6851 bamboo_config::ClusterNodeCredentialIntents::clear_all(),
6852 )]),
6853 |config| {
6854 config
6855 .cluster_fabric
6856 .nodes
6857 .retain(|node| node.id != "live-node");
6858 Ok(())
6859 },
6860 )
6861 .await
6862 .unwrap();
6863 assert_eq!(deleted.section.revision, 3);
6864 assert!(deleted.config.cluster_fabric.node("live-node").is_none());
6865 }
6866
6867 #[tokio::test]
6868 async fn unrelated_agent_registry_entry_does_not_block_cluster_reset() {
6869 let dir = tempfile::tempdir().unwrap();
6870 let state = AppState::new(dir.path().to_path_buf()).await.unwrap();
6871 state
6872 .update_cluster_fabric_credentials(
6873 0,
6874 BTreeMap::from([(
6875 "reset-node".to_string(),
6876 bamboo_config::ClusterNodeCredentialIntents::clear_all(),
6877 )]),
6878 |config| {
6879 config.cluster_fabric.nodes.push(bamboo_config::Node {
6880 id: "reset-node".to_string(),
6881 label: "reset-node".to_string(),
6882 placement: bamboo_config::NodePlacement::Local,
6883 trust_level: bamboo_config::TrustLevel::Trusted,
6884 deploy: bamboo_config::DeployProfile::default(),
6885 state: None,
6886 enabled: true,
6887 });
6888 Ok(())
6889 },
6890 )
6891 .await
6892 .unwrap();
6893 let agent_key = bamboo_server_tools::registry_keys::agent_key("unrelated-agent");
6894 insert_registry_worker(&state, agent_key.clone(), "unrelated-agent").await;
6895
6896 state
6897 .reset_credential_backed_section(SectionId::ClusterFabric, 1)
6898 .await
6899 .unwrap();
6900 assert_eq!(
6901 state
6902 .config_facade
6903 .as_ref()
6904 .unwrap()
6905 .registry()
6906 .cluster_fabric
6907 .snapshot()
6908 .revision,
6909 2
6910 );
6911 assert!(state.config.read().await.cluster_fabric.nodes.is_empty());
6912 let unrelated = state
6913 .fabric_deployer
6914 .registry()
6915 .lock()
6916 .await
6917 .remove(&agent_key)
6918 .expect("agent registry entry must survive cluster reset");
6919 unrelated.handle.shutdown().await;
6920 }
6921
6922 #[tokio::test]
6923 async fn operator_cluster_crud_recovers_before_finish_and_converges_once() {
6924 let _key = bamboo_config::encryption::set_test_encryption_key([0x72; 32]);
6925 let dir = tempfile::tempdir().unwrap();
6926 let state = AppState::new(dir.path().to_path_buf()).await.unwrap();
6927 let baseline_seq = state.account_sink.latest_seq();
6928 bamboo_config::set_cluster_exact_commit_test_fault(
6929 dir.path().to_path_buf(),
6930 bamboo_config::ClusterExactCommitTestFault::BeforeFinish,
6931 );
6932
6933 let committed = state
6934 .update_cluster_fabric_credentials(
6935 0,
6936 BTreeMap::from([(
6937 "recovered-crud-node".to_string(),
6938 bamboo_config::ClusterNodeCredentialIntents::clear_all(),
6939 )]),
6940 |config| {
6941 config.cluster_fabric.nodes.push(bamboo_config::Node {
6942 id: "recovered-crud-node".to_string(),
6943 label: "recovered-crud-node".to_string(),
6944 placement: bamboo_config::NodePlacement::Local,
6945 trust_level: bamboo_config::TrustLevel::Trusted,
6946 deploy: bamboo_config::DeployProfile::default(),
6947 state: None,
6948 enabled: true,
6949 });
6950 Ok(())
6951 },
6952 )
6953 .await
6954 .expect("operator CRUD must recover the committed transaction");
6955 assert_eq!(committed.section.revision, 1);
6956 assert_eq!(
6957 committed
6958 .config
6959 .cluster_fabric
6960 .node("recovered-crud-node")
6961 .unwrap()
6962 .label,
6963 "recovered-crud-node"
6964 );
6965 assert_eq!(
6966 state
6967 .config
6968 .read()
6969 .await
6970 .cluster_fabric
6971 .node("recovered-crud-node")
6972 .unwrap()
6973 .label,
6974 "recovered-crud-node"
6975 );
6976 assert_eq!(
6977 state
6978 .config_facade
6979 .as_ref()
6980 .unwrap()
6981 .registry()
6982 .cluster_fabric
6983 .snapshot()
6984 .revision,
6985 1
6986 );
6987 let reopened = bamboo_config::ConfigFacade::open(dir.path()).unwrap();
6988 assert_eq!(reopened.registry().cluster_fabric.snapshot().revision, 1);
6989 assert_eq!(
6990 reopened
6991 .effective_config()
6992 .cluster_fabric
6993 .node("recovered-crud-node")
6994 .unwrap()
6995 .label,
6996 "recovered-crud-node"
6997 );
6998 bamboo_config::ensure_provider_mcp_migration_ready(dir.path()).unwrap();
6999
7000 tokio::time::sleep(Duration::from_millis(100)).await;
7001 let events = bamboo_engine::events::journal::read_since(
7002 state.account_sink.events_dir(),
7003 baseline_seq,
7004 )
7005 .unwrap();
7006 let cluster_events = events
7007 .iter()
7008 .filter(|event| {
7009 matches!(
7010 &event.event,
7011 AgentEvent::ConfigChanged { section, revision }
7012 if section == "cluster-fabric" && *revision == 1
7013 )
7014 })
7015 .count();
7016 assert_eq!(cluster_events, 1);
7017 assert!(!events.iter().any(|event| {
7018 matches!(
7019 &event.event,
7020 AgentEvent::ConfigChanged { section, .. } if section == "credentials"
7021 )
7022 }));
7023 }
7024
7025 #[tokio::test]
7026 async fn cluster_replace_and_keep_noop_retain_the_exact_hydrated_runtime() {
7027 let _key = bamboo_config::encryption::set_test_encryption_key([0x73; 32]);
7028 let dir = tempfile::tempdir().unwrap();
7029 let state = AppState::new(dir.path().to_path_buf()).await.unwrap();
7030 let baseline_seq = state.account_sink.latest_seq();
7031 let password_ref = bamboo_config::cluster_password_credential_ref("secret-node").unwrap();
7032 let password_from = |config: &Config| match &config
7033 .cluster_fabric
7034 .node("secret-node")
7035 .expect("secret node exists")
7036 .placement
7037 {
7038 bamboo_config::NodePlacement::Ssh(target) => match &target.auth {
7039 bamboo_config::SshAuth::Password { password, .. } => password.clone(),
7040 _ => panic!("expected password authentication"),
7041 },
7042 _ => panic!("expected SSH placement"),
7043 };
7044
7045 let replaced = state
7046 .update_cluster_fabric_credentials(
7047 0,
7048 BTreeMap::from([(
7049 "secret-node".to_string(),
7050 bamboo_config::ClusterNodeCredentialIntents {
7051 password: bamboo_config::ClusterCredentialAction::Replace(
7052 "exact-password".to_string(),
7053 ),
7054 private_key: bamboo_config::ClusterCredentialAction::Clear,
7055 passphrase: bamboo_config::ClusterCredentialAction::Clear,
7056 },
7057 )]),
7058 |config| {
7059 config.cluster_fabric.nodes.push(bamboo_config::Node {
7060 id: "secret-node".to_string(),
7061 label: "secret-node".to_string(),
7062 placement: bamboo_config::NodePlacement::Ssh(bamboo_config::SshTarget {
7063 host: "secret.example.test".to_string(),
7064 port: 22,
7065 username: "operator".to_string(),
7066 auth: bamboo_config::SshAuth::Password {
7067 password: String::new(),
7068 password_encrypted: None,
7069 },
7070 host_key_fingerprint: None,
7071 }),
7072 trust_level: bamboo_config::TrustLevel::Trusted,
7073 deploy: bamboo_config::DeployProfile::default(),
7074 state: None,
7075 enabled: true,
7076 });
7077 Ok(())
7078 },
7079 )
7080 .await
7081 .unwrap();
7082 assert_eq!(replaced.section.revision, 1);
7083 assert_eq!(password_from(&replaced.config), "exact-password");
7084 assert_eq!(
7085 password_from(&*state.config.read().await),
7086 "exact-password",
7087 "live runtime must install the under-lock hydrated candidate"
7088 );
7089 assert_eq!(replaced.credential_health.revision, 1);
7090 assert_eq!(replaced.credential_statuses.len(), 1);
7091 assert_eq!(replaced.credential_statuses[0].credential_ref, password_ref);
7092 assert!(replaced.credential_statuses[0].configured);
7093
7094 tokio::time::sleep(Duration::from_millis(500)).await;
7095 let replace_events = bamboo_engine::events::journal::read_since(
7096 state.account_sink.events_dir(),
7097 baseline_seq,
7098 )
7099 .unwrap();
7100 let cluster_revisions = replace_events
7101 .iter()
7102 .filter_map(|event| match &event.event {
7103 AgentEvent::ConfigChanged { section, revision } if section == "cluster-fabric" => {
7104 Some(*revision)
7105 }
7106 _ => None,
7107 })
7108 .collect::<Vec<_>>();
7109 assert_eq!(cluster_revisions, vec![1]);
7110 assert!(!replace_events.iter().any(|event| {
7111 matches!(
7112 &event.event,
7113 AgentEvent::ConfigChanged { section, .. }
7114 | AgentEvent::ConfigInvalid { section, .. }
7115 | AgentEvent::ConfigRecovered { section, .. }
7116 if section == "credentials"
7117 )
7118 }));
7119
7120 let noop_baseline_seq = state.account_sink.latest_seq();
7121 let kept = state
7122 .update_cluster_fabric_credentials(
7123 1,
7124 BTreeMap::from([(
7125 "secret-node".to_string(),
7126 bamboo_config::ClusterNodeCredentialIntents {
7127 password: bamboo_config::ClusterCredentialAction::Keep,
7128 private_key: bamboo_config::ClusterCredentialAction::Clear,
7129 passphrase: bamboo_config::ClusterCredentialAction::Clear,
7130 },
7131 )]),
7132 |config| {
7133 let node = config
7134 .cluster_fabric
7135 .node_mut("secret-node")
7136 .expect("secret node exists");
7137 let bamboo_config::NodePlacement::Ssh(target) = &mut node.placement else {
7138 panic!("expected SSH placement")
7139 };
7140 let bamboo_config::SshAuth::Password {
7141 password,
7142 password_encrypted,
7143 } = &mut target.auth
7144 else {
7145 panic!("expected password authentication")
7146 };
7147 password.clear();
7148 *password_encrypted = None;
7149 Ok(())
7150 },
7151 )
7152 .await
7153 .unwrap();
7154 assert_eq!(kept.section.revision, 1);
7155 assert_eq!(kept.credential_health.revision, 1);
7156 assert_eq!(password_from(&kept.config), "exact-password");
7157 assert_eq!(
7158 password_from(&*state.config.read().await),
7159 "exact-password",
7160 "semantic no-op must retain the exact credential snapshot"
7161 );
7162
7163 tokio::time::sleep(Duration::from_millis(500)).await;
7164 let noop_events = bamboo_engine::events::journal::read_since(
7165 state.account_sink.events_dir(),
7166 noop_baseline_seq,
7167 )
7168 .unwrap();
7169 assert!(!noop_events.iter().any(|event| {
7170 matches!(
7171 &event.event,
7172 AgentEvent::ConfigChanged { section, .. }
7173 | AgentEvent::ConfigInvalid { section, .. }
7174 | AgentEvent::ConfigRecovered { section, .. }
7175 if section == "cluster-fabric" || section == "credentials"
7176 )
7177 }));
7178 }
7179
7180 #[tokio::test(flavor = "multi_thread", worker_threads = 4)]
7181 async fn later_external_credential_winner_remains_observable_after_exact_cluster_commit() {
7182 let _key = bamboo_config::encryption::set_test_encryption_key([0x74; 32]);
7183 let dir = tempfile::tempdir().unwrap();
7184 let state = AppState::new(dir.path().to_path_buf()).await.unwrap();
7185 let baseline_seq = state.account_sink.latest_seq();
7186 let password_ref =
7187 bamboo_config::cluster_password_credential_ref("credential-race-node").unwrap();
7188 let external_ref = password_ref.clone();
7189 let (external_done_tx, external_done_rx) = std::sync::mpsc::sync_channel(1);
7190 set_cluster_after_commit_before_adoption_test_hook(dir.path(), 0, move |data_dir| {
7191 let data_dir = data_dir.to_path_buf();
7192 let (started_tx, started_rx) = std::sync::mpsc::sync_channel(0);
7193 std::thread::spawn(move || {
7194 started_tx.send(()).unwrap();
7195 let result = bamboo_config::CredentialStore::open(&data_dir).replace(
7196 external_ref,
7197 "later-external-password",
7198 bamboo_config::CredentialSource::User,
7199 1,
7200 );
7201 external_done_tx.send(result).unwrap();
7202 });
7203 started_rx
7204 .recv_timeout(Duration::from_secs(5))
7205 .expect("external credential writer must launch under the commit lock");
7206 });
7207
7208 let committed = state
7209 .update_cluster_fabric_credentials(
7210 0,
7211 BTreeMap::from([(
7212 "credential-race-node".to_string(),
7213 bamboo_config::ClusterNodeCredentialIntents {
7214 password: bamboo_config::ClusterCredentialAction::Replace(
7215 "exact-commit-password".to_string(),
7216 ),
7217 private_key: bamboo_config::ClusterCredentialAction::Clear,
7218 passphrase: bamboo_config::ClusterCredentialAction::Clear,
7219 },
7220 )]),
7221 |config| {
7222 config.cluster_fabric.nodes.push(bamboo_config::Node {
7223 id: "credential-race-node".to_string(),
7224 label: "credential-race-node".to_string(),
7225 placement: bamboo_config::NodePlacement::Ssh(bamboo_config::SshTarget {
7226 host: "race.example.test".to_string(),
7227 port: 22,
7228 username: "operator".to_string(),
7229 auth: bamboo_config::SshAuth::Password {
7230 password: String::new(),
7231 password_encrypted: None,
7232 },
7233 host_key_fingerprint: None,
7234 }),
7235 trust_level: bamboo_config::TrustLevel::Trusted,
7236 deploy: bamboo_config::DeployProfile::default(),
7237 state: None,
7238 enabled: true,
7239 });
7240 Ok(())
7241 },
7242 )
7243 .await
7244 .unwrap();
7245 let committed_password = match &committed
7246 .config
7247 .cluster_fabric
7248 .node("credential-race-node")
7249 .unwrap()
7250 .placement
7251 {
7252 bamboo_config::NodePlacement::Ssh(target) => match &target.auth {
7253 bamboo_config::SshAuth::Password { password, .. } => password,
7254 _ => panic!("expected password authentication"),
7255 },
7256 _ => panic!("expected SSH placement"),
7257 };
7258 assert_eq!(committed.section.revision, 1);
7259 assert_eq!(committed.credential_health.revision, 1);
7260 assert_eq!(committed_password, "exact-commit-password");
7261
7262 let external_revision = tokio::task::spawn_blocking(move || {
7263 external_done_rx
7264 .recv_timeout(Duration::from_secs(10))
7265 .expect("external credential writer must complete")
7266 .unwrap()
7267 .0
7268 })
7269 .await
7270 .unwrap();
7271 assert_eq!(external_revision, 2);
7272
7273 tokio::time::timeout(Duration::from_secs(5), async {
7274 loop {
7275 let facade_revision = state
7276 .config_facade
7277 .as_ref()
7278 .unwrap()
7279 .registry()
7280 .credentials
7281 .snapshot()
7282 .revision;
7283 let events = bamboo_engine::events::journal::read_since(
7284 state.account_sink.events_dir(),
7285 baseline_seq,
7286 )
7287 .unwrap();
7288 let saw_external_event = events.iter().any(|event| {
7289 matches!(
7290 &event.event,
7291 AgentEvent::ConfigChanged { section, revision }
7292 if section == "credentials" && *revision == 2
7293 )
7294 });
7295 if facade_revision == 2 && saw_external_event {
7296 break;
7297 }
7298 tokio::time::sleep(Duration::from_millis(20)).await;
7299 }
7300 })
7301 .await
7302 .expect("watcher must expose the later credential revision");
7303 tokio::time::sleep(Duration::from_millis(250)).await;
7304
7305 let runtime_password = match &state
7306 .config
7307 .read()
7308 .await
7309 .cluster_fabric
7310 .node("credential-race-node")
7311 .unwrap()
7312 .placement
7313 {
7314 bamboo_config::NodePlacement::Ssh(target) => match &target.auth {
7315 bamboo_config::SshAuth::Password { password, .. } => password.clone(),
7316 _ => panic!("expected password authentication"),
7317 },
7318 _ => panic!("expected SSH placement"),
7319 };
7320 assert_eq!(
7321 runtime_password, "exact-commit-password",
7322 "a status-only credential event must not rewrite the exact cluster runtime"
7323 );
7324 let credential_dir = dir.path().to_path_buf();
7325 let durable_password = tokio::task::spawn_blocking(move || {
7326 bamboo_config::CredentialStore::open(credential_dir)
7327 .resolve(&password_ref)
7328 .unwrap()
7329 .unwrap()
7330 .expose()
7331 .to_string()
7332 })
7333 .await
7334 .unwrap();
7335 assert_eq!(durable_password, "later-external-password");
7336
7337 let events = bamboo_engine::events::journal::read_since(
7338 state.account_sink.events_dir(),
7339 baseline_seq,
7340 )
7341 .unwrap();
7342 let relevant = events
7343 .iter()
7344 .filter_map(|event| match &event.event {
7345 AgentEvent::ConfigChanged { section, revision }
7346 if section == "cluster-fabric" || section == "credentials" =>
7347 {
7348 Some((section.as_str(), *revision))
7349 }
7350 _ => None,
7351 })
7352 .collect::<Vec<_>>();
7353 assert_eq!(
7354 relevant,
7355 vec![("cluster-fabric", 1), ("credentials", 2)],
7356 "the exact cluster event must precede the genuine later credential winner"
7357 );
7358 }
7359
7360 #[tokio::test]
7361 async fn changed_cluster_commit_publishes_secret_free_runtime_before_materialization_error() {
7362 let _key = bamboo_config::encryption::set_test_encryption_key([0x75; 32]);
7363 let dir = tempfile::tempdir().unwrap();
7364 let state = AppState::new(dir.path().to_path_buf()).await.unwrap();
7365 let password_ref =
7366 bamboo_config::cluster_password_credential_ref("corrupt-secret-node").unwrap();
7367 state
7368 .update_cluster_fabric_credentials(
7369 0,
7370 BTreeMap::from([(
7371 "corrupt-secret-node".to_string(),
7372 bamboo_config::ClusterNodeCredentialIntents {
7373 password: bamboo_config::ClusterCredentialAction::Replace(
7374 "initial-password".to_string(),
7375 ),
7376 private_key: bamboo_config::ClusterCredentialAction::Clear,
7377 passphrase: bamboo_config::ClusterCredentialAction::Clear,
7378 },
7379 )]),
7380 |config| {
7381 config.cluster_fabric.nodes.push(bamboo_config::Node {
7382 id: "corrupt-secret-node".to_string(),
7383 label: "before-corruption".to_string(),
7384 placement: bamboo_config::NodePlacement::Ssh(bamboo_config::SshTarget {
7385 host: "corrupt.example.test".to_string(),
7386 port: 22,
7387 username: "operator".to_string(),
7388 auth: bamboo_config::SshAuth::Password {
7389 password: String::new(),
7390 password_encrypted: None,
7391 },
7392 host_key_fingerprint: None,
7393 }),
7394 trust_level: bamboo_config::TrustLevel::Trusted,
7395 deploy: bamboo_config::DeployProfile::default(),
7396 state: None,
7397 enabled: true,
7398 });
7399 Ok(())
7400 },
7401 )
7402 .await
7403 .unwrap();
7404
7405 let credentials_path = dir.path().join("credentials.json");
7406 let mut document: Value =
7407 serde_json::from_slice(&std::fs::read(&credentials_path).unwrap()).unwrap();
7408 document["data"]["entries"][password_ref.as_str()]["ciphertext"] =
7409 Value::String("corrupt-ciphertext".to_string());
7410 std::fs::write(
7411 &credentials_path,
7412 serde_json::to_vec_pretty(&document).unwrap(),
7413 )
7414 .unwrap();
7415 tokio::time::sleep(Duration::from_millis(300)).await;
7416
7417 let noop_baseline_seq = state.account_sink.latest_seq();
7418 let noop = state
7419 .update_cluster_fabric_credentials(1, BTreeMap::new(), |_| Ok(()))
7420 .await;
7421 match noop {
7422 Err(AppError::InternalError(_)) => {}
7423 Err(error) => panic!("no-op materialization error was misclassified: {error}"),
7424 Ok(_) => panic!("corrupt credential unexpectedly materialized"),
7425 }
7426 let runtime = state.config.read().await;
7427 let node = runtime.cluster_fabric.node("corrupt-secret-node").unwrap();
7428 let bamboo_config::NodePlacement::Ssh(target) = &node.placement else {
7429 panic!("expected SSH placement")
7430 };
7431 let bamboo_config::SshAuth::Password { password, .. } = &target.auth else {
7432 panic!("expected password authentication")
7433 };
7434 assert_eq!(
7435 password, "initial-password",
7436 "a true no-op materialization failure must preserve the old runtime"
7437 );
7438 drop(runtime);
7439 assert_eq!(
7440 state
7441 .config_facade
7442 .as_ref()
7443 .unwrap()
7444 .registry()
7445 .cluster_fabric
7446 .snapshot()
7447 .revision,
7448 1
7449 );
7450 let noop_events = bamboo_engine::events::journal::read_since(
7451 state.account_sink.events_dir(),
7452 noop_baseline_seq,
7453 )
7454 .unwrap();
7455 assert!(!noop_events.iter().any(|event| {
7456 matches!(
7457 &event.event,
7458 AgentEvent::ConfigChanged { section, .. } if section == "cluster-fabric"
7459 )
7460 }));
7461
7462 let baseline_seq = state.account_sink.latest_seq();
7463 let result = state
7464 .update_cluster_fabric_credentials(1, BTreeMap::new(), |config| {
7465 config
7466 .cluster_fabric
7467 .node_mut("corrupt-secret-node")
7468 .unwrap()
7469 .label = "committed-metadata".to_string();
7470 Ok(())
7471 })
7472 .await;
7473 match result {
7474 Err(AppError::InternalError(_)) => {}
7475 Err(error) => panic!("post-commit materialization error was misclassified: {error}"),
7476 Ok(_) => panic!("corrupt credential unexpectedly materialized"),
7477 }
7478
7479 let runtime = state.config.read().await;
7480 let node = runtime.cluster_fabric.node("corrupt-secret-node").unwrap();
7481 assert_eq!(node.label, "committed-metadata");
7482 let bamboo_config::NodePlacement::Ssh(target) = &node.placement else {
7483 panic!("expected SSH placement")
7484 };
7485 let bamboo_config::SshAuth::Password {
7486 password,
7487 password_encrypted,
7488 } = &target.auth
7489 else {
7490 panic!("expected password authentication")
7491 };
7492 assert!(password.is_empty());
7493 assert!(password_encrypted.is_none());
7494 drop(runtime);
7495
7496 let section = state
7497 .config_facade
7498 .as_ref()
7499 .unwrap()
7500 .registry()
7501 .cluster_fabric
7502 .snapshot();
7503 assert_eq!(section.revision, 2);
7504 assert_eq!(
7505 section.data.0.node("corrupt-secret-node").unwrap().label,
7506 "committed-metadata"
7507 );
7508 let events = bamboo_engine::events::journal::read_since(
7509 state.account_sink.events_dir(),
7510 baseline_seq,
7511 )
7512 .unwrap();
7513 let cluster_revisions = events
7514 .iter()
7515 .filter_map(|event| match &event.event {
7516 AgentEvent::ConfigChanged { section, revision } if section == "cluster-fabric" => {
7517 Some(*revision)
7518 }
7519 _ => None,
7520 })
7521 .collect::<Vec<_>>();
7522 assert_eq!(cluster_revisions, vec![2]);
7523 }
7524
7525 #[tokio::test(flavor = "multi_thread", worker_threads = 4)]
7526 async fn cluster_commit_adopts_exact_revision_before_later_external_winner() {
7527 let _key = bamboo_config::encryption::set_test_encryption_key([0x72; 32]);
7528 let dir = tempfile::tempdir().unwrap();
7529 let state = AppState::new(dir.path().to_path_buf()).await.unwrap();
7530 let baseline_seq = state.account_sink.latest_seq();
7531 let (external_done_tx, external_done_rx) = std::sync::mpsc::sync_channel(1);
7532 set_cluster_after_commit_before_adoption_test_hook(dir.path(), 0, move |data_dir| {
7533 let data_dir = data_dir.to_path_buf();
7534 let (started_tx, started_rx) = std::sync::mpsc::sync_channel(0);
7535 std::thread::spawn(move || {
7536 started_tx.send(()).unwrap();
7537 let external = bamboo_config::ConfigFacade::open(&data_dir).unwrap();
7538 let mut winner = external.effective_config();
7539 winner.cluster_fabric.node_mut("race-node").unwrap().label =
7540 "external-winner".to_string();
7541 let result =
7542 bamboo_config::persist_cluster_fabric_credential_transaction_at_revision(
7543 &data_dir,
7544 &mut winner,
7545 &BTreeMap::new(),
7546 1,
7547 );
7548 external_done_tx.send(result).unwrap();
7549 });
7550 started_rx
7551 .recv_timeout(Duration::from_secs(5))
7552 .expect("external writer must launch after the durable commit");
7553 });
7554
7555 let committed = state
7556 .update_cluster_fabric_credentials(
7557 0,
7558 BTreeMap::from([(
7559 "race-node".to_string(),
7560 bamboo_config::ClusterNodeCredentialIntents::clear_all(),
7561 )]),
7562 |config| {
7563 config.cluster_fabric.nodes.push(bamboo_config::Node {
7564 id: "race-node".to_string(),
7565 label: "api-commit".to_string(),
7566 placement: bamboo_config::NodePlacement::Local,
7567 trust_level: bamboo_config::TrustLevel::Trusted,
7568 deploy: bamboo_config::DeployProfile::default(),
7569 state: None,
7570 enabled: true,
7571 });
7572 Ok(())
7573 },
7574 )
7575 .await
7576 .unwrap();
7577 assert_eq!(committed.section.revision, 1);
7578 assert_eq!(
7579 committed
7580 .config
7581 .cluster_fabric
7582 .node("race-node")
7583 .unwrap()
7584 .label,
7585 "api-commit",
7586 "the response must remain bound to its exact committed candidate"
7587 );
7588 assert_eq!(
7589 tokio::task::spawn_blocking(move || {
7590 external_done_rx
7591 .recv_timeout(Duration::from_secs(10))
7592 .expect("later external winner must complete")
7593 .unwrap()
7594 })
7595 .await
7596 .unwrap(),
7597 2
7598 );
7599
7600 tokio::time::timeout(Duration::from_secs(5), async {
7601 loop {
7602 let facade_revision = state
7603 .config_facade
7604 .as_ref()
7605 .unwrap()
7606 .registry()
7607 .cluster_fabric
7608 .snapshot()
7609 .revision;
7610 let runtime_label = state
7611 .config
7612 .read()
7613 .await
7614 .cluster_fabric
7615 .node("race-node")
7616 .map(|node| node.label.clone());
7617 if facade_revision == 2 && runtime_label.as_deref() == Some("external-winner") {
7618 break;
7619 }
7620 tokio::time::sleep(Duration::from_millis(20)).await;
7621 }
7622 })
7623 .await
7624 .expect("watcher must apply the later external revision");
7625
7626 let events = bamboo_engine::events::journal::read_since(
7627 state.account_sink.events_dir(),
7628 baseline_seq,
7629 )
7630 .unwrap();
7631 let revisions = events
7632 .iter()
7633 .filter_map(|event| match &event.event {
7634 AgentEvent::ConfigChanged { section, revision } if section == "cluster-fabric" => {
7635 Some(*revision)
7636 }
7637 _ => None,
7638 })
7639 .collect::<Vec<_>>();
7640 assert_eq!(
7641 revisions,
7642 vec![1, 2],
7643 "the exact API event must precede the later watcher winner exactly once"
7644 );
7645 assert!(!events.iter().any(|event| {
7646 matches!(
7647 &event.event,
7648 AgentEvent::ConfigChanged { section, .. } if section == "credentials"
7649 )
7650 }));
7651 }
7652
7653 async fn wait_for_facade_health(
7654 state: &AppState,
7655 id: SectionId,
7656 status: SectionStatus,
7657 revision: u64,
7658 ) -> bamboo_config::SectionHealth {
7659 tokio::time::timeout(Duration::from_secs(4), async {
7660 loop {
7661 let health = state
7662 .config_facade
7663 .as_ref()
7664 .expect("production state owns a facade")
7665 .registry()
7666 .health()
7667 .unwrap()
7668 .into_iter()
7669 .find(|health| health.section == id)
7670 .unwrap();
7671 if health.status == status && health.revision == revision {
7672 break health;
7673 }
7674 tokio::time::sleep(Duration::from_millis(20)).await;
7675 }
7676 })
7677 .await
7678 .expect("facade health transition timed out")
7679 }
7680
7681 #[test]
7682 fn initial_provider_health_validates_primary_and_backup() {
7683 let dir = tempfile::tempdir().unwrap();
7684 let store = AtomicJsonStore::new(dir.path().join("providers.json"), 1);
7685 let missing = initial_provider_health(&store);
7686 assert_eq!(missing.status, SectionStatus::Missing);
7687 assert_eq!(missing.source_kind, SectionSourceKind::Default);
7688
7689 std::fs::write(dir.path().join("providers.json"), b"{broken").unwrap();
7690 let invalid = initial_provider_health(&store);
7691 assert_eq!(invalid.status, SectionStatus::Invalid);
7692 assert_eq!(invalid.source_kind, SectionSourceKind::File);
7693
7694 std::fs::write(dir.path().join("providers.json.bak"), b"{}").unwrap();
7695 let recovered = initial_provider_health(&store);
7696 assert_eq!(recovered.status, SectionStatus::Degraded);
7697 assert_eq!(recovered.source_kind, SectionSourceKind::Backup);
7698 assert!(recovered
7699 .last_error
7700 .as_deref()
7701 .unwrap()
7702 .contains("last-known-good backup"));
7703
7704 std::fs::write(dir.path().join("providers.json"), b"{}").unwrap();
7705 let healthy = initial_provider_health(&store);
7706 assert_eq!(healthy.status, SectionStatus::Healthy);
7707 assert_eq!(healthy.source_kind, SectionSourceKind::File);
7708 }
7709
7710 #[tokio::test]
7711 async fn unrecoverable_pending_manifest_never_publishes_partial_provider_state() {
7712 let _key = bamboo_config::encryption::set_test_encryption_key([0x6c; 32]);
7713 let dir = tempfile::tempdir().unwrap();
7714 install_unrecoverable_pending_provider_migration(dir.path());
7715
7716 let loaded = Config::from_data_dir_without_env(Some(dir.path().to_path_buf()));
7717 assert_eq!(
7718 loaded.providers().openai.as_ref().unwrap().model.as_deref(),
7719 Some("root-lkg")
7720 );
7721 let store = AtomicJsonStore::new(dir.path().join("providers.json"), 1);
7722 let health = initial_provider_health(&store);
7723 assert_eq!(health.status, SectionStatus::Degraded);
7724 assert!(health
7725 .last_error
7726 .as_deref()
7727 .unwrap()
7728 .contains("migration is pending"));
7729 let error = match load_and_prepare_provider_candidate(&store, 0, loaded).await {
7730 Ok(_) => panic!("pending migration must reject provider candidate"),
7731 Err(error) => error,
7732 };
7733 assert!(error.message.contains("retaining last-known-good runtime"));
7734 assert!(!error.message.contains("partial-must-not-load"));
7735
7736 let state = AppState::new(dir.path().to_path_buf()).await.unwrap();
7737 assert_eq!(
7738 state
7739 .config
7740 .read()
7741 .await
7742 .providers()
7743 .openai
7744 .as_ref()
7745 .unwrap()
7746 .model
7747 .as_deref(),
7748 Some("root-lkg")
7749 );
7750 assert_eq!(
7751 state
7752 .config_live_health
7753 .read()
7754 .unwrap_or_else(|poisoned| poisoned.into_inner())
7755 .status,
7756 SectionStatus::Degraded
7757 );
7758 }
7759
7760 #[async_trait::async_trait]
7761 impl LLMProvider for WorkingProvider {
7762 async fn chat_stream(
7763 &self,
7764 _messages: &[Message],
7765 _tools: &[ToolSchema],
7766 _max_output_tokens: Option<u32>,
7767 _model: &str,
7768 ) -> Result<LLMStream, LLMError> {
7769 Err(LLMError::Api("working-provider-marker".to_string()))
7770 }
7771 }
7772
7773 #[tokio::test]
7774 async fn cancelled_provider_put_cannot_commit_before_publication_guards() {
7775 let _key = bamboo_config::encryption::set_test_encryption_key([0x53; 32]);
7776 let dir = tempfile::tempdir().unwrap();
7777 let state = AppState::new(dir.path().to_path_buf()).await.unwrap();
7778 let secret = "provider-cancel-secret";
7779 let reference = bamboo_config::credential_ref("provider", "openai", "api_key").unwrap();
7780 bamboo_config::CredentialStore::open(dir.path())
7781 .replace(
7782 reference.clone(),
7783 secret,
7784 bamboo_config::CredentialSource::User,
7785 0,
7786 )
7787 .unwrap();
7788 {
7789 let mut config = state.config.write().await;
7790 config.provider = "openai".to_string();
7791 *config.providers_mut() = ProviderConfigs {
7792 openai: Some(bamboo_config::OpenAIConfig {
7793 api_key: secret.to_string(),
7794 credential_ref: Some(reference),
7795 ..Default::default()
7796 }),
7797 ..Default::default()
7798 };
7799 }
7800 let provider_lock = state.provider.clone();
7801 let held_provider = provider_lock.write().await;
7802 let providers_before = std::fs::read(dir.path().join("providers.json")).unwrap();
7803 let mut operation = Box::pin(state.put_provider_section(
7804 0,
7805 ProviderConfigs {
7806 openai: Some(bamboo_config::OpenAIConfig {
7807 model: Some("candidate-model".to_string()),
7808 ..Default::default()
7809 }),
7810 ..Default::default()
7811 },
7812 ));
7813
7814 assert!(
7815 tokio::time::timeout(Duration::from_millis(500), &mut operation)
7816 .await
7817 .is_err()
7818 );
7819 drop(operation);
7820 assert_eq!(
7821 std::fs::read(dir.path().join("providers.json")).unwrap(),
7822 providers_before,
7823 "cancellation while waiting for publication guards must precede durable commit"
7824 );
7825 drop(held_provider);
7826 }
7827
7828 #[test]
7829 fn cancelled_provider_settings_request_finishes_exact_commit_and_live_publication() {
7830 let _key = bamboo_config::encryption::set_test_encryption_key([0x71; 32]);
7831 let runtime = tokio::runtime::Builder::new_multi_thread()
7832 .worker_threads(2)
7833 .max_blocking_threads(1)
7834 .enable_all()
7835 .build()
7836 .unwrap();
7837 runtime.block_on(async {
7838 let dir = tempfile::tempdir().unwrap();
7839 let state = Arc::new(AppState::new(dir.path().to_path_buf()).await.unwrap());
7840 wait_for_mcp_health(&state, SectionStatus::Healthy, 0).await;
7841
7842 let (started_tx, started_rx) = tokio::sync::oneshot::channel();
7843 let (release_tx, release_rx) = std::sync::mpsc::sync_channel(0);
7844 let blocker = tokio::task::spawn_blocking(move || {
7845 let _ = started_tx.send(());
7846 release_rx.recv().unwrap();
7847 });
7848 started_rx.await.unwrap();
7849
7850 let operation_state = state.clone();
7851 let operation = tokio::spawn(async move {
7852 operation_state
7853 .put_provider_settings(0, |_current, candidate| {
7854 candidate.provider = "openai".to_string();
7855 candidate.providers_mut().openai = Some(bamboo_config::OpenAIConfig {
7856 api_key: "provider-settings-cancel-secret".to_string(),
7857 model: Some("provider-settings-cancel-model".to_string()),
7858 ..Default::default()
7859 });
7860 Ok((BTreeSet::from(["openai".to_string()]), BTreeSet::new()))
7861 })
7862 .await
7863 });
7864
7865 tokio::time::timeout(Duration::from_secs(1), async {
7866 loop {
7867 if state.config_io_lock.try_lock().is_err() {
7868 break;
7869 }
7870 assert!(!operation.is_finished());
7871 tokio::task::yield_now().await;
7872 }
7873 })
7874 .await
7875 .expect("provider settings mutation acquires the config IO lock");
7876 operation.abort();
7877 let _ = operation.await;
7878 release_tx.send(()).unwrap();
7879 blocker.await.unwrap();
7880
7881 tokio::time::timeout(Duration::from_secs(5), async {
7882 loop {
7883 let committed = std::fs::read(dir.path().join("providers.json"))
7884 .ok()
7885 .and_then(|bytes| serde_json::from_slice::<Value>(&bytes).ok())
7886 .is_some_and(|value| {
7887 value["revision"] == 1
7888 && value["data"]["openai"]["model"]
7889 == "provider-settings-cancel-model"
7890 });
7891 if committed {
7892 break;
7893 }
7894 tokio::task::yield_now().await;
7895 }
7896 })
7897 .await
7898 .expect("owned provider settings transaction completes after cancellation");
7899
7900 let converged =
7901 tokio::time::timeout(Duration::from_secs(5), state.config_io_lock.lock())
7902 .await
7903 .expect("owned provider runtime publication completes after cancellation");
7904 drop(converged);
7905 let live = state.config.read().await;
7906 let openai = live.providers().openai.as_ref().unwrap();
7907 assert_eq!(
7908 openai.model.as_deref(),
7909 Some("provider-settings-cancel-model")
7910 );
7911 assert_eq!(openai.api_key, "provider-settings-cancel-secret");
7912 drop(live);
7913 let providers = std::fs::read_to_string(dir.path().join("providers.json")).unwrap();
7914 let credentials = std::fs::read_to_string(dir.path().join("credentials.json")).unwrap();
7915 assert!(!providers.contains("provider-settings-cancel-secret"));
7916 assert!(!credentials.contains("provider-settings-cancel-secret"));
7917 });
7918 }
7919
7920 #[test]
7921 fn cancelled_proxy_update_cannot_leave_durable_state_ahead_of_live_snapshot() {
7922 let runtime = tokio::runtime::Builder::new_multi_thread()
7923 .worker_threads(2)
7924 .max_blocking_threads(1)
7925 .enable_all()
7926 .build()
7927 .unwrap();
7928 runtime.block_on(async {
7929 let dir = tempfile::tempdir().unwrap();
7930 let state = Arc::new(AppState::new(dir.path().to_path_buf()).await.unwrap());
7931 wait_for_mcp_health(&state, SectionStatus::Healthy, 0).await;
7932
7933 let (started_tx, started_rx) = tokio::sync::oneshot::channel();
7937 let (release_tx, release_rx) = std::sync::mpsc::sync_channel(0);
7938 let blocker = tokio::task::spawn_blocking(move || {
7939 let _ = started_tx.send(());
7940 release_rx.recv().unwrap();
7941 });
7942 started_rx.await.unwrap();
7943
7944 let operation_state = state.clone();
7945 let operation = tokio::spawn(async move {
7946 operation_state
7947 .update_proxy_auth_credential(
7948 Some(bamboo_config::ProxyAuth {
7949 username: "cancel-user".to_string(),
7950 password: "cancel-secret".to_string(),
7951 }),
7952 0,
7953 ConfigUpdateEffects {
7954 reload_provider: true,
7955 reconcile_mcp: true,
7956 },
7957 )
7958 .await
7959 });
7960
7961 tokio::time::timeout(Duration::from_secs(1), async {
7966 loop {
7967 if state.config_io_lock.try_lock().is_err() {
7968 break;
7969 }
7970 assert!(!operation.is_finished());
7971 tokio::task::yield_now().await;
7972 }
7973 })
7974 .await
7975 .expect("proxy mutation acquires the config IO lock");
7976 operation.abort();
7977 let _ = operation.await;
7978 release_tx.send(()).unwrap();
7979 blocker.await.unwrap();
7980
7981 tokio::time::timeout(Duration::from_secs(5), async {
7982 loop {
7983 let credentials_ready = std::fs::read(dir.path().join("credentials.json"))
7984 .ok()
7985 .and_then(|bytes| serde_json::from_slice::<serde_json::Value>(&bytes).ok())
7986 .and_then(|value| value.get("revision").and_then(|value| value.as_u64()))
7987 == Some(1);
7988 let config_ready = std::fs::read(dir.path().join("core.json"))
7989 .ok()
7990 .and_then(|bytes| serde_json::from_slice::<serde_json::Value>(&bytes).ok())
7991 .and_then(|value| {
7992 value
7993 .get("data")
7994 .and_then(|value| value.get("proxy_auth_credential_ref"))
7995 .and_then(|value| value.as_str())
7996 .map(str::to_string)
7997 })
7998 .as_deref()
7999 == Some("proxy.default.auth");
8000 if credentials_ready && config_ready {
8001 break;
8002 }
8003 tokio::task::yield_now().await;
8004 }
8005 })
8006 .await
8007 .expect("owned durable transaction completes after caller cancellation");
8008
8009 let converged =
8013 tokio::time::timeout(Duration::from_secs(5), state.config_io_lock.lock())
8014 .await
8015 .expect("owned runtime convergence completes after cancellation");
8016 drop(converged);
8017
8018 let live = state.config.read().await;
8019 assert_eq!(
8020 live.proxy_auth_credential_ref
8021 .as_ref()
8022 .map(|reference| reference.as_str()),
8023 Some("proxy.default.auth")
8024 );
8025 let auth = live
8026 .proxy_auth
8027 .as_ref()
8028 .expect("durable proxy auth must be published despite cancellation");
8029 assert_eq!(auth.username, "cancel-user");
8030 assert_eq!(auth.password, "cancel-secret");
8031 drop(live);
8032
8033 let root = std::fs::read_to_string(dir.path().join("core.json")).unwrap();
8034 let credentials = std::fs::read_to_string(dir.path().join("credentials.json")).unwrap();
8035 assert!(!root.contains("cancel-secret"));
8036 assert!(!credentials.contains("cancel-secret"));
8037 });
8038 }
8039
8040 #[tokio::test]
8041 async fn missing_or_corrupt_referenced_credentials_reject_candidates_redacted() {
8042 let _key = bamboo_config::encryption::set_test_encryption_key([0x68; 32]);
8043 for corrupt_credentials in [false, true] {
8044 let dir = tempfile::tempdir().unwrap();
8045 let reference = bamboo_config::credential_ref("provider", "openai", "api_key").unwrap();
8046 let providers = ProviderConfigs {
8047 openai: Some(bamboo_config::OpenAIConfig {
8048 credential_ref: Some(reference),
8049 model: Some("candidate".to_string()),
8050 ..Default::default()
8051 }),
8052 ..Default::default()
8053 };
8054 let provider_store = AtomicJsonStore::new(dir.path().join("providers.json"), 1);
8055 provider_store
8056 .commit(0, providers, validate_provider_config)
8057 .unwrap();
8058 if corrupt_credentials {
8059 std::fs::write(dir.path().join("credentials.json"), b"{corrupt-secret").unwrap();
8060 }
8061 let error =
8062 match load_and_prepare_provider_candidate(&provider_store, 0, Config::default())
8063 .await
8064 {
8065 Ok(_) => panic!("unavailable credential must reject provider candidate"),
8066 Err(error) => error,
8067 };
8068 assert_eq!(error.message, "provider credential is unavailable");
8069 assert!(!error
8070 .message
8071 .contains(dir.path().to_string_lossy().as_ref()));
8072
8073 let mut mcp = disabled_mcp_config("credential-lkg");
8074 let TransportConfig::Stdio(stdio) = &mut mcp.servers[0].transport else {
8075 unreachable!()
8076 };
8077 stdio.env_credential_refs.insert(
8078 "TOKEN".to_string(),
8079 bamboo_config::credential_ref("mcp", "credential-lkg", "env_TOKEN")
8080 .unwrap()
8081 .as_str()
8082 .to_string(),
8083 );
8084 let mcp_store = AtomicJsonStore::new(dir.path().join("mcp.json"), 1);
8085 mcp_store.commit(0, mcp, validate_mcp_config).unwrap();
8086 let error = match load_and_validate_mcp_candidate(
8087 &mcp_store,
8088 0,
8089 Config::default(),
8090 false,
8091 )
8092 .await
8093 {
8094 Ok(_) => panic!("unavailable credential must reject MCP candidate"),
8095 Err(error) => error,
8096 };
8097 assert_eq!(error.message, "MCP credential is unavailable");
8098 assert!(!error.message.contains("TOKEN"));
8099 assert!(!error
8100 .message
8101 .contains(dir.path().to_string_lossy().as_ref()));
8102 }
8103 }
8104
8105 #[tokio::test]
8106 async fn typed_provider_put_switches_refs_and_rejects_missing_ref_without_mutation() {
8107 let _key = bamboo_config::encryption::set_test_encryption_key([0x6d; 32]);
8108 let dir = tempfile::tempdir().unwrap();
8109 let state = AppState::new(dir.path().to_path_buf()).await.unwrap();
8110 let ref_a = bamboo_config::credential_ref("provider", "openai-a", "api_key").unwrap();
8111 let ref_b = bamboo_config::credential_ref("provider", "openai-b", "api_key").unwrap();
8112 let missing = bamboo_config::credential_ref("provider", "missing", "api_key").unwrap();
8113 state
8114 .credential_store
8115 .replace(
8116 ref_a.clone(),
8117 "provider-secret-a",
8118 bamboo_config::CredentialSource::User,
8119 0,
8120 )
8121 .unwrap();
8122 state
8123 .credential_store
8124 .replace(
8125 ref_b.clone(),
8126 "provider-secret-b",
8127 bamboo_config::CredentialSource::User,
8128 1,
8129 )
8130 .unwrap();
8131 {
8132 let mut config = state.config.write().await;
8133 config.provider = "openai".to_string();
8134 *config.providers_mut() = ProviderConfigs {
8135 openai: Some(bamboo_config::OpenAIConfig {
8136 api_key: "provider-secret-a".to_string(),
8137 credential_ref: Some(ref_a),
8138 ..Default::default()
8139 }),
8140 ..Default::default()
8141 };
8142 }
8143
8144 let revision = state
8145 .put_provider_section(
8146 0,
8147 ProviderConfigs {
8148 openai: Some(bamboo_config::OpenAIConfig {
8149 credential_ref: Some(ref_b.clone()),
8150 model: Some("switched".to_string()),
8151 ..Default::default()
8152 }),
8153 ..Default::default()
8154 },
8155 )
8156 .await
8157 .unwrap();
8158 assert_eq!(revision, 1);
8159 let runtime = state.config.read().await;
8160 let openai = runtime.providers().openai.as_ref().unwrap();
8161 assert_eq!(openai.credential_ref.as_ref(), Some(&ref_b));
8162 assert_eq!(openai.api_key, "provider-secret-b");
8163 drop(runtime);
8164 let disk_before = std::fs::read(dir.path().join("providers.json")).unwrap();
8165
8166 assert!(state
8167 .put_provider_section(
8168 1,
8169 ProviderConfigs {
8170 openai: Some(bamboo_config::OpenAIConfig {
8171 credential_ref: Some(missing),
8172 model: Some("must-not-publish".to_string()),
8173 ..Default::default()
8174 }),
8175 ..Default::default()
8176 },
8177 )
8178 .await
8179 .is_err());
8180 assert_eq!(
8181 std::fs::read(dir.path().join("providers.json")).unwrap(),
8182 disk_before
8183 );
8184 let runtime = state.config.read().await;
8185 let openai = runtime.providers().openai.as_ref().unwrap();
8186 assert_eq!(openai.credential_ref.as_ref(), Some(&ref_b));
8187 assert_eq!(openai.api_key, "provider-secret-b");
8188 }
8189
8190 #[tokio::test]
8191 async fn typed_mcp_put_switches_stdio_and_header_refs_atomically() {
8192 let _key = bamboo_config::encryption::set_test_encryption_key([0x6e; 32]);
8193 let dir = tempfile::tempdir().unwrap();
8194 let state = AppState::new(dir.path().to_path_buf()).await.unwrap();
8195 let refs = [
8196 bamboo_config::credential_ref("mcp", "stdio-a", "env_TOKEN").unwrap(),
8197 bamboo_config::credential_ref("mcp", "header-a", "header_Authorization").unwrap(),
8198 bamboo_config::credential_ref("mcp", "stdio-b", "env_TOKEN").unwrap(),
8199 bamboo_config::credential_ref("mcp", "header-b", "header_Authorization").unwrap(),
8200 ];
8201 for (revision, (reference, value)) in refs
8202 .iter()
8203 .zip(["env-a", "header-a", "env-b", "header-b"])
8204 .enumerate()
8205 {
8206 state
8207 .credential_store
8208 .replace(
8209 reference.clone(),
8210 value,
8211 bamboo_config::CredentialSource::User,
8212 revision as u64,
8213 )
8214 .unwrap();
8215 }
8216 let make_config = |env_ref: &bamboo_config::CredentialRef,
8217 header_ref: &bamboo_config::CredentialRef| {
8218 McpConfig {
8219 version: 1,
8220 servers: vec![
8221 McpServerConfig {
8222 id: "switch-stdio".to_string(),
8223 name: None,
8224 enabled: false,
8225 transport: TransportConfig::Stdio(StdioConfig {
8226 command: "unused-disabled-command".to_string(),
8227 args: vec![],
8228 cwd: None,
8229 env: std::collections::HashMap::new(),
8230 env_encrypted: std::collections::HashMap::new(),
8231 env_credential_refs: std::collections::HashMap::from([(
8232 "TOKEN".to_string(),
8233 env_ref.as_str().to_string(),
8234 )]),
8235 startup_timeout_ms: 100,
8236 }),
8237 request_timeout_ms: 100,
8238 healthcheck_interval_ms: 100,
8239 reconnect: ReconnectConfig::default(),
8240 allowed_tools: vec![],
8241 denied_tools: vec![],
8242 },
8243 McpServerConfig {
8244 id: "switch-header".to_string(),
8245 name: None,
8246 enabled: false,
8247 transport: TransportConfig::Sse(bamboo_mcp::SseConfig {
8248 url: "https://example.test/sse".to_string(),
8249 headers: vec![bamboo_mcp::HeaderConfig {
8250 name: "Authorization".to_string(),
8251 value: String::new(),
8252 value_encrypted: None,
8253 credential_ref: Some(header_ref.as_str().to_string()),
8254 }],
8255 connect_timeout_ms: 100,
8256 }),
8257 request_timeout_ms: 100,
8258 healthcheck_interval_ms: 100,
8259 reconnect: ReconnectConfig::default(),
8260 allowed_tools: vec![],
8261 denied_tools: vec![],
8262 },
8263 ],
8264 }
8265 };
8266 let mut current = make_config(&refs[0], &refs[1]);
8267 if let TransportConfig::Stdio(stdio) = &mut current.servers[0].transport {
8268 stdio.env.insert("TOKEN".to_string(), "env-a".to_string());
8269 }
8270 if let TransportConfig::Sse(sse) = &mut current.servers[1].transport {
8271 sse.headers[0].value = "header-a".to_string();
8272 }
8273 state.config.write().await.mcp = current;
8274
8275 assert_eq!(
8276 state
8277 .put_mcp_section(0, make_config(&refs[2], &refs[3]))
8278 .await
8279 .unwrap(),
8280 1
8281 );
8282 let runtime = state.config.read().await;
8283 let TransportConfig::Stdio(stdio) = &runtime.mcp.servers[0].transport else {
8284 panic!("stdio transport")
8285 };
8286 assert_eq!(stdio.env["TOKEN"], "env-b");
8287 let TransportConfig::Sse(sse) = &runtime.mcp.servers[1].transport else {
8288 panic!("sse transport")
8289 };
8290 assert_eq!(sse.headers[0].value, "header-b");
8291 drop(runtime);
8292 let disk_before = std::fs::read(dir.path().join("mcp.json")).unwrap();
8293 let missing_env = bamboo_config::credential_ref("mcp", "missing", "env_TOKEN").unwrap();
8294 let missing_header =
8295 bamboo_config::credential_ref("mcp", "missing", "header_Authorization").unwrap();
8296 assert!(state
8297 .put_mcp_section(1, make_config(&missing_env, &missing_header))
8298 .await
8299 .is_err());
8300 assert_eq!(
8301 std::fs::read(dir.path().join("mcp.json")).unwrap(),
8302 disk_before
8303 );
8304 let runtime = state.config.read().await;
8305 let TransportConfig::Stdio(stdio) = &runtime.mcp.servers[0].transport else {
8306 panic!("stdio transport")
8307 };
8308 assert_eq!(stdio.env["TOKEN"], "env-b");
8309 }
8310
8311 #[tokio::test]
8312 async fn failed_candidate_keeps_existing_provider_registry_and_runtime() {
8313 let dir = tempfile::tempdir().unwrap();
8314 let state = AppState::new(dir.path().to_path_buf()).await.unwrap();
8315 let working: Arc<dyn LLMProvider> = Arc::new(WorkingProvider);
8316 state
8317 .provider_registry
8318 .insert("working".to_string(), working.clone());
8319 state.provider_registry.set_default("working".to_string());
8320 *state.provider.write().await = working.clone();
8321 state.config.write().await.provider = "openai".to_string();
8322
8323 assert!(state.reload_provider().await.is_err());
8324 assert_eq!(state.provider_registry.default_provider_name(), "working");
8325 assert!(Arc::ptr_eq(
8326 &state.provider_registry.get_default().unwrap(),
8327 &working
8328 ));
8329 let live = state.provider.read().await;
8330 assert!(Arc::ptr_eq(&*live, &working));
8331 }
8332
8333 #[tokio::test]
8334 async fn provider_watcher_retains_lkg_on_invalid_and_recovers_after_repair() {
8335 let _key = bamboo_config::encryption::set_test_encryption_key([0x43; 32]);
8336 let dir = tempfile::tempdir().unwrap();
8337 let state = AppState::new(dir.path().to_path_buf()).await.unwrap();
8338 let working: Arc<dyn LLMProvider> = Arc::new(WorkingProvider);
8339 state
8340 .provider_registry
8341 .insert("working".to_string(), working.clone());
8342 state.provider_registry.set_default("working".to_string());
8343 *state.provider.write().await = working.clone();
8344 state.config.write().await.provider = "openai".to_string();
8345 wait_for_mcp_health(&state, SectionStatus::Healthy, 0).await;
8346 let mut feed = state.account_sink.subscribe();
8347 let providers_path = dir.path().join("providers.json");
8348
8349 std::fs::write(&providers_path, b"{broken").unwrap();
8350 tokio::time::timeout(Duration::from_secs(3), async {
8351 loop {
8352 if state
8353 .config_live_health
8354 .read()
8355 .unwrap_or_else(|poisoned| poisoned.into_inner())
8356 .status
8357 == SectionStatus::Invalid
8358 {
8359 break;
8360 }
8361 tokio::time::sleep(Duration::from_millis(20)).await;
8362 }
8363 })
8364 .await
8365 .unwrap();
8366 assert_eq!(
8367 state
8368 .config_live_health
8369 .read()
8370 .unwrap_or_else(|poisoned| poisoned.into_inner())
8371 .revision,
8372 0,
8373 "invalid edits must not advance the LKG revision"
8374 );
8375 {
8376 let health = state
8377 .config_live_health
8378 .read()
8379 .unwrap_or_else(|poisoned| poisoned.into_inner());
8380 assert_eq!(health.status, SectionStatus::Invalid);
8381 assert_eq!(health.source_kind, SectionSourceKind::File);
8382 assert_eq!(health.source_path, providers_path);
8383 }
8384 assert!(Arc::ptr_eq(
8385 &state.provider_registry.get_default().unwrap(),
8386 &working
8387 ));
8388 let invalid = next_config_event(&mut feed, "providers").await;
8389 assert!(matches!(
8390 invalid,
8391 AgentEvent::ConfigInvalid { revision: 0, .. }
8392 ));
8393
8394 let reference = bamboo_config::credential_ref("provider", "openai", "api_key").unwrap();
8395 let credential_store = bamboo_config::CredentialStore::open(dir.path());
8396 let credential_revision = credential_store.revision().unwrap();
8397 credential_store
8398 .replace(
8399 reference.clone(),
8400 "watcher-test-key",
8401 bamboo_config::CredentialSource::User,
8402 credential_revision,
8403 )
8404 .unwrap();
8405 let providers = ProviderConfigs {
8406 openai: Some(bamboo_config::OpenAIConfig {
8407 credential_ref: Some(reference),
8408 ..Default::default()
8409 }),
8410 ..Default::default()
8411 };
8412 std::fs::write(
8413 &providers_path,
8414 serde_json::to_vec_pretty(&providers).unwrap(),
8415 )
8416 .unwrap();
8417 tokio::time::timeout(Duration::from_secs(3), async {
8418 loop {
8419 let health = state
8420 .config_live_health
8421 .read()
8422 .unwrap_or_else(|poisoned| poisoned.into_inner())
8423 .clone();
8424 if health.status == SectionStatus::Healthy && health.revision == 1 {
8425 break;
8426 }
8427 tokio::time::sleep(Duration::from_millis(20)).await;
8428 }
8429 })
8430 .await
8431 .unwrap();
8432 let recovered = next_config_event(&mut feed, "providers").await;
8433 assert!(matches!(
8434 recovered,
8435 AgentEvent::ConfigRecovered { revision: 1, .. }
8436 ));
8437 assert_eq!(state.provider_registry.default_provider_name(), "openai");
8438 }
8439
8440 #[tokio::test]
8441 async fn ordinary_section_watcher_updates_runtime_retains_lkg_and_recovers() {
8442 let _key = bamboo_config::encryption::set_test_encryption_key([0x44; 32]);
8443 let dir = tempfile::tempdir().unwrap();
8444 let state = AppState::new(dir.path().to_path_buf()).await.unwrap();
8445 wait_for_mcp_health(&state, SectionStatus::Healthy, 0).await;
8446 let mut feed = state.account_sink.subscribe();
8447 let path = dir.path().join("core.json");
8448 let mut document: serde_json::Value =
8449 serde_json::from_slice(&std::fs::read(&path).unwrap()).unwrap();
8450 document["revision"] = serde_json::json!(2);
8451 document["data"]["server"]["port"] = serde_json::json!(9876);
8452 std::fs::write(&path, serde_json::to_vec_pretty(&document).unwrap()).unwrap();
8453
8454 wait_for_facade_health(&state, SectionId::Core, SectionStatus::Healthy, 2).await;
8455 tokio::time::timeout(Duration::from_secs(3), async {
8456 loop {
8457 if state.config.read().await.server.port == 9876 {
8458 break;
8459 }
8460 tokio::time::sleep(Duration::from_millis(20)).await;
8461 }
8462 })
8463 .await
8464 .unwrap();
8465 assert!(matches!(
8466 next_config_event(&mut feed, "core").await,
8467 AgentEvent::ConfigChanged { revision: 2, .. }
8468 ));
8469
8470 std::fs::write(&path, b"{broken").unwrap();
8471 wait_for_facade_health(&state, SectionId::Core, SectionStatus::Invalid, 2).await;
8472 assert_eq!(state.config.read().await.server.port, 9876);
8473 assert!(matches!(
8474 next_config_event(&mut feed, "core").await,
8475 AgentEvent::ConfigInvalid { revision: 2, .. }
8476 ));
8477
8478 document["revision"] = serde_json::json!(3);
8479 document["data"]["server"]["port"] = serde_json::json!(9877);
8480 std::fs::write(&path, serde_json::to_vec_pretty(&document).unwrap()).unwrap();
8481 wait_for_facade_health(&state, SectionId::Core, SectionStatus::Healthy, 3).await;
8482 tokio::time::timeout(Duration::from_secs(3), async {
8483 loop {
8484 if state.config.read().await.server.port == 9877 {
8485 break;
8486 }
8487 tokio::time::sleep(Duration::from_millis(20)).await;
8488 }
8489 })
8490 .await
8491 .unwrap();
8492 assert!(matches!(
8493 next_config_event(&mut feed, "core").await,
8494 AgentEvent::ConfigRecovered { revision: 3, .. }
8495 ));
8496
8497 std::fs::remove_file(&path).unwrap();
8498 wait_for_facade_health(&state, SectionId::Core, SectionStatus::Missing, 3).await;
8499 assert_eq!(state.config.read().await.server.port, 9877);
8500 assert!(matches!(
8501 next_config_event(&mut feed, "core").await,
8502 AgentEvent::ConfigInvalid { revision: 3, .. }
8503 ));
8504
8505 document["revision"] = serde_json::json!(4);
8506 document["data"]["server"]["port"] = serde_json::json!(9878);
8507 let swap = dir.path().join("core.json.swap");
8508 std::fs::write(&swap, serde_json::to_vec_pretty(&document).unwrap()).unwrap();
8509 std::fs::rename(&swap, &path).unwrap();
8510 wait_for_facade_health(&state, SectionId::Core, SectionStatus::Healthy, 4).await;
8511 tokio::time::timeout(Duration::from_secs(3), async {
8512 loop {
8513 if state.config.read().await.server.port == 9878 {
8514 break;
8515 }
8516 tokio::time::sleep(Duration::from_millis(20)).await;
8517 }
8518 })
8519 .await
8520 .unwrap();
8521 assert!(matches!(
8522 next_config_event(&mut feed, "core").await,
8523 AgentEvent::ConfigRecovered { revision: 4, .. }
8524 ));
8525 }
8526
8527 #[tokio::test]
8528 async fn mcp_watcher_updates_lkg_rejects_invalid_and_recovers_after_atomic_replace() {
8529 let _key = bamboo_config::encryption::set_test_encryption_key([0x45; 32]);
8530 let dir = tempfile::tempdir().unwrap();
8531 let state = AppState::new(dir.path().to_path_buf()).await.unwrap();
8532 wait_for_mcp_health(&state, SectionStatus::Healthy, 0).await;
8533 let mut feed = state.account_sink.subscribe();
8534 let path = dir.path().join("mcp.json");
8535
8536 std::fs::write(&path, mcp_document_bytes(2, &disabled_mcp_config("first"))).unwrap();
8537 let first = wait_for_mcp_health(&state, SectionStatus::Healthy, 2).await;
8538 assert_eq!(first.revision, 2);
8539 assert_eq!(state.config.read().await.mcp.servers[0].id, "first");
8540 assert!(matches!(
8541 next_mcp_config_event(&mut feed).await,
8542 AgentEvent::ConfigChanged { revision: 2, .. }
8543 ));
8544
8545 std::fs::write(&path, b"{broken").unwrap();
8546 let invalid = wait_for_mcp_health(&state, SectionStatus::Invalid, 2).await;
8547 assert_eq!(invalid.revision, 2, "invalid candidates cannot advance LKG");
8548 assert_eq!(state.config.read().await.mcp.servers[0].id, "first");
8549 assert!(matches!(
8550 next_mcp_config_event(&mut feed).await,
8551 AgentEvent::ConfigInvalid { revision: 2, .. }
8552 ));
8553
8554 let swap = dir.path().join("mcp.json.swap");
8559 std::fs::write(
8560 &swap,
8561 mcp_document_bytes(3, &disabled_mcp_config("intermediate")),
8562 )
8563 .unwrap();
8564 std::fs::rename(&swap, &path).unwrap();
8565 std::fs::write(
8566 &path,
8567 mcp_document_bytes(3, &disabled_mcp_config("recovered")),
8568 )
8569 .unwrap();
8570 let recovered = wait_for_mcp_health(&state, SectionStatus::Healthy, 3).await;
8571 assert_eq!(recovered.revision, 3, "rename burst should coalesce once");
8572 assert_eq!(state.config.read().await.mcp.servers[0].id, "recovered");
8573 assert!(matches!(
8574 next_mcp_config_event(&mut feed).await,
8575 AgentEvent::ConfigRecovered { revision: 3, .. }
8576 ));
8577
8578 std::fs::write(
8583 &path,
8584 mcp_document_bytes(3, &disabled_mcp_config("normalized")),
8585 )
8586 .unwrap();
8587 let normalized = wait_for_mcp_health(&state, SectionStatus::Healthy, 4).await;
8588 assert_eq!(normalized.revision, 4);
8589 assert_eq!(state.config.read().await.mcp.servers[0].id, "normalized");
8590 assert!(matches!(
8591 next_mcp_config_event(&mut feed).await,
8592 AgentEvent::ConfigChanged { revision: 4, .. }
8593 ));
8594 assert!(
8595 tokio::time::timeout(Duration::from_millis(500), feed.recv())
8596 .await
8597 .is_err()
8598 );
8599 let persisted: serde_json::Value =
8600 serde_json::from_slice(&std::fs::read(&path).unwrap()).unwrap();
8601 assert_eq!(persisted["revision"], 4);
8602 }
8603
8604 #[tokio::test]
8605 async fn mcp_sidecar_present_at_startup_is_applied_through_runtime_transaction() {
8606 let _key = bamboo_config::encryption::set_test_encryption_key([0x47; 32]);
8607 let dir = tempfile::tempdir().unwrap();
8608 std::fs::write(
8609 dir.path().join("mcp.json"),
8610 mcp_document_bytes(1, &disabled_mcp_config("startup-sidecar")),
8611 )
8612 .unwrap();
8613
8614 let state = AppState::new(dir.path().to_path_buf()).await.unwrap();
8615 let health = wait_for_mcp_health(&state, SectionStatus::Healthy, 1).await;
8616 assert_eq!(health.revision, 1);
8617 assert_eq!(health.source_kind, SectionSourceKind::File);
8618 assert_eq!(
8619 state.config.read().await.mcp.servers[0].id,
8620 "startup-sidecar"
8621 );
8622 }
8623
8624 #[tokio::test]
8625 async fn mcp_startup_uses_valid_backup_and_reports_degraded_invalid_health() {
8626 let _key = bamboo_config::encryption::set_test_encryption_key([0x48; 32]);
8627 let dir = tempfile::tempdir().unwrap();
8628 let path = dir.path().join("mcp.json");
8629 let store = AtomicJsonStore::new(&path, 1);
8630 store
8631 .commit(0, disabled_mcp_config("backup-lkg"), validate_mcp_config)
8632 .unwrap();
8633 store
8634 .commit(1, disabled_mcp_config("new-primary"), validate_mcp_config)
8635 .unwrap();
8636 std::fs::write(&path, b"{corrupt-primary").unwrap();
8637
8638 let state = AppState::new(dir.path().to_path_buf()).await.unwrap();
8639 let health = wait_for_mcp_health(&state, SectionStatus::Degraded, 1).await;
8640 assert_eq!(health.revision, 1);
8641 assert_eq!(health.source_kind, SectionSourceKind::Backup);
8642 assert_eq!(health.source_path, path.with_extension("json.bak"));
8643 assert!(health
8644 .last_error
8645 .as_deref()
8646 .unwrap()
8647 .contains("last-known-good backup runtime"));
8648 assert_eq!(state.config.read().await.mcp.servers[0].id, "backup-lkg");
8649
8650 tokio::time::timeout(Duration::from_secs(3), async {
8651 loop {
8652 if state.account_sink.latest_seq() > 0 {
8653 break;
8654 }
8655 tokio::time::sleep(Duration::from_millis(20)).await;
8656 }
8657 })
8658 .await
8659 .unwrap();
8660 tokio::time::sleep(Duration::from_millis(500)).await;
8661 let events =
8662 bamboo_engine::events::journal::read_since(state.account_sink.events_dir(), 0).unwrap();
8663 assert_eq!(
8664 events
8665 .iter()
8666 .filter(|event| matches!(
8667 &event.event,
8668 AgentEvent::ConfigInvalid { section, revision }
8669 if section == "mcp" && *revision == 1
8670 ))
8671 .count(),
8672 1
8673 );
8674 let stable_health = state
8675 .mcp_config_live_health
8676 .read()
8677 .unwrap_or_else(|poisoned| poisoned.into_inner())
8678 .clone();
8679 assert_eq!(stable_health.status, SectionStatus::Degraded);
8680 assert_eq!(stable_health.source_kind, SectionSourceKind::Backup);
8681 assert_eq!(stable_health.source_path, path.with_extension("json.bak"));
8682 }
8683
8684 #[tokio::test]
8685 async fn mcp_runtime_init_failure_marks_degraded_and_retains_lkg_config() {
8686 let _key = bamboo_config::encryption::set_test_encryption_key([0x46; 32]);
8687 let dir = tempfile::tempdir().unwrap();
8688 let state = AppState::new(dir.path().to_path_buf()).await.unwrap();
8689 wait_for_mcp_health(&state, SectionStatus::Healthy, 0).await;
8690 let mut feed = state.account_sink.subscribe();
8691 let path = dir.path().join("mcp.json");
8692
8693 std::fs::write(
8694 &path,
8695 mcp_document_bytes(1, &disabled_mcp_config("last-known-good")),
8696 )
8697 .unwrap();
8698 wait_for_mcp_health(&state, SectionStatus::Healthy, 1).await;
8699 let _ = next_mcp_config_event(&mut feed).await;
8700
8701 let mut failing = disabled_mcp_config("candidate");
8702 failing.servers[0].enabled = true;
8703 if let TransportConfig::Stdio(stdio) = &mut failing.servers[0].transport {
8704 stdio.command = "definitely-not-a-real-mcp-command-597".to_string();
8705 }
8706 std::fs::write(&path, mcp_document_bytes(2, &failing)).unwrap();
8707
8708 let degraded = wait_for_mcp_health(&state, SectionStatus::Degraded, 1).await;
8709 assert_eq!(degraded.revision, 1);
8710 assert!(degraded
8711 .last_error
8712 .as_deref()
8713 .unwrap()
8714 .contains("last-known-good runtime"));
8715 assert_eq!(
8716 state.config.read().await.mcp.servers[0].id,
8717 "last-known-good"
8718 );
8719 assert!(state.mcp_manager.list_servers().is_empty());
8720 assert!(matches!(
8721 next_mcp_config_event(&mut feed).await,
8722 AgentEvent::ConfigInvalid { revision: 1, .. }
8723 ));
8724 }
8725}