Skip to main content

bamboo_server/app_state/
config_runtime.rs

1use super::*;
2
3use std::collections::BTreeSet;
4use std::path::Path;
5use std::sync::atomic::{AtomicBool, Ordering};
6use std::time::Duration;
7
8use bamboo_config::{
9    ensure_provider_mcp_migration_ready, AtomicJsonStore, ConfigDirectoryWatcher,
10    ConfigSectionEvent, ConfigStoreError, McpSection, ProviderConfigs, SectionId,
11    SectionSourceKind, SectionStatus,
12};
13use bamboo_mcp::{McpConfig, McpServerManager, TransportConfig};
14use chrono::{DateTime, Utc};
15use serde::Serialize;
16use serde_json::Value;
17
18struct FacadeRuntimeMaterialization {
19    config: Config,
20    failures: BTreeSet<SectionId>,
21}
22
23fn materialize_facade_effective_config(
24    facade: &bamboo_config::ConfigFacade,
25    data_dir: &Path,
26) -> FacadeRuntimeMaterialization {
27    let mut config = facade.effective_config();
28    let mut failures = BTreeSet::new();
29    if let Err(error) = config.hydrate_proxy_auth_from_store(data_dir) {
30        tracing::warn!(error = %error, "proxy auth credential hydration unavailable");
31        config.proxy_auth = None;
32        failures.insert(SectionId::Core);
33    }
34    if let Err(error) = config.hydrate_provider_credentials_from_store(data_dir) {
35        tracing::warn!(error = %error, "provider credential hydration unavailable");
36        failures.insert(SectionId::Providers);
37    }
38    if let Err(error) = config.hydrate_mcp_credentials_from_store(data_dir) {
39        tracing::warn!(error = %error, "MCP credential hydration unavailable");
40        failures.insert(SectionId::Mcp);
41    }
42    if let Err(error) = config.hydrate_env_var_credentials_from_store(data_dir) {
43        tracing::warn!(error = %error, "env credential hydration unavailable");
44        for entry in &mut config.env_vars {
45            if entry.secret {
46                entry.value.clear();
47            }
48        }
49        failures.insert(SectionId::Env);
50    }
51    if let Err(error) = config.hydrate_cluster_credentials_from_store(data_dir) {
52        tracing::warn!(error = %error, "cluster credential hydration unavailable");
53        failures.insert(SectionId::ClusterFabric);
54    }
55    if let Err(error) = config.hydrate_notification_credentials_from_store(data_dir) {
56        tracing::warn!(error = %error, "notification credential hydration unavailable");
57        config.notifications.ntfy.token = None;
58        config.notifications.bark.device_key = None;
59        failures.insert(SectionId::Notifications);
60    }
61    if let Err(error) = config.hydrate_connect_credentials_from_store(data_dir) {
62        tracing::warn!(error = %error, "connect credential hydration unavailable");
63        for platform in &mut config.connect.platforms {
64            platform.token = None;
65            platform.app_secret = None;
66        }
67        failures.insert(SectionId::Connect);
68    }
69    if let Err(error) = config.hydrate_access_control_credentials_from_store(data_dir) {
70        tracing::warn!(error = %error, "access-control credential hydration unavailable");
71        config.clear_access_control_runtime_verifiers();
72        failures.insert(SectionId::AccessControl);
73    }
74    if let Some(broker) = config.subagents_mut().broker.as_mut() {
75        if let Err(error) = broker.hydrate_credential_from_store(data_dir) {
76            tracing::warn!(error = %error, "external broker credential hydration unavailable");
77            broker.token.clear();
78            failures.insert(SectionId::Subagents);
79        }
80    }
81    config.apply_runtime_env_overrides();
82    FacadeRuntimeMaterialization { config, failures }
83}
84
85pub(super) fn load_facade_effective_config(
86    facade: &bamboo_config::ConfigFacade,
87    data_dir: &Path,
88) -> Config {
89    materialize_facade_effective_config(facade, data_dir).config
90}
91
92fn load_committed_effective_config(data_dir: &Path) -> Result<Config, ConfigStoreError> {
93    if bamboo_config::section_layout_is_active(data_dir)? {
94        let facade = bamboo_config::ConfigFacade::open(data_dir)?;
95        let config = load_facade_effective_config(&facade, data_dir);
96        Ok(config)
97    } else {
98        Ok(Config::from_data_dir_without_publish(Some(
99            data_dir.to_path_buf(),
100        )))
101    }
102}
103
104/// Health metadata for the server-owned effective/provider configuration view.
105#[derive(Debug, Clone, Serialize)]
106pub struct ConfigLiveHealth {
107    pub revision: u64,
108    pub loaded_at: DateTime<Utc>,
109    pub source_path: PathBuf,
110    pub source_kind: SectionSourceKind,
111    pub status: SectionStatus,
112    pub last_error: Option<String>,
113}
114
115/// Owns the blocking filesystem watcher and async runtime-apply task.
116pub struct ConfigWatcherRuntime {
117    stop: Arc<AtomicBool>,
118    watcher_task: Option<std::thread::JoinHandle<()>>,
119    apply_task: Option<tokio::task::JoinHandle<()>>,
120}
121
122struct ConfigPathChanges {
123    paths: Vec<PathBuf>,
124    initial_mcp: bool,
125}
126
127impl ConfigWatcherRuntime {
128    #[allow(clippy::too_many_arguments)]
129    pub fn start(
130        data_dir: PathBuf,
131        config: Arc<RwLock<Config>>,
132        config_facade: Option<Arc<bamboo_config::ConfigFacade>>,
133        config_io_lock: Arc<tokio::sync::Mutex<()>>,
134        provider_registry: Arc<bamboo_llm::ProviderRegistry>,
135        provider: Arc<RwLock<Arc<dyn LLMProvider>>>,
136        mcp_manager: Arc<McpServerManager>,
137        account_sink: Arc<bamboo_engine::events::AccountEventSink>,
138    ) -> (
139        Self,
140        Arc<std::sync::RwLock<ConfigLiveHealth>>,
141        Arc<std::sync::RwLock<ConfigLiveHealth>>,
142    ) {
143        let provider_store = AtomicJsonStore::new(data_dir.join("providers.json"), 1);
144        let provider_health = Arc::new(std::sync::RwLock::new(initial_provider_health(
145            &provider_store,
146        )));
147        let mcp_store = AtomicJsonStore::new(data_dir.join("mcp.json"), 1);
148        let mcp_health = Arc::new(std::sync::RwLock::new(initial_mcp_health(&mcp_store)));
149        let stop = Arc::new(AtomicBool::new(false));
150        let watcher = match ConfigDirectoryWatcher::watch(&data_dir, Duration::from_millis(120)) {
151            Ok(watcher) => watcher,
152            Err(error) => {
153                tracing::warn!(error = %error, "live configuration watcher could not start");
154                {
155                    let mut value = provider_health
156                        .write()
157                        .unwrap_or_else(|poisoned| poisoned.into_inner());
158                    value.status = SectionStatus::Degraded;
159                    value.last_error = Some("configuration watcher unavailable".to_string());
160                }
161                {
162                    let mut value = mcp_health
163                        .write()
164                        .unwrap_or_else(|poisoned| poisoned.into_inner());
165                    value.status = SectionStatus::Degraded;
166                    value.last_error = Some("configuration watcher unavailable".to_string());
167                }
168                return (
169                    Self {
170                        stop,
171                        watcher_task: None,
172                        apply_task: None,
173                    },
174                    provider_health,
175                    mcp_health,
176                );
177            }
178        };
179
180        // The filesystem side must never block in send: Drop joins this OS
181        // thread after aborting the async consumer, so a bounded blocking_send
182        // could deadlock shutdown if its queue were full.
183        let self_write_marker = watcher.self_write_marker();
184        let (changes_tx, mut changes_rx) =
185            tokio::sync::mpsc::unbounded_channel::<ConfigPathChanges>();
186        let initial_changes = changes_tx.clone();
187        let worker_stop = stop.clone();
188        let watcher_task = std::thread::spawn(move || {
189            while !worker_stop.load(Ordering::Relaxed) {
190                match watcher.recv_timeout(Duration::from_millis(250)) {
191                    Ok(paths) => {
192                        if changes_tx
193                            .send(ConfigPathChanges {
194                                paths,
195                                initial_mcp: false,
196                            })
197                            .is_err()
198                        {
199                            break;
200                        }
201                    }
202                    Err(std::sync::mpsc::RecvTimeoutError::Timeout) => {}
203                    Err(std::sync::mpsc::RecvTimeoutError::Disconnected) => break,
204                }
205            }
206        });
207
208        // A sidecar may already exist before the server starts. Queue it through
209        // the exact same candidate/runtime transaction as later filesystem
210        // events; parse-only initial health must never be mistaken for a
211        // published runtime snapshot.
212        let initial_mcp_path = data_dir.join("mcp.json");
213        if initial_mcp_path.exists() {
214            let _ = initial_changes.send(ConfigPathChanges {
215                paths: vec![initial_mcp_path],
216                initial_mcp: true,
217            });
218        }
219
220        let apply_provider_health = provider_health.clone();
221        let apply_mcp_health = mcp_health.clone();
222        let apply_task = tokio::spawn(async move {
223            while let Some(changes) = changes_rx.recv().await {
224                let watched_sections = config_facade
225                    .as_ref()
226                    .map(|_| {
227                        changes
228                            .paths
229                            .iter()
230                            .filter_map(|path| {
231                                path.file_name()
232                                    .and_then(|name| name.to_str())
233                                    .and_then(SectionId::from_file_name)
234                            })
235                            .collect::<BTreeSet<_>>()
236                    })
237                    .unwrap_or_default();
238                let provider_watched = changes.paths.iter().any(|path| {
239                    path.file_name().and_then(|name| name.to_str()) == Some("providers.json")
240                });
241                let mcp_watched = changes.paths.iter().any(|path| {
242                    path.file_name().and_then(|name| name.to_str()) == Some("mcp.json")
243                });
244                let ordinary_watched = watched_sections
245                    .iter()
246                    .copied()
247                    .filter(|id| !matches!(id, SectionId::Providers | SectionId::Mcp))
248                    .collect::<Vec<_>>();
249                if !provider_watched && !mcp_watched && ordinary_watched.is_empty() {
250                    continue;
251                }
252
253                // Serialize candidate construction and publication with config
254                // writers. Otherwise a slow provider build could later publish
255                // a clone taken before an unrelated API update and clobber it.
256                let _io = config_io_lock.lock().await;
257                if let Some(facade) = config_facade.as_ref() {
258                    let mut publishable = Vec::new();
259                    for id in ordinary_watched {
260                        wait_for_section_file_settle(&data_dir, id).await;
261                        let Some(event) = facade.registry().reload_if_changed(id) else {
262                            continue;
263                        };
264                        if matches!(event, ConfigSectionEvent::Invalid { .. }) {
265                            publish_registry_event(&account_sink, &event);
266                        } else {
267                            publishable.push((id, event));
268                        }
269                    }
270                    if !publishable.is_empty() {
271                        let materialized = materialize_facade_effective_config(facade, &data_dir);
272                        let mut current = config.read().await.clone();
273                        let mut applied = Vec::new();
274                        for (id, event) in publishable {
275                            if materialized.failures.contains(&id) {
276                                if let Some(invalid) = facade.registry().mark_runtime_degraded(
277                                    id,
278                                    "configuration runtime hydration failed; retaining last-known-good runtime",
279                                ) {
280                                    publish_registry_event(&account_sink, &invalid);
281                                }
282                                continue;
283                            }
284                            apply_runtime_section(id, &materialized.config, &mut current);
285                            applied.push((id, event));
286                        }
287                        if !applied.is_empty() {
288                            let publishes_env = applied.iter().any(|(id, _)| *id == SectionId::Env);
289                            let enforcement_newly_off =
290                                !config.read().await.plugin_trust.enforcement_is_off()
291                                    && current.plugin_trust.enforcement_is_off();
292                            *config.write().await = current.clone();
293                            if publishes_env {
294                                current.publish_env_vars();
295                            }
296                            if enforcement_newly_off {
297                                warn_plugin_trust_enforcement_off();
298                            }
299                            for (_, event) in applied {
300                                publish_registry_event(&account_sink, &event);
301                            }
302                        }
303                    }
304                }
305                if provider_watched {
306                    if let Some(facade) = config_facade.as_ref() {
307                        wait_for_section_file_settle(&data_dir, SectionId::Providers).await;
308                        if let Some(event) =
309                            facade.registry().reload_if_changed(SectionId::Providers)
310                        {
311                            if matches!(event, ConfigSectionEvent::Invalid { .. }) {
312                                publish_section_failure(
313                                &apply_provider_health,
314                                &account_sink,
315                                "providers",
316                                facade.registry().providers.snapshot().status,
317                                "provider section is invalid; retaining last-known-good runtime"
318                                    .to_string(),
319                            );
320                            } else {
321                                self_write_marker.mark_self_write(provider_store.path());
322                                let materialized =
323                                    materialize_facade_effective_config(facade, &data_dir);
324                                if materialized.failures.contains(&SectionId::Providers) {
325                                    facade.registry().mark_runtime_degraded(
326                                    SectionId::Providers,
327                                    "provider credential hydration failed; retaining last-known-good runtime",
328                                );
329                                    publish_section_failure(
330                                    &apply_provider_health,
331                                    &account_sink,
332                                    "providers",
333                                    SectionStatus::Degraded,
334                                    "provider credential hydration failed; retaining last-known-good runtime"
335                                        .to_string(),
336                                );
337                                } else {
338                                    let mut candidate = config.read().await.clone();
339                                    apply_runtime_section(
340                                        SectionId::Providers,
341                                        &materialized.config,
342                                        &mut candidate,
343                                    );
344                                    match prepare_provider_candidate(candidate, &data_dir).await {
345                                        Ok((candidate, registry, next_provider)) => {
346                                            let mut live_config = config.write().await;
347                                            let mut live_provider = provider.write().await;
348                                            let recovered =
349                                                section_is_unhealthy(&apply_provider_health);
350                                            candidate.publish_env_vars();
351                                            *live_config = candidate;
352                                            provider_registry.replace_with(registry);
353                                            *live_provider = next_provider;
354                                            drop(live_provider);
355                                            drop(live_config);
356                                            let revision =
357                                                facade.registry().providers.snapshot().revision;
358                                            publish_section_success(
359                                                &apply_provider_health,
360                                                &account_sink,
361                                                "providers",
362                                                data_dir.join("providers.json"),
363                                                recovered,
364                                                Some(revision),
365                                            );
366                                        }
367                                        Err(_) => {
368                                            facade.registry().mark_runtime_degraded(
369                                            SectionId::Providers,
370                                            "provider runtime initialization failed; retaining last-known-good runtime",
371                                        );
372                                            publish_section_failure(
373                                            &apply_provider_health,
374                                            &account_sink,
375                                            "providers",
376                                            SectionStatus::Degraded,
377                                            "provider runtime initialization failed; retaining last-known-good runtime"
378                                                .to_string(),
379                                        );
380                                        }
381                                    }
382                                }
383                            }
384                        }
385                    } else {
386                        let current_config = config.read().await.clone();
387                        let current_revision = apply_provider_health
388                            .read()
389                            .unwrap_or_else(|poisoned| poisoned.into_inner())
390                            .revision;
391                        let result = load_and_prepare_provider_candidate(
392                            &provider_store,
393                            current_revision,
394                            current_config,
395                        )
396                        .await;
397                        match result {
398                            Ok(candidate) if candidate.unchanged => {}
399                            Ok(candidate) => {
400                                if candidate.normalized_external_revision {
401                                    self_write_marker.mark_self_write(provider_store.path());
402                                }
403                                let mut live_config = config.write().await;
404                                let mut live_provider = provider.write().await;
405                                let recovered = section_is_unhealthy(&apply_provider_health);
406                                candidate.config.publish_env_vars();
407                                *live_config = candidate.config;
408                                provider_registry.replace_with(candidate.registry);
409                                *live_provider = candidate.provider;
410                                drop(live_provider);
411                                drop(live_config);
412
413                                publish_section_success(
414                                    &apply_provider_health,
415                                    &account_sink,
416                                    "providers",
417                                    data_dir.join("providers.json"),
418                                    recovered,
419                                    Some(candidate.revision),
420                                );
421                            }
422                            Err(error) => publish_section_failure(
423                                &apply_provider_health,
424                                &account_sink,
425                                "providers",
426                                candidate_error_status(&error.kind),
427                                error.message,
428                            ),
429                        }
430                    }
431                }
432
433                if mcp_watched {
434                    if let Some(facade) = config_facade.as_ref() {
435                        wait_for_section_file_settle(&data_dir, SectionId::Mcp).await;
436                        let event =
437                            facade
438                                .registry()
439                                .reload_if_changed(SectionId::Mcp)
440                                .or_else(|| {
441                                    changes.initial_mcp.then(|| ConfigSectionEvent::Changed {
442                                        section: "mcp".to_string(),
443                                        revision: facade.registry().mcp.snapshot().revision,
444                                    })
445                                });
446                        let Some(event) = event else {
447                            continue;
448                        };
449                        if matches!(event, ConfigSectionEvent::Invalid { .. }) {
450                            publish_section_failure(
451                                &apply_mcp_health,
452                                &account_sink,
453                                "mcp",
454                                facade.registry().mcp.snapshot().status,
455                                "MCP section is invalid; retaining last-known-good runtime"
456                                    .to_string(),
457                            );
458                        } else {
459                            self_write_marker.mark_self_write(mcp_store.path());
460                            let materialized =
461                                materialize_facade_effective_config(facade, &data_dir);
462                            if materialized.failures.contains(&SectionId::Mcp) {
463                                facade.registry().mark_runtime_degraded(
464                                    SectionId::Mcp,
465                                    "MCP credential hydration failed; retaining last-known-good runtime",
466                                );
467                                publish_section_failure(
468                                    &apply_mcp_health,
469                                    &account_sink,
470                                    "mcp",
471                                    SectionStatus::Degraded,
472                                    "MCP credential hydration failed; retaining last-known-good runtime"
473                                        .to_string(),
474                                );
475                                continue;
476                            }
477                            let next_mcp = materialized.config.mcp.clone();
478                            let publish_config = config.clone();
479                            match mcp_manager
480                                .reconcile_from_config_transactional_after(
481                                    &materialized.config.mcp,
482                                    || async move {
483                                        publish_config.write().await.mcp = next_mcp;
484                                        Ok(())
485                                    },
486                                )
487                                .await
488                            {
489                                Ok(()) => {
490                                    let recovered = section_is_unhealthy(&apply_mcp_health);
491                                    let snapshot = facade.registry().mcp.snapshot();
492                                    let revision = snapshot.revision;
493                                    if changes.initial_mcp
494                                        && snapshot.status == SectionStatus::Healthy
495                                    {
496                                        // Startup reconciliation makes the
497                                        // already-materialized section live in
498                                        // the MCP manager; it is not a config
499                                        // mutation and must not consume an
500                                        // account-feed sequence number.
501                                        set_live_health_revision(
502                                            &apply_mcp_health,
503                                            revision,
504                                            Some((
505                                                data_dir.join("mcp.json"),
506                                                SectionSourceKind::File,
507                                            )),
508                                        );
509                                    } else {
510                                        publish_section_success(
511                                            &apply_mcp_health,
512                                            &account_sink,
513                                            "mcp",
514                                            data_dir.join("mcp.json"),
515                                            recovered,
516                                            Some(revision),
517                                        );
518                                    }
519                                }
520                                Err(_) => {
521                                    facade.registry().mark_runtime_degraded(
522                                        SectionId::Mcp,
523                                        "MCP runtime initialization failed; retaining last-known-good runtime",
524                                    );
525                                    publish_section_failure(
526                                        &apply_mcp_health,
527                                        &account_sink,
528                                        "mcp",
529                                        SectionStatus::Degraded,
530                                        "MCP runtime initialization failed; retaining last-known-good runtime"
531                                            .to_string(),
532                                    )
533                                }
534                            }
535                        }
536                    } else {
537                        let current_config = config.read().await.clone();
538                        let current_revision = apply_mcp_health
539                            .read()
540                            .unwrap_or_else(|poisoned| poisoned.into_inner())
541                            .revision;
542                        let result = load_and_validate_mcp_candidate(
543                            &mcp_store,
544                            current_revision,
545                            current_config,
546                            changes.initial_mcp,
547                        )
548                        .await;
549                        match result {
550                            Ok(candidate) if candidate.unchanged => {}
551                            Ok(candidate) => {
552                                if candidate.normalized_external_revision
553                                    || candidate.source_kind == SectionSourceKind::Backup
554                                {
555                                    // Normalization writes the primary and backup
556                                    // recovery quarantines it. Suppress only that
557                                    // exact watcher echo; a later external write has
558                                    // a different fingerprint and remains visible.
559                                    self_write_marker.mark_self_write(mcp_store.path());
560                                }
561                                let next_mcp = candidate.config.mcp.clone();
562                                let publish_config = config.clone();
563                                match mcp_manager
564                                    .reconcile_from_config_transactional_after(
565                                        &candidate.config.mcp,
566                                        || async move {
567                                            publish_config.write().await.mcp = next_mcp;
568                                            Ok(())
569                                        },
570                                    )
571                                    .await
572                                {
573                                    Ok(()) => {
574                                        let recovered = section_is_unhealthy(&apply_mcp_health);
575                                        if candidate.source_kind == SectionSourceKind::Backup {
576                                            publish_mcp_backup_lkg(
577                                                &apply_mcp_health,
578                                                &account_sink,
579                                                candidate.source_path,
580                                                candidate.revision,
581                                            );
582                                        } else {
583                                            publish_section_success(
584                                                &apply_mcp_health,
585                                                &account_sink,
586                                                "mcp",
587                                                data_dir.join("mcp.json"),
588                                                recovered,
589                                                Some(candidate.revision),
590                                            );
591                                        }
592                                    }
593                                    Err(_) => publish_section_failure(
594                                        &apply_mcp_health,
595                                        &account_sink,
596                                        "mcp",
597                                        SectionStatus::Degraded,
598                                        "MCP runtime initialization failed; retaining last-known-good runtime"
599                                            .to_string(),
600                                    ),
601                                }
602                            }
603                            Err(error) => publish_section_failure(
604                                &apply_mcp_health,
605                                &account_sink,
606                                "mcp",
607                                candidate_error_status(&error.kind),
608                                error.message,
609                            ),
610                        }
611                    }
612                }
613            }
614        });
615
616        (
617            Self {
618                stop,
619                watcher_task: Some(watcher_task),
620                apply_task: Some(apply_task),
621            },
622            provider_health,
623            mcp_health,
624        )
625    }
626}
627
628async fn wait_for_section_file_settle(data_dir: &Path, id: SectionId) {
629    let path = data_dir.join(id.descriptor().file_name);
630    for _ in 0..3 {
631        if path.exists() {
632            return;
633        }
634        tokio::time::sleep(Duration::from_millis(50)).await;
635    }
636}
637
638fn publish_registry_event(
639    account_sink: &bamboo_engine::events::AccountEventSink,
640    event: &ConfigSectionEvent,
641) {
642    let event = match event {
643        ConfigSectionEvent::Changed { section, revision } => AgentEvent::ConfigChanged {
644            section: section.clone(),
645            revision: *revision,
646        },
647        ConfigSectionEvent::Invalid { section, revision } => AgentEvent::ConfigInvalid {
648            section: section.clone(),
649            revision: *revision,
650        },
651        ConfigSectionEvent::Recovered { section, revision } => AgentEvent::ConfigRecovered {
652            section: section.clone(),
653            revision: *revision,
654        },
655    };
656    account_sink.record(None, &event);
657}
658
659fn publish_exact_facade_commit(
660    facade: Option<&std::sync::Arc<bamboo_config::ConfigFacade>>,
661    account_sink: &bamboo_engine::events::AccountEventSink,
662    sections: &[SectionId],
663) -> Result<(), AppError> {
664    let Some(facade) = facade else {
665        return Ok(());
666    };
667    for section in sections {
668        let Some(event) = facade.registry().reload_if_changed(*section) else {
669            continue;
670        };
671        publish_registry_event(account_sink, &event);
672        if matches!(event, ConfigSectionEvent::Invalid { .. }) {
673            return Err(AppError::InternalError(anyhow::anyhow!(
674                "committed configuration section became invalid before publication"
675            )));
676        }
677    }
678    Ok(())
679}
680
681fn apply_runtime_section(id: SectionId, source: &Config, target: &mut Config) {
682    match id {
683        SectionId::Core => {
684            target.http_proxy = source.http_proxy.clone();
685            target.https_proxy = source.https_proxy.clone();
686            target.proxy_auth = source.proxy_auth.clone();
687            target.proxy_auth_encrypted = None;
688            target.proxy_auth_credential_ref = source.proxy_auth_credential_ref.clone();
689            target.headless_auth = source.headless_auth;
690            target.server = source.server.clone();
691            target.default_work_area = source.default_work_area.clone();
692            target.run_budget = source.run_budget;
693            target.stream_timeout = source.stream_timeout;
694            target.extra = source.extra.clone();
695        }
696        SectionId::Providers => {
697            target.provider = source.provider.clone();
698            target.defaults = source.defaults.clone();
699            target.provider_instances = source.provider_instances.clone();
700            target.default_provider_instance = source.default_provider_instance.clone();
701            target.features = source.features.clone();
702            *target.providers_mut() = source.providers().clone();
703        }
704        SectionId::Mcp => target.mcp = source.mcp.clone(),
705        SectionId::ToolsSkills => {
706            target.tools = source.tools.clone();
707            target.skills = source.skills.clone();
708            target.plugin_trust = source.plugin_trust.clone();
709        }
710        SectionId::Memory => *target.memory_mut() = source.memory().clone(),
711        SectionId::Subagents => *target.subagents_mut() = source.subagents().clone(),
712        SectionId::Notifications => target.notifications = source.notifications.clone(),
713        SectionId::Connect => target.connect = source.connect.clone(),
714        SectionId::ClusterFabric => target.cluster_fabric = source.cluster_fabric.clone(),
715        SectionId::Env => target.env_vars = source.env_vars.clone(),
716        SectionId::AccessControl => target.access_control = source.access_control.clone(),
717        SectionId::Hooks => target.hooks = source.hooks.clone(),
718        SectionId::ModelPolicy => {
719            target.keyword_masking = source.keyword_masking.clone();
720            target.anthropic_model_mapping = source.anthropic_model_mapping.clone();
721            target.gemini_model_mapping = source.gemini_model_mapping.clone();
722        }
723        SectionId::ModelLimits | SectionId::Credentials => {}
724    }
725}
726
727fn section_is_unhealthy(health: &std::sync::RwLock<ConfigLiveHealth>) -> bool {
728    health
729        .read()
730        .unwrap_or_else(|poisoned| poisoned.into_inner())
731        .status
732        != SectionStatus::Healthy
733}
734
735fn publish_section_success(
736    health: &std::sync::RwLock<ConfigLiveHealth>,
737    account_sink: &bamboo_engine::events::AccountEventSink,
738    section: &str,
739    source_path: PathBuf,
740    recovered: bool,
741    revision: Option<u64>,
742) {
743    let revision = match revision {
744        Some(revision) => set_live_health_revision(
745            health,
746            revision,
747            Some((source_path, SectionSourceKind::File)),
748        ),
749        None => update_live_health(
750            health,
751            SectionStatus::Healthy,
752            None,
753            true,
754            Some((source_path, SectionSourceKind::File)),
755        ),
756    };
757    let event = if recovered {
758        AgentEvent::ConfigRecovered {
759            section: section.to_string(),
760            revision,
761        }
762    } else {
763        AgentEvent::ConfigChanged {
764            section: section.to_string(),
765            revision,
766        }
767    };
768    account_sink.record(None, &event);
769}
770
771fn publish_section_failure(
772    health: &std::sync::RwLock<ConfigLiveHealth>,
773    account_sink: &bamboo_engine::events::AccountEventSink,
774    section: &str,
775    status: SectionStatus,
776    message: String,
777) {
778    let revision = update_live_health(health, status, Some(message), false, None);
779    account_sink.record(
780        None,
781        &AgentEvent::ConfigInvalid {
782            section: section.to_string(),
783            revision,
784        },
785    );
786}
787
788fn publish_mcp_backup_lkg(
789    health: &std::sync::RwLock<ConfigLiveHealth>,
790    account_sink: &bamboo_engine::events::AccountEventSink,
791    source_path: PathBuf,
792    revision: u64,
793) {
794    {
795        let mut health = health
796            .write()
797            .unwrap_or_else(|poisoned| poisoned.into_inner());
798        health.revision = revision;
799        health.loaded_at = Utc::now();
800        health.source_path = source_path;
801        health.source_kind = SectionSourceKind::Backup;
802        health.status = SectionStatus::Degraded;
803        health.last_error =
804            Some("primary MCP section invalid; running last-known-good backup runtime".to_string());
805    }
806    account_sink.record(
807        None,
808        &AgentEvent::ConfigInvalid {
809            section: "mcp".to_string(),
810            revision,
811        },
812    );
813}
814
815fn initial_provider_health(store: &AtomicJsonStore<ProviderConfigs>) -> ConfigLiveHealth {
816    if ensure_provider_mcp_migration_ready(store.path().parent().unwrap_or_else(|| Path::new(".")))
817        .is_err()
818    {
819        return ConfigLiveHealth {
820            revision: 0,
821            loaded_at: Utc::now(),
822            source_path: store.path().to_path_buf(),
823            source_kind: SectionSourceKind::File,
824            status: SectionStatus::Degraded,
825            last_error: Some("provider/MCP credential migration is pending".to_string()),
826        };
827    }
828    match store.load_validated_allowing_unversioned(|_| Ok(())) {
829        Ok(Some(stored)) => ConfigLiveHealth {
830            revision: stored.revision,
831            loaded_at: Utc::now(),
832            source_path: stored.source_path,
833            source_kind: if stored.recovered_from_backup {
834                SectionSourceKind::Backup
835            } else {
836                SectionSourceKind::File
837            },
838            status: if stored.recovered_from_backup {
839                SectionStatus::Degraded
840            } else {
841                SectionStatus::Healthy
842            },
843            last_error: stored.recovered_from_backup.then(|| {
844                "primary provider section invalid; using last-known-good backup".to_string()
845            }),
846        },
847        Ok(None) => ConfigLiveHealth {
848            revision: 0,
849            loaded_at: Utc::now(),
850            source_path: store.path().to_path_buf(),
851            source_kind: SectionSourceKind::Default,
852            status: SectionStatus::Missing,
853            last_error: None,
854        },
855        Err(_) => ConfigLiveHealth {
856            revision: 0,
857            loaded_at: Utc::now(),
858            source_path: store.path().to_path_buf(),
859            source_kind: SectionSourceKind::File,
860            status: SectionStatus::Invalid,
861            last_error: Some("provider section could not be parsed or read".to_string()),
862        },
863    }
864}
865
866fn initial_mcp_health(store: &AtomicJsonStore<McpConfig>) -> ConfigLiveHealth {
867    if ensure_provider_mcp_migration_ready(store.path().parent().unwrap_or_else(|| Path::new(".")))
868        .is_err()
869    {
870        return ConfigLiveHealth {
871            revision: 0,
872            loaded_at: Utc::now(),
873            source_path: store.path().to_path_buf(),
874            source_kind: SectionSourceKind::File,
875            status: SectionStatus::Degraded,
876            last_error: Some("provider/MCP credential migration is pending".to_string()),
877        };
878    }
879    match store.load_validated(validate_mcp_config) {
880        Ok(Some(stored)) => ConfigLiveHealth {
881            // The document is only a parsed candidate until runtime staging
882            // succeeds; the published LKG revision remains zero meanwhile.
883            revision: 0,
884            loaded_at: Utc::now(),
885            source_path: store.path().to_path_buf(),
886            source_kind: if stored.recovered_from_backup {
887                SectionSourceKind::Backup
888            } else {
889                SectionSourceKind::File
890            },
891            status: SectionStatus::Degraded,
892            last_error: Some(if stored.recovered_from_backup {
893                "primary MCP section invalid; runtime initialization pending from backup"
894                    .to_string()
895            } else {
896                "MCP runtime initialization pending".to_string()
897            }),
898        },
899        Ok(None) => ConfigLiveHealth {
900            revision: 0,
901            loaded_at: Utc::now(),
902            source_path: store.path().to_path_buf(),
903            source_kind: SectionSourceKind::Default,
904            status: SectionStatus::Missing,
905            last_error: None,
906        },
907        Err(_) => ConfigLiveHealth {
908            revision: 0,
909            loaded_at: Utc::now(),
910            source_path: store.path().to_path_buf(),
911            source_kind: SectionSourceKind::File,
912            status: SectionStatus::Invalid,
913            last_error: Some("MCP section could not be parsed or validated".to_string()),
914        },
915    }
916}
917
918impl Drop for ConfigWatcherRuntime {
919    fn drop(&mut self) {
920        self.stop.store(true, Ordering::Relaxed);
921        if let Some(task) = self.apply_task.take() {
922            task.abort();
923        }
924        if let Some(task) = self.watcher_task.take() {
925            let _ = task.join();
926        }
927    }
928}
929
930async fn load_and_prepare_provider_candidate(
931    store: &AtomicJsonStore<ProviderConfigs>,
932    current_revision: u64,
933    candidate_config: Config,
934) -> Result<ProviderCandidate, ProviderCandidateError> {
935    ensure_provider_mcp_migration_ready(store.path().parent().unwrap_or_else(|| Path::new(".")))
936        .map_err(|_| {
937            ProviderCandidateError::invalid(
938                "provider/MCP credential migration is pending; retaining last-known-good runtime",
939            )
940        })?;
941    // Editors commonly implement save as delete/rename/create. Retry a missing
942    // watched file briefly instead of treating the transient gap as a reset.
943    for _ in 0..3 {
944        if store.path().exists() {
945            break;
946        }
947        tokio::time::sleep(Duration::from_millis(50)).await;
948    }
949    if !store.path().exists() {
950        return Err(ProviderCandidateError::missing());
951    }
952    let stored = store
953        .load_validated_for_reload_allowing_unversioned(
954            current_revision,
955            candidate_config.providers(),
956            validate_provider_config,
957        )
958        .map_err(|_| {
959            if store.path().exists() {
960                ProviderCandidateError::invalid("provider section is invalid")
961            } else {
962                ProviderCandidateError::missing()
963            }
964        })?
965        .ok_or_else(ProviderCandidateError::missing)?;
966    if stored.recovered_from_backup {
967        return Err(ProviderCandidateError::invalid(
968            "primary provider section is invalid; retaining last-known-good runtime",
969        ));
970    }
971    let unchanged = stored.revision == current_revision
972        && serde_json::to_value(&stored.data).ok()
973            == serde_json::to_value(candidate_config.providers()).ok();
974    let mut candidate_config = candidate_config;
975    *candidate_config.providers_mut() = stored.data;
976    let (candidate_config, registry, provider) = prepare_provider_candidate(
977        candidate_config,
978        store
979            .path()
980            .parent()
981            .unwrap_or_else(|| std::path::Path::new(".")),
982    )
983    .await?;
984    Ok(ProviderCandidate {
985        config: candidate_config,
986        registry,
987        provider,
988        revision: stored.revision,
989        normalized_external_revision: stored.normalized_external_revision,
990        unchanged,
991    })
992}
993
994async fn prepare_provider_candidate(
995    mut candidate_config: Config,
996    data_dir: &std::path::Path,
997) -> Result<(Config, bamboo_llm::ProviderRegistry, Arc<dyn LLMProvider>), ProviderCandidateError> {
998    candidate_config.hydrate_provider_api_keys_from_encrypted();
999    candidate_config
1000        .hydrate_provider_credentials_from_store(data_dir)
1001        .map_err(|_| ProviderCandidateError::invalid("provider credential is unavailable"))?;
1002    let candidate_registry =
1003        bamboo_llm::ProviderRegistry::from_config(&candidate_config, data_dir.to_path_buf())
1004            .await
1005            .map_err(|_| ProviderCandidateError::runtime())?;
1006    let candidate_provider = candidate_registry
1007        .get_default()
1008        .ok_or_else(ProviderCandidateError::runtime)?;
1009    Ok((candidate_config, candidate_registry, candidate_provider))
1010}
1011
1012struct ProviderCandidate {
1013    config: Config,
1014    registry: bamboo_llm::ProviderRegistry,
1015    provider: Arc<dyn LLMProvider>,
1016    revision: u64,
1017    normalized_external_revision: bool,
1018    unchanged: bool,
1019}
1020
1021async fn load_and_validate_mcp_candidate(
1022    store: &AtomicJsonStore<McpConfig>,
1023    current_revision: u64,
1024    mut candidate_config: Config,
1025    allow_startup_backup: bool,
1026) -> Result<McpCandidate, ProviderCandidateError> {
1027    ensure_provider_mcp_migration_ready(store.path().parent().unwrap_or_else(|| Path::new(".")))
1028        .map_err(|_| {
1029            ProviderCandidateError::invalid(
1030                "provider/MCP credential migration is pending; retaining last-known-good runtime",
1031            )
1032        })?;
1033    for _ in 0..3 {
1034        if store.path().exists() {
1035            break;
1036        }
1037        tokio::time::sleep(Duration::from_millis(50)).await;
1038    }
1039    if !store.path().exists() {
1040        return Err(ProviderCandidateError::missing_section(
1041            "MCP section is missing",
1042        ));
1043    }
1044    let current_document = mcp_durable_comparison_document(&candidate_config.mcp);
1045    let stored = store
1046        .load_validated_for_reload(current_revision, &current_document, validate_mcp_config)
1047        .map_err(|_| ProviderCandidateError::invalid("MCP section is invalid"))?
1048        .ok_or_else(|| ProviderCandidateError::missing_section("MCP section is missing"))?;
1049    if stored.recovered_from_backup && !allow_startup_backup {
1050        return Err(ProviderCandidateError::invalid(
1051            "primary MCP section is invalid; retaining last-known-good runtime",
1052        ));
1053    }
1054    let unchanged = !allow_startup_backup
1055        && stored.revision == current_revision
1056        && serde_json::to_value(&stored.data).ok() == serde_json::to_value(&current_document).ok();
1057    candidate_config.mcp = stored.data;
1058    candidate_config.hydrate_mcp_secrets_from_encrypted();
1059    candidate_config
1060        .hydrate_mcp_credentials_from_store(
1061            store
1062                .path()
1063                .parent()
1064                .unwrap_or_else(|| std::path::Path::new(".")),
1065        )
1066        .map_err(|_| ProviderCandidateError::invalid("MCP credential is unavailable"))?;
1067    Ok(McpCandidate {
1068        config: candidate_config,
1069        revision: stored.revision,
1070        source_kind: if stored.recovered_from_backup {
1071            SectionSourceKind::Backup
1072        } else {
1073            SectionSourceKind::File
1074        },
1075        source_path: stored.source_path,
1076        normalized_external_revision: stored.normalized_external_revision,
1077        unchanged,
1078    })
1079}
1080
1081struct McpCandidate {
1082    config: Config,
1083    revision: u64,
1084    source_kind: SectionSourceKind,
1085    source_path: PathBuf,
1086    normalized_external_revision: bool,
1087    unchanged: bool,
1088}
1089
1090/// Project a hydrated runtime section back to its durable comparison shape.
1091/// Public compatibility serialization intentionally retains plaintext beside
1092/// ciphertext, while the sidecar stores only ciphertext for paired secrets.
1093fn mcp_durable_comparison_document(config: &McpConfig) -> McpConfig {
1094    let mut document = config.clone();
1095    for server in &mut document.servers {
1096        match &mut server.transport {
1097            TransportConfig::Stdio(config) => {
1098                config.env.retain(|name, _| {
1099                    !config.env_encrypted.contains_key(name)
1100                        && !config.env_credential_refs.contains_key(name)
1101                });
1102            }
1103            TransportConfig::Sse(config) => clear_paired_header_plaintext(&mut config.headers),
1104            TransportConfig::StreamableHttp(config) => {
1105                clear_paired_header_plaintext(&mut config.headers)
1106            }
1107        }
1108    }
1109    document
1110}
1111
1112fn clear_paired_header_plaintext(headers: &mut [bamboo_mcp::HeaderConfig]) {
1113    for header in headers {
1114        if header.value_encrypted.is_some() || header.credential_ref.is_some() {
1115            header.value.clear();
1116        }
1117    }
1118}
1119
1120fn validate_mcp_config(config: &McpConfig) -> Result<(), String> {
1121    let mut ids = std::collections::HashSet::new();
1122    for server in &config.servers {
1123        if server.id.trim().is_empty() {
1124            return Err("MCP server id cannot be empty".to_string());
1125        }
1126        if !ids.insert(server.id.as_str()) {
1127            return Err(format!("duplicate MCP server id '{}'", server.id));
1128        }
1129        if server.request_timeout_ms == 0 || server.healthcheck_interval_ms == 0 {
1130            return Err(format!(
1131                "MCP server '{}' timeouts must be non-zero",
1132                server.id
1133            ));
1134        }
1135        match &server.transport {
1136            TransportConfig::Stdio(stdio) if stdio.command.trim().is_empty() => {
1137                return Err(format!(
1138                    "MCP stdio server '{}' command cannot be empty",
1139                    server.id
1140                ));
1141            }
1142            TransportConfig::Sse(sse) if sse.url.trim().is_empty() => {
1143                return Err(format!(
1144                    "MCP SSE server '{}' URL cannot be empty",
1145                    server.id
1146                ));
1147            }
1148            TransportConfig::StreamableHttp(http) if http.url.trim().is_empty() => {
1149                return Err(format!(
1150                    "MCP HTTP server '{}' URL cannot be empty",
1151                    server.id
1152                ));
1153            }
1154            _ => {}
1155        }
1156        match &server.transport {
1157            TransportConfig::Stdio(stdio) => {
1158                if !stdio.env_encrypted.is_empty()
1159                    || stdio.env.iter().any(|(name, value)| {
1160                        !value.is_empty() && !stdio.env_credential_refs.contains_key(name)
1161                    })
1162                {
1163                    return Err(format!(
1164                        "MCP server '{}' contains a secret outside the credential store",
1165                        server.id
1166                    ));
1167                }
1168                for raw in stdio.env_credential_refs.values() {
1169                    bamboo_config::CredentialRef::parse(raw.clone())
1170                        .map_err(|_| "MCP credential reference is invalid".to_string())?;
1171                }
1172            }
1173            TransportConfig::Sse(config) => validate_header_refs(&server.id, &config.headers)?,
1174            TransportConfig::StreamableHttp(config) => {
1175                validate_header_refs(&server.id, &config.headers)?
1176            }
1177        }
1178    }
1179    Ok(())
1180}
1181
1182fn validate_provider_config(providers: &ProviderConfigs) -> Result<(), String> {
1183    macro_rules! validate {
1184        ($field:ident) => {
1185            if let Some(provider) = &providers.$field {
1186                if provider.api_key_encrypted.is_some()
1187                    || (!provider.api_key.trim().is_empty()
1188                        && !provider.api_key_from_env
1189                        && provider.credential_ref.is_none())
1190                {
1191                    return Err("provider secret is outside the credential store".to_string());
1192                }
1193            }
1194        };
1195    }
1196    validate!(openai);
1197    validate!(anthropic);
1198    validate!(gemini);
1199    if let Some(provider) = &providers.bodhi {
1200        if provider.api_key_encrypted.is_some()
1201            || (!provider.api_key.trim().is_empty() && provider.credential_ref.is_none())
1202        {
1203            return Err("provider secret is outside the credential store".to_string());
1204        }
1205    }
1206    Ok(())
1207}
1208
1209fn validate_header_refs(
1210    server_id: &str,
1211    headers: &[bamboo_mcp::HeaderConfig],
1212) -> Result<(), String> {
1213    for header in headers {
1214        if header.value_encrypted.is_some()
1215            || (!header.value.is_empty() && header.credential_ref.is_none())
1216        {
1217            return Err(format!(
1218                "MCP server '{server_id}' contains a secret outside the credential store"
1219            ));
1220        }
1221        if let Some(raw) = &header.credential_ref {
1222            bamboo_config::CredentialRef::parse(raw.clone())
1223                .map_err(|_| "MCP credential reference is invalid".to_string())?;
1224        }
1225    }
1226    Ok(())
1227}
1228
1229enum ProviderCandidateErrorKind {
1230    Missing,
1231    InvalidDocument,
1232    Runtime,
1233}
1234
1235struct ProviderCandidateError {
1236    kind: ProviderCandidateErrorKind,
1237    message: String,
1238}
1239
1240impl ProviderCandidateError {
1241    fn missing() -> Self {
1242        Self {
1243            kind: ProviderCandidateErrorKind::Missing,
1244            message: "provider section is missing".to_string(),
1245        }
1246    }
1247
1248    fn invalid(message: &str) -> Self {
1249        Self {
1250            kind: ProviderCandidateErrorKind::InvalidDocument,
1251            message: message.to_string(),
1252        }
1253    }
1254
1255    fn missing_section(message: &str) -> Self {
1256        Self {
1257            kind: ProviderCandidateErrorKind::Missing,
1258            message: message.to_string(),
1259        }
1260    }
1261
1262    fn runtime() -> Self {
1263        Self {
1264            kind: ProviderCandidateErrorKind::Runtime,
1265            message: "provider runtime initialization failed".to_string(),
1266        }
1267    }
1268}
1269
1270fn candidate_error_status(kind: &ProviderCandidateErrorKind) -> SectionStatus {
1271    match kind {
1272        ProviderCandidateErrorKind::Missing => SectionStatus::Missing,
1273        ProviderCandidateErrorKind::InvalidDocument => SectionStatus::Invalid,
1274        ProviderCandidateErrorKind::Runtime => SectionStatus::Degraded,
1275    }
1276}
1277
1278fn update_live_health(
1279    health: &std::sync::RwLock<ConfigLiveHealth>,
1280    status: SectionStatus,
1281    last_error: Option<String>,
1282    advance_revision: bool,
1283    source: Option<(PathBuf, SectionSourceKind)>,
1284) -> u64 {
1285    let mut health = health
1286        .write()
1287        .unwrap_or_else(|poisoned| poisoned.into_inner());
1288    if advance_revision {
1289        health.revision = health.revision.saturating_add(1);
1290    }
1291    health.loaded_at = Utc::now();
1292    health.status = status;
1293    health.last_error = last_error;
1294    if let Some((source_path, source_kind)) = source {
1295        health.source_path = source_path;
1296        health.source_kind = source_kind;
1297    }
1298    health.revision
1299}
1300
1301fn set_live_health_revision(
1302    health: &std::sync::RwLock<ConfigLiveHealth>,
1303    revision: u64,
1304    source: Option<(PathBuf, SectionSourceKind)>,
1305) -> u64 {
1306    let mut health = health
1307        .write()
1308        .unwrap_or_else(|poisoned| poisoned.into_inner());
1309    health.revision = revision;
1310    health.loaded_at = Utc::now();
1311    health.status = SectionStatus::Healthy;
1312    health.last_error = None;
1313    if let Some((source_path, source_kind)) = source {
1314        health.source_path = source_path;
1315        health.source_kind = source_kind;
1316    }
1317    revision
1318}
1319
1320#[derive(Debug)]
1321pub(crate) enum ConfigSectionMutationError {
1322    Store(ConfigStoreError),
1323    Invalid(String),
1324    Runtime(String),
1325}
1326
1327impl AppState {
1328    /// Commit a single ordinary typed section with CAS, then publish exactly
1329    /// that section into the process-owned effective snapshot. Credential
1330    /// bindings are server-owned and cannot be forged or detached here.
1331    pub(crate) async fn put_ordinary_section(
1332        &self,
1333        id: SectionId,
1334        expected_revision: u64,
1335        candidate: Value,
1336    ) -> Result<u64, ConfigSectionMutationError> {
1337        if matches!(
1338            id,
1339            SectionId::Providers | SectionId::Mcp | SectionId::Credentials
1340        ) {
1341            return Err(ConfigSectionMutationError::Invalid(
1342                "this section requires its dedicated endpoint".to_string(),
1343            ));
1344        }
1345        let _io = self.config_io_lock.lock().await;
1346        ensure_provider_mcp_migration_ready(&self.app_data_dir)
1347            .map_err(ConfigSectionMutationError::Store)?;
1348        let facade = self.config_facade.as_ref().ok_or_else(|| {
1349            ConfigSectionMutationError::Invalid(
1350                "typed section writes require the modular configuration facade".to_string(),
1351            )
1352        })?;
1353        let current = facade
1354            .registry()
1355            .envelope_value(id)
1356            .map_err(ConfigSectionMutationError::Store)?;
1357        if credential_reference_inventory(&current.data)
1358            != credential_reference_inventory(&candidate)
1359        {
1360            return Err(ConfigSectionMutationError::Invalid(
1361                "credential references are server-managed; use the credential or domain API"
1362                    .to_string(),
1363            ));
1364        }
1365
1366        let event = facade
1367            .registry()
1368            .commit_value(id, expected_revision, candidate)
1369            .map_err(ConfigSectionMutationError::Store)?;
1370        let revision = match &event {
1371            ConfigSectionEvent::Changed { revision, .. }
1372            | ConfigSectionEvent::Recovered { revision, .. }
1373            | ConfigSectionEvent::Invalid { revision, .. } => *revision,
1374        };
1375        let materialized = materialize_facade_effective_config(facade, &self.app_data_dir);
1376        if materialized.failures.contains(&id) {
1377            let message =
1378                "configuration runtime hydration failed; retaining last-known-good runtime"
1379                    .to_string();
1380            if let Some(invalid) = facade.registry().mark_runtime_degraded(id, message.clone()) {
1381                publish_registry_event(&self.account_sink, &invalid);
1382            }
1383            return Err(ConfigSectionMutationError::Runtime(message));
1384        }
1385
1386        let mut live = self.config.read().await.clone();
1387        let enforcement_newly_off = id == SectionId::ToolsSkills
1388            && !live.plugin_trust.enforcement_is_off()
1389            && materialized.config.plugin_trust.enforcement_is_off();
1390        apply_runtime_section(id, &materialized.config, &mut live);
1391        if id == SectionId::Env {
1392            live.publish_env_vars();
1393        }
1394        *self.config.write().await = live;
1395        if enforcement_newly_off {
1396            warn_plugin_trust_enforcement_off();
1397        }
1398        publish_registry_event(&self.account_sink, &event);
1399        Ok(revision)
1400    }
1401
1402    /// Validate and stage a provider runtime before the first durable CAS
1403    /// write, then publish config/runtime/health/event under `config_io_lock`.
1404    pub(crate) async fn put_provider_section(
1405        &self,
1406        expected_revision: u64,
1407        mut providers: ProviderConfigs,
1408    ) -> Result<u64, ConfigSectionMutationError> {
1409        let _io = self.config_io_lock.lock().await;
1410        ensure_provider_mcp_migration_ready(&self.app_data_dir)
1411            .map_err(ConfigSectionMutationError::Store)?;
1412        let current = self.config.read().await.clone();
1413        retain_provider_credentials(current.providers(), &mut providers);
1414        let mut candidate = current;
1415        *candidate.providers_mut() = providers.clone();
1416        let (candidate, registry, provider) =
1417            match prepare_provider_candidate(candidate, &self.app_data_dir).await {
1418                Ok(prepared) => prepared,
1419                Err(_) => {
1420                    let message =
1421                        "provider runtime initialization failed; retaining last-known-good runtime"
1422                            .to_string();
1423                    publish_section_failure(
1424                        &self.config_live_health,
1425                        &self.account_sink,
1426                        "providers",
1427                        SectionStatus::Degraded,
1428                        message.clone(),
1429                    );
1430                    return Err(ConfigSectionMutationError::Runtime(message));
1431                }
1432            };
1433
1434        let durable_providers = provider_durable_document(&providers)?;
1435        // Acquire every async publication guard before crossing the durable
1436        // boundary. Once commit succeeds, cancellation cannot strand the file
1437        // ahead of the live config/provider snapshots.
1438        let mut live_config = self.config.write().await;
1439        let mut live_provider = self.provider.write().await;
1440        let (revision, source_path) = if let Some(facade) = self.config_facade.as_ref() {
1441            let mut section = facade.registry().providers.snapshot().data.as_ref().clone();
1442            section.providers = durable_providers;
1443            let event = facade
1444                .registry()
1445                .providers
1446                .commit(expected_revision, section)
1447                .map_err(ConfigSectionMutationError::Store)?;
1448            let ConfigSectionEvent::Changed { revision, .. } = event else {
1449                unreachable!("a successful section commit is changed")
1450            };
1451            (
1452                revision,
1453                facade.registry().providers.snapshot().source_path.clone(),
1454            )
1455        } else {
1456            let store = AtomicJsonStore::new(self.app_data_dir.join("providers.json"), 1);
1457            let revision = store
1458                .commit_allowing_unversioned(
1459                    expected_revision,
1460                    durable_providers,
1461                    validate_provider_config,
1462                )
1463                .map_err(ConfigSectionMutationError::Store)?;
1464            (revision, store.path().to_path_buf())
1465        };
1466
1467        candidate.publish_env_vars();
1468        *live_config = candidate;
1469        self.provider_registry.replace_with(registry);
1470        *live_provider = provider;
1471        publish_section_success(
1472            &self.config_live_health,
1473            &self.account_sink,
1474            "providers",
1475            source_path,
1476            section_is_unhealthy(&self.config_live_health),
1477            Some(revision),
1478        );
1479        Ok(revision)
1480    }
1481
1482    /// Stage MCP connection/initialization/tool discovery, perform the CAS at
1483    /// the manager's pre-publication boundary, then publish the config snapshot
1484    /// before the prepared runtimes and finally emit one section event.
1485    pub(crate) async fn put_mcp_section(
1486        &self,
1487        expected_revision: u64,
1488        mut candidate: McpConfig,
1489    ) -> Result<u64, ConfigSectionMutationError> {
1490        let _io = self.config_io_lock.lock().await;
1491        ensure_provider_mcp_migration_ready(&self.app_data_dir)
1492            .map_err(ConfigSectionMutationError::Store)?;
1493        retain_mcp_credentials(&self.config.read().await.mcp, &mut candidate);
1494        validate_mcp_config(&candidate).map_err(ConfigSectionMutationError::Invalid)?;
1495        let mut hydration_config = Config::default();
1496        hydration_config.mcp = candidate;
1497        hydration_config
1498            .hydrate_mcp_credentials_from_store(&self.app_data_dir)
1499            .map_err(|_| {
1500                ConfigSectionMutationError::Invalid(
1501                    "referenced MCP credential is unavailable".to_string(),
1502                )
1503            })?;
1504        let candidate = hydration_config.mcp.clone();
1505        let mut revision = None;
1506        let mut store_error = None;
1507        let durable_candidate = credential_ref_mcp_document(&candidate)?;
1508        let mut next_config = candidate.clone();
1509        retain_mcp_credential_refs(&durable_candidate, &mut next_config);
1510        let result = self
1511            .mcp_manager
1512            .reconcile_from_config_transactional_after(&candidate, || async {
1513                // Stage may await freely, but acquire the snapshot guard before
1514                // the durable boundary. Commit + snapshot publication below is
1515                // then one cancellation-free synchronous critical section.
1516                let mut live_config = self.config.write().await;
1517                let commit = if let Some(facade) = self.config_facade.as_ref() {
1518                    facade
1519                        .registry()
1520                        .mcp
1521                        .commit(expected_revision, McpSection(durable_candidate))
1522                        .map(|event| match event {
1523                            ConfigSectionEvent::Changed { revision, .. } => revision,
1524                            _ => unreachable!("a successful section commit is changed"),
1525                        })
1526                } else {
1527                    AtomicJsonStore::new(self.app_data_dir.join("mcp.json"), 1).commit(
1528                        expected_revision,
1529                        durable_candidate,
1530                        validate_mcp_config,
1531                    )
1532                };
1533                match commit {
1534                    Ok(committed) => {
1535                        live_config.mcp = next_config;
1536                        revision = Some(committed);
1537                        Ok(())
1538                    }
1539                    Err(error) => {
1540                        store_error = Some(error);
1541                        Err(bamboo_mcp::McpError::InvalidConfig(
1542                            "MCP section durable commit failed".to_string(),
1543                        ))
1544                    }
1545                }
1546            })
1547            .await;
1548        if let Some(error) = store_error {
1549            return Err(ConfigSectionMutationError::Store(error));
1550        }
1551        if result.is_err() {
1552            let message =
1553                "MCP runtime initialization failed; retaining last-known-good runtime".to_string();
1554            publish_section_failure(
1555                &self.mcp_config_live_health,
1556                &self.account_sink,
1557                "mcp",
1558                SectionStatus::Degraded,
1559                message.clone(),
1560            );
1561            return Err(ConfigSectionMutationError::Runtime(message));
1562        }
1563        let revision = revision.expect("successful MCP reconcile commits a revision");
1564        publish_section_success(
1565            &self.mcp_config_live_health,
1566            &self.account_sink,
1567            "mcp",
1568            self.app_data_dir.join("mcp.json"),
1569            section_is_unhealthy(&self.mcp_config_live_health),
1570            Some(revision),
1571        );
1572        Ok(revision)
1573    }
1574}
1575
1576fn credential_reference_inventory(value: &Value) -> std::collections::BTreeMap<String, Value> {
1577    fn collect(value: &Value, path: &str, output: &mut std::collections::BTreeMap<String, Value>) {
1578        match value {
1579            Value::Object(object) => {
1580                for (key, value) in object {
1581                    let child_path =
1582                        format!("{path}/{}", key.replace('~', "~0").replace('/', "~1"));
1583                    let normalized = key
1584                        .chars()
1585                        .filter(|ch| ch.is_ascii_alphanumeric())
1586                        .flat_map(char::to_lowercase)
1587                        .collect::<String>();
1588                    if normalized == "credentialref"
1589                        || normalized.ends_with("credentialref")
1590                        || normalized.ends_with("credentialrefs")
1591                    {
1592                        output.insert(child_path, value.clone());
1593                    } else {
1594                        collect(value, &child_path, output);
1595                    }
1596                }
1597            }
1598            Value::Array(values) => {
1599                for (index, value) in values.iter().enumerate() {
1600                    collect(value, &format!("{path}/{index}"), output);
1601                }
1602            }
1603            _ => {}
1604        }
1605    }
1606
1607    let mut output = std::collections::BTreeMap::new();
1608    collect(value, "", &mut output);
1609    output
1610}
1611
1612fn provider_durable_document(
1613    providers: &ProviderConfigs,
1614) -> Result<ProviderConfigs, ConfigSectionMutationError> {
1615    let mut document = providers.clone();
1616    macro_rules! sanitize {
1617        ($field:ident) => {
1618            if let Some(provider) = document.$field.as_mut() {
1619                provider.api_key.clear();
1620                provider.api_key_encrypted = None;
1621            }
1622        };
1623    }
1624    sanitize!(openai);
1625    sanitize!(anthropic);
1626    sanitize!(gemini);
1627    if let Some(provider) = document.bodhi.as_mut() {
1628        provider.api_key.clear();
1629        provider.api_key_encrypted = None;
1630    }
1631    validate_provider_config(&document).map_err(ConfigSectionMutationError::Invalid)?;
1632    Ok(document)
1633}
1634
1635fn retain_provider_credentials(current: &ProviderConfigs, candidate: &mut ProviderConfigs) {
1636    candidate.extra = current.extra.clone();
1637    macro_rules! retain {
1638        ($field:ident) => {
1639            if let (Some(current), Some(candidate)) = (&current.$field, &mut candidate.$field) {
1640                candidate.api_key = current.api_key.clone();
1641                candidate.api_key_encrypted = current.api_key_encrypted.clone();
1642                if candidate.credential_ref.is_none() {
1643                    candidate.credential_ref = current.credential_ref.clone();
1644                }
1645                if candidate.credential_ref != current.credential_ref {
1646                    candidate.api_key.clear();
1647                    candidate.api_key_encrypted = None;
1648                }
1649                candidate.api_key_from_env = current.api_key_from_env;
1650                candidate.request_overrides = current.request_overrides.clone();
1651                candidate.extra = current.extra.clone();
1652            }
1653        };
1654    }
1655    retain!(openai);
1656    retain!(anthropic);
1657    retain!(gemini);
1658    if let (Some(current), Some(candidate)) = (&current.bodhi, &mut candidate.bodhi) {
1659        candidate.api_key = current.api_key.clone();
1660        candidate.api_key_encrypted = current.api_key_encrypted.clone();
1661        if candidate.credential_ref.is_none() {
1662            candidate.credential_ref = current.credential_ref.clone();
1663        }
1664        if candidate.credential_ref != current.credential_ref {
1665            candidate.api_key.clear();
1666            candidate.api_key_encrypted = None;
1667        }
1668        candidate.extra = current.extra.clone();
1669    }
1670    if let (Some(current), Some(candidate)) = (&current.copilot, &mut candidate.copilot) {
1671        candidate.request_overrides = current.request_overrides.clone();
1672        candidate.extra = current.extra.clone();
1673    }
1674}
1675
1676fn retain_mcp_credentials(current: &McpConfig, candidate: &mut McpConfig) {
1677    for candidate_server in &mut candidate.servers {
1678        let Some(current_server) = current
1679            .servers
1680            .iter()
1681            .find(|server| server.id == candidate_server.id)
1682        else {
1683            continue;
1684        };
1685        match (&current_server.transport, &mut candidate_server.transport) {
1686            (TransportConfig::Stdio(current), TransportConfig::Stdio(candidate)) => {
1687                if candidate.env.is_empty()
1688                    && candidate.env_encrypted.is_empty()
1689                    && candidate.env_credential_refs.is_empty()
1690                {
1691                    candidate.env = current.env.clone();
1692                    candidate.env_encrypted = current.env_encrypted.clone();
1693                    candidate.env_credential_refs = current.env_credential_refs.clone();
1694                }
1695            }
1696            (TransportConfig::Sse(current), TransportConfig::Sse(candidate))
1697                if candidate.headers.is_empty() =>
1698            {
1699                candidate.headers = current.headers.clone();
1700            }
1701            (
1702                TransportConfig::StreamableHttp(current),
1703                TransportConfig::StreamableHttp(candidate),
1704            ) if candidate.headers.is_empty() => {
1705                candidate.headers = current.headers.clone();
1706            }
1707            _ => {}
1708        }
1709    }
1710}
1711
1712fn credential_ref_mcp_document(
1713    runtime: &McpConfig,
1714) -> Result<McpConfig, ConfigSectionMutationError> {
1715    let mut document = runtime.clone();
1716    for server in &mut document.servers {
1717        match &mut server.transport {
1718            TransportConfig::Stdio(config) => {
1719                config.env_encrypted.clear();
1720                config.env.retain(|name, value| {
1721                    !(value.is_empty() || config.env_credential_refs.contains_key(name))
1722                });
1723                if !config.env.is_empty() {
1724                    return Err(ConfigSectionMutationError::Invalid(
1725                        "MCP secret requires a credential reference".to_string(),
1726                    ));
1727                }
1728            }
1729            TransportConfig::Sse(config) => reference_headers(&mut config.headers)?,
1730            TransportConfig::StreamableHttp(config) => reference_headers(&mut config.headers)?,
1731        }
1732    }
1733    Ok(document)
1734}
1735
1736fn retain_mcp_credential_refs(document: &McpConfig, runtime: &mut McpConfig) {
1737    for runtime_server in &mut runtime.servers {
1738        let Some(document_server) = document
1739            .servers
1740            .iter()
1741            .find(|server| server.id == runtime_server.id)
1742        else {
1743            continue;
1744        };
1745        match (&document_server.transport, &mut runtime_server.transport) {
1746            (TransportConfig::Stdio(document), TransportConfig::Stdio(runtime)) => {
1747                runtime.env_encrypted.clear();
1748                runtime.env_credential_refs = document.env_credential_refs.clone();
1749            }
1750            (TransportConfig::Sse(document), TransportConfig::Sse(runtime)) => {
1751                copy_header_ciphertext(&document.headers, &mut runtime.headers);
1752            }
1753            (
1754                TransportConfig::StreamableHttp(document),
1755                TransportConfig::StreamableHttp(runtime),
1756            ) => copy_header_ciphertext(&document.headers, &mut runtime.headers),
1757            _ => {}
1758        }
1759    }
1760}
1761
1762fn copy_header_ciphertext(
1763    document: &[bamboo_mcp::HeaderConfig],
1764    runtime: &mut [bamboo_mcp::HeaderConfig],
1765) {
1766    for runtime_header in runtime {
1767        if let Some(document_header) = document
1768            .iter()
1769            .find(|header| header.name == runtime_header.name)
1770        {
1771            runtime_header.value_encrypted = None;
1772            runtime_header.credential_ref = document_header.credential_ref.clone();
1773        }
1774    }
1775}
1776
1777fn reference_headers(
1778    headers: &mut [bamboo_mcp::HeaderConfig],
1779) -> Result<(), ConfigSectionMutationError> {
1780    for header in headers {
1781        if !header.value.is_empty() && header.credential_ref.is_none() {
1782            return Err(ConfigSectionMutationError::Invalid(
1783                "MCP secret requires a credential reference".to_string(),
1784            ));
1785        }
1786        header.value.clear();
1787        header.value_encrypted = None;
1788    }
1789    Ok(())
1790}
1791
1792impl AppState {
1793    /// Reload the provider based on current configuration
1794    ///
1795    /// Re-reads the configuration and creates a new LLM provider
1796    /// instance, allowing runtime switching of providers or models.
1797    ///
1798    /// # Returns
1799    ///
1800    /// `Ok(())` if the provider was successfully reloaded.
1801    ///
1802    /// # Errors
1803    ///
1804    /// Returns an error if:
1805    /// - Configuration cannot be read
1806    /// - Provider initialization fails (e.g., invalid API key)
1807    ///
1808    /// # Example
1809    ///
1810    /// ```rust,no_run
1811    /// use bamboo_server::app_state::AppState;
1812    /// use std::path::PathBuf;
1813    ///
1814    /// #[tokio::main]
1815    /// async fn main() {
1816    ///     let state = AppState::new(PathBuf::from("/path/to/.bamboo"))
1817    ///         .await
1818    ///         .expect("failed to initialize app state");
1819    ///
1820    ///     // User updated config file...
1821    ///     state.reload_provider().await.expect("Provider reload failed");
1822    /// }
1823    /// ```
1824    pub async fn reload_provider(&self) -> Result<(), bamboo_llm::LLMError> {
1825        let config = self.config.read().await.clone();
1826        let candidate_registry =
1827            bamboo_llm::ProviderRegistry::from_config(&config, self.app_data_dir.clone()).await?;
1828        let default_provider_name = candidate_registry.default_provider_name();
1829        tracing::info!(
1830            default_provider = %default_provider_name,
1831            legacy_provider = %config.provider,
1832            has_provider_instances = config.has_provider_instances(),
1833            "Reloading provider runtime from current config"
1834        );
1835
1836        let new_provider = candidate_registry.get_default().ok_or_else(|| {
1837            let message = if config.has_provider_instances() {
1838                format!(
1839                    "Default provider instance '{}' is not available or failed to initialize",
1840                    default_provider_name
1841                )
1842            } else {
1843                format!(
1844                    "Provider '{}' is not available or failed to initialize",
1845                    config.provider
1846                )
1847            };
1848            bamboo_llm::LLMError::Auth(message)
1849        })?;
1850
1851        let mut provider = self.provider.write().await;
1852        self.provider_registry.replace_with(candidate_registry);
1853        *provider = new_provider;
1854
1855        tracing::info!(
1856            default_provider = %default_provider_name,
1857            "Provider reloaded successfully"
1858        );
1859        Ok(())
1860    }
1861
1862    /// Reload the configuration from file
1863    ///
1864    /// Reads the configuration file again and updates the in-memory
1865    /// config. Note: This does NOT automatically reload the provider;
1866    /// call `reload_provider()` afterwards if needed.
1867    ///
1868    /// # Returns
1869    ///
1870    /// The newly loaded configuration.
1871    ///
1872    /// # Example
1873    ///
1874    /// ```rust,no_run
1875    /// use bamboo_server::app_state::AppState;
1876    /// use std::path::PathBuf;
1877    ///
1878    /// #[tokio::main]
1879    /// async fn main() {
1880    ///     let state = AppState::new(PathBuf::from("/path/to/.bamboo"))
1881    ///         .await
1882    ///         .expect("failed to initialize app state");
1883    ///
1884    ///     // Reload config from disk
1885    ///     let new_config = state.reload_config().await;
1886    ///
1887    ///     // Optionally reload provider with new config
1888    ///     state.reload_provider().await.ok();
1889    /// }
1890    /// ```
1891    pub async fn reload_config(&self) -> Config {
1892        // The process facade is the only production read authority. Its
1893        // watcher owns disk reloads, so this compatibility method republishes
1894        // the current immutable facade view instead of constructing a second
1895        // disk-reading Config authority.
1896        let _io = self.config_io_lock.lock().await;
1897        let mut config = self.config.write().await;
1898        let new_config = self
1899            .config_facade
1900            .as_ref()
1901            .map(|facade| load_facade_effective_config(facade, &self.app_data_dir))
1902            .unwrap_or_else(|| {
1903                Config::from_data_dir_without_publish(Some(self.app_data_dir.clone()))
1904            });
1905        new_config.publish_env_vars();
1906        *config = new_config.clone();
1907        new_config
1908    }
1909
1910    async fn persist_config_snapshot(&self, config: Config) -> anyhow::Result<()> {
1911        let data_dir = self.app_data_dir.clone();
1912        if self.config_facade.is_some() {
1913            tokio::task::spawn_blocking(move || {
1914                bamboo_config::persist_facade_effective_config(data_dir, &config)
1915            })
1916            .await
1917            .map_err(|e| anyhow::anyhow!("Config save task failed: {e}"))??;
1918            let sections = bamboo_config::SECTION_DESCRIPTORS
1919                .iter()
1920                .map(|descriptor| descriptor.id)
1921                .collect::<Vec<_>>();
1922            publish_exact_facade_commit(self.config_facade.as_ref(), &self.account_sink, &sections)
1923                .map_err(|error| anyhow::anyhow!(error.to_string()))?;
1924        } else {
1925            tokio::task::spawn_blocking(move || config.save_to_dir(data_dir))
1926                .await
1927                .map_err(|e| anyhow::anyhow!("Config save task failed: {e}"))??;
1928        }
1929        Ok(())
1930    }
1931
1932    /// Unified config update entrypoint.
1933    ///
1934    /// Invariants:
1935    /// - Build and validate a detached candidate
1936    /// - Persist to disk before publishing it in memory
1937    /// - Apply runtime side-effects last (provider reload, MCP reconcile)
1938    pub async fn update_config<F>(
1939        &self,
1940        update: F,
1941        effects: ConfigUpdateEffects,
1942    ) -> Result<Config, AppError>
1943    where
1944        F: FnOnce(&mut Config) -> Result<(), AppError>,
1945    {
1946        // Hold the config-IO lock across BOTH the in-memory mutation AND the disk
1947        // persist, so a concurrent reload_config can't read the disk in the gap
1948        // before we persist and then clobber this mutation with the stale copy
1949        // (#126). The lock is dropped before apply_config_effects — slow side
1950        // effects (provider reload) don't need to block reloads/other updates.
1951        let snapshot = {
1952            let _io = self.config_io_lock.lock().await;
1953            let (snapshot, enforcement_newly_off) = {
1954                let cfg = self.config.read().await;
1955                // Refuse the whole operation (no in-memory mutation, no disk
1956                // write) while a config-corruption recovery is pending
1957                // confirmation (#153) — `save_to_dir` would reject the persist
1958                // anyway, but checking here BEFORE `update()` runs keeps the
1959                // in-memory config frozen exactly at the recovered state
1960                // instead of silently drifting further from what's on disk.
1961                reject_if_recovery_pending(&cfg)?;
1962                let was_off = cfg.plugin_trust.enforcement_is_off();
1963                let mut candidate = cfg.clone();
1964                update(&mut candidate)?;
1965                // Backfill any missing connect.platforms id (#496) on the live
1966                // in-memory config itself — not just inside `save_to_dir`'s
1967                // internal save-copy — so the response this update returns
1968                // (and any GET immediately after) already reflects the id a
1969                // client can round-trip on its next PATCH.
1970                candidate.assign_connect_platform_ids();
1971                // Same live-vs-save-copy treatment for ciphertext (#516):
1972                // `save_to_dir` refreshes `*_encrypted` only on its save-time
1973                // clone, so a secret set through this entrypoint (e.g. a
1974                // provider instance created over HTTP) would otherwise stay
1975                // plaintext-only in memory — and the next settings-PATCH merge
1976                // (`build_merged_config`'s serde round-trip drops plaintext)
1977                // would lose the key entirely.
1978                candidate.refresh_encrypted_secrets().map_err(|e| {
1979                    AppError::InternalError(anyhow::anyhow!(
1980                        "Failed to refresh encrypted secrets: {e}"
1981                    ))
1982                })?;
1983                let newly_off = !was_off && candidate.plugin_trust.enforcement_is_off();
1984                (candidate, newly_off)
1985            };
1986            // Loud signal at the MOMENT plugin_trust.enforcement is flipped off
1987            // live (e.g. via `bamboo config set plugin_trust.enforcement off`
1988            // over HTTP), mirroring the boot-time warn in `AppState::new` — so
1989            // this security-relevant relaxation is never applied silently. Only
1990            // on the transition into `Off` (not every unrelated config write
1991            // while already off).
1992            if enforcement_newly_off {
1993                warn_plugin_trust_enforcement_off();
1994            }
1995            self.persist_config_snapshot(snapshot.clone())
1996                .await
1997                .map_err(|e| {
1998                    AppError::InternalError(anyhow::anyhow!("Failed to save config: {e}"))
1999                })?;
2000            let snapshot = self
2001                .config_facade
2002                .as_ref()
2003                .map(|facade| load_facade_effective_config(facade, &self.app_data_dir))
2004                .unwrap_or(snapshot);
2005            {
2006                let mut cfg = self.config.write().await;
2007                snapshot.publish_env_vars();
2008                *cfg = snapshot.clone();
2009            }
2010            snapshot
2011        };
2012
2013        self.apply_config_effects(snapshot.clone(), effects).await?;
2014        Ok(snapshot)
2015    }
2016
2017    /// Compatibility provider update whose credential and metadata documents
2018    /// share the recoverable config transaction manifest.
2019    pub async fn update_config_with_provider_credentials<F>(
2020        &self,
2021        update: F,
2022        provider_intents: std::collections::BTreeSet<String>,
2023        provider_instance_intents: std::collections::BTreeSet<String>,
2024        effects: ConfigUpdateEffects,
2025    ) -> Result<Config, AppError>
2026    where
2027        F: FnOnce(&mut Config) -> Result<(), AppError>,
2028    {
2029        if provider_intents.is_empty() && provider_instance_intents.is_empty() {
2030            return self.update_config(update, effects).await;
2031        }
2032        let config_facade = self.config_facade.clone();
2033        let account_sink = self.account_sink.clone();
2034        let (snapshot, enforcement_newly_off) = {
2035            let _io = self.config_io_lock.lock().await;
2036            let (mut candidate, enforcement_newly_off) = {
2037                let cfg = self.config.read().await;
2038                reject_if_recovery_pending(&cfg)?;
2039                let was_off = cfg.plugin_trust.enforcement_is_off();
2040                let mut candidate = cfg.clone();
2041                update(&mut candidate)?;
2042                // Provider plaintext may be present until the exact credential
2043                // transaction assigns its durable reference. Compare every
2044                // other section against a candidate whose provider projection
2045                // is replaced with the current durable domain; the exact
2046                // transaction below remains the sole provider validator.
2047                let mut non_provider_candidate = candidate.clone();
2048                apply_runtime_section(SectionId::Providers, &cfg, &mut non_provider_candidate);
2049                let mut changed =
2050                    bamboo_config::changed_facade_sections(&cfg, &non_provider_candidate).map_err(
2051                        |_| {
2052                            AppError::InternalError(anyhow::anyhow!(
2053                                "failed to compare modular configuration sections"
2054                            ))
2055                        },
2056                    )?;
2057                // The compatibility wire shape can normalize duplicated
2058                // external-broker metadata while leaving the actual subagents
2059                // module byte-for-byte equivalent. That is an unchanged echo,
2060                // not a second section mutation.
2061                if serde_json::to_value(cfg.subagents()).ok()
2062                    == serde_json::to_value(candidate.subagents()).ok()
2063                {
2064                    changed.retain(|section| *section != SectionId::Subagents);
2065                }
2066                if let Some(other) = changed
2067                    .into_iter()
2068                    .find(|section| *section != SectionId::Providers)
2069                {
2070                    return Err(AppError::BadRequest(format!(
2071                        "provider credential updates cannot be combined with {} changes; split the request",
2072                        other.descriptor().name
2073                    )));
2074                }
2075                candidate.assign_connect_platform_ids();
2076                candidate.refresh_encrypted_secrets().map_err(|error| {
2077                    AppError::InternalError(anyhow::anyhow!(
2078                        "Failed to refresh encrypted secrets: {error}"
2079                    ))
2080                })?;
2081                let newly_off = !was_off && candidate.plugin_trust.enforcement_is_off();
2082                (candidate, newly_off)
2083            };
2084            let data_dir = self.app_data_dir.clone();
2085            let candidate = tokio::task::spawn_blocking(move || {
2086                bamboo_config::persist_provider_instance_credential_transaction(
2087                    &data_dir,
2088                    &mut candidate,
2089                    &provider_intents,
2090                    &provider_instance_intents,
2091                )?;
2092                load_committed_effective_config(&data_dir)
2093            })
2094            .await
2095            .map_err(|error| {
2096                AppError::InternalError(anyhow::anyhow!(
2097                    "provider credential transaction task failed: {error}"
2098                ))
2099            })?
2100            .map_err(|error| match error {
2101                ConfigStoreError::Conflict { expected, actual } => {
2102                    AppError::ConfigConflict { expected, actual }
2103                }
2104                ConfigStoreError::Validation(message) => AppError::BadRequest(message),
2105                ConfigStoreError::Io(error) => AppError::StorageError(error),
2106                ConfigStoreError::Json(_) => {
2107                    AppError::BadRequest("configuration document is invalid".to_string())
2108                }
2109                ConfigStoreError::Watch(error) => {
2110                    AppError::InternalError(anyhow::anyhow!("configuration watch failed: {error}"))
2111                }
2112            })?;
2113            publish_exact_facade_commit(
2114                config_facade.as_ref(),
2115                &account_sink,
2116                &[SectionId::Credentials, SectionId::Providers],
2117            )?;
2118            {
2119                let mut cfg = self.config.write().await;
2120                candidate.publish_env_vars();
2121                *cfg = candidate.clone();
2122            }
2123            (candidate, enforcement_newly_off)
2124        };
2125        if enforcement_newly_off {
2126            warn_plugin_trust_enforcement_off();
2127        }
2128        self.apply_config_effects(snapshot.clone(), effects).await?;
2129        Ok(snapshot)
2130    }
2131
2132    /// Mutate user env vars through the recoverable credentials.json +
2133    /// config.json exact transaction. The detached task owns the mutation so
2134    /// request cancellation cannot strand durable metadata ahead of runtime.
2135    pub async fn update_env_var_credentials<F>(
2136        &self,
2137        expected_revision: u64,
2138        env_intents: std::collections::BTreeSet<String>,
2139        update: F,
2140    ) -> Result<(Config, u64), AppError>
2141    where
2142        F: FnOnce(&mut Config) -> Result<(), AppError> + Send + 'static,
2143    {
2144        let config_io_lock = self.config_io_lock.clone();
2145        let config = self.config.clone();
2146        let app_data_dir = self.app_data_dir.clone();
2147        let account_sink = self.account_sink.clone();
2148        let config_facade = self.config_facade.clone();
2149        let transaction = tokio::spawn(async move {
2150            let _io = config_io_lock.lock().await;
2151            let mut candidate = {
2152                let current = config.read().await;
2153                reject_if_recovery_pending(&current)?;
2154                let mut candidate = current.clone();
2155                update(&mut candidate)?;
2156                candidate.assign_connect_platform_ids();
2157                candidate
2158            };
2159            let transaction_dir = app_data_dir.clone();
2160            let (candidate, revision) = tokio::task::spawn_blocking(move || {
2161                let revision = bamboo_config::persist_env_var_credential_transaction_at_revision(
2162                    &transaction_dir,
2163                    &mut candidate,
2164                    &env_intents,
2165                    expected_revision,
2166                )?;
2167                // Reload from the durable authority selected by the layout.
2168                // Active modular layouts never publish stale config.json.
2169                let committed = load_committed_effective_config(&transaction_dir)?;
2170                Ok::<_, ConfigStoreError>((committed, revision))
2171            })
2172            .await
2173            .map_err(|error| {
2174                AppError::InternalError(anyhow::anyhow!(
2175                    "env credential transaction task failed: {error}"
2176                ))
2177            })?
2178            .map_err(|error| match error {
2179                ConfigStoreError::Conflict { expected, actual } => {
2180                    AppError::ConfigConflict { expected, actual }
2181                }
2182                ConfigStoreError::Validation(message) => AppError::BadRequest(message),
2183                ConfigStoreError::Io(error) => AppError::StorageError(error),
2184                ConfigStoreError::Json(_) => {
2185                    AppError::BadRequest("configuration document is invalid".to_string())
2186                }
2187                ConfigStoreError::Watch(error) => {
2188                    AppError::InternalError(anyhow::anyhow!("configuration watch failed: {error}"))
2189                }
2190            })?;
2191            publish_exact_facade_commit(
2192                config_facade.as_ref(),
2193                &account_sink,
2194                &[SectionId::Credentials, SectionId::Env],
2195            )?;
2196            candidate.publish_env_vars();
2197            *config.write().await = candidate.clone();
2198            Ok::<_, AppError>((candidate, revision))
2199        });
2200        transaction.await.map_err(|error| {
2201            AppError::InternalError(anyhow::anyhow!(
2202                "env credential transaction task failed: {error}"
2203            ))
2204        })?
2205    }
2206
2207    /// Mutate notification metadata and ntfy/Bark credentials through the
2208    /// recoverable credentials.json + config.json exact transaction. The
2209    /// detached task completes durable commit and live publication even if the
2210    /// initiating HTTP request is cancelled.
2211    pub async fn update_notification_credentials<F>(
2212        &self,
2213        expected_revision: u64,
2214        secret_intents: std::collections::BTreeSet<String>,
2215        reset_domain: bool,
2216        update: F,
2217    ) -> Result<(Config, u64), AppError>
2218    where
2219        F: FnOnce(&mut Config) -> Result<(), AppError> + Send + 'static,
2220    {
2221        let config_io_lock = self.config_io_lock.clone();
2222        let config = self.config.clone();
2223        let app_data_dir = self.app_data_dir.clone();
2224        let account_sink = self.account_sink.clone();
2225        let config_facade = self.config_facade.clone();
2226        let transaction = tokio::spawn(async move {
2227            let _io = config_io_lock.lock().await;
2228            let mut candidate = {
2229                let current = config.read().await;
2230                reject_if_recovery_pending(&current)?;
2231                let mut candidate = current.clone();
2232                update(&mut candidate)?;
2233                candidate
2234            };
2235            let transaction_dir = app_data_dir.clone();
2236            let (candidate, revision) = tokio::task::spawn_blocking(move || {
2237                let revision =
2238                    bamboo_config::persist_notification_credential_transaction_at_revision_with_reset(
2239                            &transaction_dir,
2240                            &mut candidate,
2241                            &secret_intents,
2242                            reset_domain,
2243                            expected_revision,
2244                        )?;
2245                let committed = load_committed_effective_config(&transaction_dir)?;
2246                Ok::<_, ConfigStoreError>((committed, revision))
2247            })
2248            .await
2249            .map_err(|error| {
2250                AppError::InternalError(anyhow::anyhow!(
2251                    "notification credential transaction task failed: {error}"
2252                ))
2253            })?
2254            .map_err(|error| match error {
2255                ConfigStoreError::Conflict { expected, actual } => {
2256                    AppError::ConfigConflict { expected, actual }
2257                }
2258                ConfigStoreError::Validation(message) => AppError::BadRequest(message),
2259                ConfigStoreError::Io(error) => AppError::StorageError(error),
2260                ConfigStoreError::Json(_) => {
2261                    AppError::BadRequest("configuration document is invalid".to_string())
2262                }
2263                ConfigStoreError::Watch(error) => {
2264                    AppError::InternalError(anyhow::anyhow!("configuration watch failed: {error}"))
2265                }
2266            })?;
2267            publish_exact_facade_commit(
2268                config_facade.as_ref(),
2269                &account_sink,
2270                &[SectionId::Credentials, SectionId::Notifications],
2271            )?;
2272            *config.write().await = candidate.clone();
2273            Ok::<_, AppError>((candidate, revision))
2274        });
2275        transaction.await.map_err(|error| {
2276            AppError::InternalError(anyhow::anyhow!(
2277                "notification credential transaction task failed: {error}"
2278            ))
2279        })?
2280    }
2281
2282    /// Replace connect metadata and explicitly touched platform credentials
2283    /// through the active connect-section + credential exact transaction.
2284    /// The detached task owns durable commit and runtime publication so a
2285    /// cancelled HTTP request cannot leave memory behind the committed pair.
2286    pub async fn update_connect_credentials<F>(
2287        &self,
2288        expected_revision: u64,
2289        secret_intents: bamboo_config::patch::ConnectSecretIntents,
2290        update: F,
2291    ) -> Result<(Config, u64), AppError>
2292    where
2293        F: FnOnce(&mut Config) -> Result<(), AppError> + Send + 'static,
2294    {
2295        let config_io_lock = self.config_io_lock.clone();
2296        let config = self.config.clone();
2297        let app_data_dir = self.app_data_dir.clone();
2298        let account_sink = self.account_sink.clone();
2299        let config_facade = self.config_facade.clone();
2300        let transaction = tokio::spawn(async move {
2301            let _io = config_io_lock.lock().await;
2302            let mut candidate = {
2303                let current = config.read().await;
2304                reject_if_recovery_pending(&current)?;
2305                let mut candidate = current.clone();
2306                update(&mut candidate)?;
2307                candidate.assign_connect_platform_ids();
2308                candidate
2309            };
2310            let transaction_dir = app_data_dir.clone();
2311            let (candidate, revision) = tokio::task::spawn_blocking(move || {
2312                let revision = bamboo_config::persist_connect_credential_transaction_at_revision(
2313                    &transaction_dir,
2314                    &mut candidate,
2315                    &secret_intents,
2316                    expected_revision,
2317                )?;
2318                let committed = load_committed_effective_config(&transaction_dir)?;
2319                Ok::<_, ConfigStoreError>((committed, revision))
2320            })
2321            .await
2322            .map_err(|error| {
2323                AppError::InternalError(anyhow::anyhow!(
2324                    "connect credential transaction task failed: {error}"
2325                ))
2326            })?
2327            .map_err(|error| match error {
2328                ConfigStoreError::Conflict { expected, actual } => {
2329                    AppError::ConfigConflict { expected, actual }
2330                }
2331                ConfigStoreError::Validation(message) => AppError::BadRequest(message),
2332                ConfigStoreError::Io(error) => AppError::StorageError(error),
2333                ConfigStoreError::Json(_) => {
2334                    AppError::BadRequest("configuration document is invalid".to_string())
2335                }
2336                ConfigStoreError::Watch(error) => {
2337                    AppError::InternalError(anyhow::anyhow!("configuration watch failed: {error}"))
2338                }
2339            })?;
2340            publish_exact_facade_commit(
2341                config_facade.as_ref(),
2342                &account_sink,
2343                &[SectionId::Credentials, SectionId::Connect],
2344            )?;
2345            *config.write().await = candidate.clone();
2346            Ok::<_, AppError>((candidate, revision))
2347        });
2348        transaction.await.map_err(|error| {
2349            AppError::InternalError(anyhow::anyhow!(
2350                "connect credential transaction task failed: {error}"
2351            ))
2352        })?
2353    }
2354
2355    /// Mutate access-control metadata and verifier records through the active
2356    /// access-control section + credential exact transaction.
2357    pub async fn update_access_control_credentials<F>(
2358        &self,
2359        expected_revision: u64,
2360        password_intent: bool,
2361        device_intents: std::collections::BTreeSet<String>,
2362        update: F,
2363    ) -> Result<(Config, u64), AppError>
2364    where
2365        F: FnOnce(&mut Config) -> Result<(), AppError> + Send + 'static,
2366    {
2367        let config_io_lock = self.config_io_lock.clone();
2368        let config = self.config.clone();
2369        let app_data_dir = self.app_data_dir.clone();
2370        let account_sink = self.account_sink.clone();
2371        let config_facade = self.config_facade.clone();
2372        let transaction = tokio::spawn(async move {
2373            let _io = config_io_lock.lock().await;
2374            let mut candidate = {
2375                let current = config.read().await;
2376                reject_if_recovery_pending(&current)?;
2377                let mut candidate = current.clone();
2378                update(&mut candidate)?;
2379                candidate
2380            };
2381            let transaction_dir = app_data_dir.clone();
2382            let (candidate, revision) = tokio::task::spawn_blocking(move || {
2383                let revision =
2384                    bamboo_config::persist_access_control_credential_transaction_at_revision(
2385                        &transaction_dir,
2386                        &mut candidate,
2387                        password_intent,
2388                        &device_intents,
2389                        expected_revision,
2390                    )?;
2391                let committed = load_committed_effective_config(&transaction_dir)?;
2392                Ok::<_, ConfigStoreError>((committed, revision))
2393            })
2394            .await
2395            .map_err(|error| {
2396                AppError::InternalError(anyhow::anyhow!(
2397                    "access-control credential transaction task failed: {error}"
2398                ))
2399            })?
2400            .map_err(|error| match error {
2401                ConfigStoreError::Conflict { expected, actual } => {
2402                    AppError::ConfigConflict { expected, actual }
2403                }
2404                ConfigStoreError::Validation(message) => AppError::BadRequest(message),
2405                ConfigStoreError::Io(error) => AppError::StorageError(error),
2406                ConfigStoreError::Json(_) => {
2407                    AppError::BadRequest("configuration document is invalid".to_string())
2408                }
2409                ConfigStoreError::Watch(error) => {
2410                    AppError::InternalError(anyhow::anyhow!("configuration watch failed: {error}"))
2411                }
2412            })?;
2413            publish_exact_facade_commit(
2414                config_facade.as_ref(),
2415                &account_sink,
2416                &[SectionId::Credentials, SectionId::AccessControl],
2417            )?;
2418            *config.write().await = candidate.clone();
2419            Ok::<_, AppError>((candidate, revision))
2420        });
2421        transaction.await.map_err(|error| {
2422            AppError::InternalError(anyhow::anyhow!(
2423                "access-control credential transaction task failed: {error}"
2424            ))
2425        })?
2426    }
2427
2428    /// Mutate cluster-fabric node metadata and SSH credentials through the
2429    /// recoverable credentials.json + config.json exact transaction. The
2430    /// detached task owns durable commit, committed-root reload, and live
2431    /// publication so request cancellation cannot leave runtime behind disk.
2432    pub async fn update_cluster_fabric_credentials<F>(
2433        &self,
2434        expected_revision: u64,
2435        node_intents: std::collections::BTreeSet<String>,
2436        update: F,
2437    ) -> Result<(Config, u64), AppError>
2438    where
2439        F: FnOnce(&mut Config) -> Result<(), AppError> + Send + 'static,
2440    {
2441        let config_io_lock = self.config_io_lock.clone();
2442        let config = self.config.clone();
2443        let app_data_dir = self.app_data_dir.clone();
2444        let account_sink = self.account_sink.clone();
2445        let config_facade = self.config_facade.clone();
2446        let transaction = tokio::spawn(async move {
2447            let _io = config_io_lock.lock().await;
2448            let mut candidate = {
2449                let current = config.read().await;
2450                reject_if_recovery_pending(&current)?;
2451                let mut candidate = current.clone();
2452                update(&mut candidate)?;
2453                candidate
2454            };
2455            let transaction_dir = app_data_dir.clone();
2456            let (candidate, revision) = tokio::task::spawn_blocking(move || {
2457                let revision =
2458                    bamboo_config::persist_cluster_fabric_credential_transaction_at_revision(
2459                        &transaction_dir,
2460                        &mut candidate,
2461                        &node_intents,
2462                        expected_revision,
2463                    )?;
2464                let committed = load_committed_effective_config(&transaction_dir)?;
2465                Ok::<_, ConfigStoreError>((committed, revision))
2466            })
2467            .await
2468            .map_err(|error| {
2469                AppError::InternalError(anyhow::anyhow!(
2470                    "cluster credential transaction task failed: {error}"
2471                ))
2472            })?
2473            .map_err(|error| match error {
2474                ConfigStoreError::Conflict { expected, actual } => {
2475                    AppError::ConfigConflict { expected, actual }
2476                }
2477                ConfigStoreError::Validation(message) => AppError::BadRequest(message),
2478                ConfigStoreError::Io(error) => AppError::StorageError(error),
2479                ConfigStoreError::Json(_) => {
2480                    AppError::BadRequest("configuration document is invalid".to_string())
2481                }
2482                ConfigStoreError::Watch(error) => {
2483                    AppError::InternalError(anyhow::anyhow!("configuration watch failed: {error}"))
2484                }
2485            })?;
2486            publish_exact_facade_commit(
2487                config_facade.as_ref(),
2488                &account_sink,
2489                &[SectionId::Credentials, SectionId::ClusterFabric],
2490            )?;
2491            *config.write().await = candidate.clone();
2492            Ok::<_, AppError>((candidate, revision))
2493        });
2494        transaction.await.map_err(|error| {
2495            AppError::InternalError(anyhow::anyhow!(
2496                "cluster credential transaction task failed: {error}"
2497            ))
2498        })?
2499    }
2500
2501    /// Persist proxy authentication through the isolated credential store and
2502    /// publish the detached runtime candidate only after the exact transaction
2503    /// has durably committed.
2504    pub async fn update_proxy_auth_credential(
2505        &self,
2506        auth: Option<bamboo_config::ProxyAuth>,
2507        expected_revision: u64,
2508        effects: ConfigUpdateEffects,
2509    ) -> Result<
2510        (
2511            Config,
2512            bamboo_config::CredentialStatus,
2513            bamboo_config::CredentialStoreHealth,
2514        ),
2515        AppError,
2516    > {
2517        let config_io_lock = self.config_io_lock.clone();
2518        let config = self.config.clone();
2519        let app_data_dir = self.app_data_dir.clone();
2520        let credential_store = self.credential_store.clone();
2521        let provider_registry = self.provider_registry.clone();
2522        let provider = self.provider.clone();
2523        let mcp_manager = self.mcp_manager.clone();
2524        let config_facade = self.config_facade.clone();
2525        let account_sink = self.account_sink.clone();
2526
2527        // This task owns the mutation after dispatch. Dropping the request's
2528        // JoinHandle does not cancel it, so the blocking durable transaction,
2529        // live publication, and runtime convergence complete as one serialized
2530        // operation even when the caller disconnects.
2531        let transaction = tokio::spawn(async move {
2532            let _io = config_io_lock.lock().await;
2533            let mut candidate = {
2534                let cfg = config.read().await;
2535                reject_if_recovery_pending(&cfg)?;
2536                let mut candidate = cfg.clone();
2537                candidate.proxy_auth = auth;
2538                candidate.assign_connect_platform_ids();
2539                candidate.refresh_encrypted_secrets().map_err(|error| {
2540                    AppError::InternalError(anyhow::anyhow!(
2541                        "Failed to refresh encrypted secrets: {error}"
2542                    ))
2543                })?;
2544                candidate
2545            };
2546            let transaction_dir = app_data_dir.clone();
2547            let status_reference =
2548                candidate
2549                    .proxy_auth_credential_ref
2550                    .clone()
2551                    .unwrap_or_else(|| {
2552                        bamboo_config::CredentialRef::parse("proxy.default.auth")
2553                            .expect("canonical proxy credential reference is valid")
2554                    });
2555            let (candidate, reference) = tokio::task::spawn_blocking(move || {
2556                bamboo_config::persist_proxy_auth_credential_transaction_at_revision(
2557                    &transaction_dir,
2558                    &mut candidate,
2559                    expected_revision,
2560                )?;
2561                Ok::<_, ConfigStoreError>((
2562                    load_committed_effective_config(&transaction_dir)?,
2563                    status_reference,
2564                ))
2565            })
2566            .await
2567            .map_err(|error| {
2568                AppError::InternalError(anyhow::anyhow!(
2569                    "proxy credential transaction task failed: {error}"
2570                ))
2571            })?
2572            .map_err(|error| match error {
2573                ConfigStoreError::Conflict { expected, actual } => {
2574                    AppError::ConfigConflict { expected, actual }
2575                }
2576                ConfigStoreError::Validation(message) => AppError::BadRequest(message),
2577                ConfigStoreError::Io(error) => AppError::StorageError(error),
2578                ConfigStoreError::Json(_) => {
2579                    AppError::BadRequest("configuration document is invalid".to_string())
2580                }
2581                ConfigStoreError::Watch(error) => {
2582                    AppError::InternalError(anyhow::anyhow!("configuration watch failed: {error}"))
2583                }
2584            })?;
2585
2586            publish_exact_facade_commit(
2587                config_facade.as_ref(),
2588                &account_sink,
2589                &[SectionId::Credentials, SectionId::Core],
2590            )?;
2591
2592            // No fallible metadata read occurs before publication. Once the
2593            // transaction commits, a response error can no longer leave live
2594            // config behind its durable credential/config pair.
2595            candidate.publish_env_vars();
2596            *config.write().await = candidate.clone();
2597
2598            if effects.reload_provider {
2599                match bamboo_llm::ProviderRegistry::from_config(&candidate, app_data_dir.clone())
2600                    .await
2601                {
2602                    Ok(candidate_registry) => {
2603                        if let Some(candidate_provider) = candidate_registry.get_default() {
2604                            let mut live_provider = provider.write().await;
2605                            provider_registry.replace_with(candidate_registry);
2606                            *live_provider = candidate_provider;
2607                        } else {
2608                            tracing::warn!(
2609                                "proxy auth committed but provider reload had no default provider"
2610                            );
2611                        }
2612                    }
2613                    Err(error) => {
2614                        tracing::warn!(
2615                            error = %error,
2616                            "proxy auth committed but provider reload failed"
2617                        );
2618                    }
2619                }
2620            }
2621
2622            if effects.reconcile_mcp {
2623                mcp_manager.reconcile_from_config(&candidate.mcp).await;
2624            }
2625
2626            let (status, health) =
2627                credential_store
2628                    .status_with_health(&reference)
2629                    .map_err(|error| match error {
2630                        ConfigStoreError::Conflict { expected, actual } => {
2631                            AppError::ConfigConflict { expected, actual }
2632                        }
2633                        ConfigStoreError::Validation(_) | ConfigStoreError::Json(_) => {
2634                            AppError::InternalError(anyhow::anyhow!(
2635                                "credential store validation failed"
2636                            ))
2637                        }
2638                        ConfigStoreError::Io(error) => AppError::StorageError(error),
2639                        ConfigStoreError::Watch(error) => AppError::InternalError(anyhow::anyhow!(
2640                            "configuration watch failed: {error}"
2641                        )),
2642                    })?;
2643            Ok::<_, AppError>((candidate, status, health))
2644        });
2645        transaction.await.map_err(|error| {
2646            AppError::InternalError(anyhow::anyhow!(
2647                "proxy credential mutation task failed: {error}"
2648            ))
2649        })?
2650    }
2651
2652    /// Replace the full config (used for JSON merge endpoints).
2653    pub async fn replace_config(
2654        &self,
2655        mut new_config: Config,
2656        effects: ConfigUpdateEffects,
2657    ) -> Result<Config, AppError> {
2658        // Backfill any missing connect.platforms id (#496) up front, before
2659        // any of the clones below are taken, so the in-memory config, the
2660        // disk-persisted snapshot, and the value this call returns to the
2661        // caller (the settings-merge HTTP response) all agree on the same
2662        // ids — mirrors the `update_config` treatment above.
2663        new_config.assign_connect_platform_ids();
2664        // Keep ciphertext in sync with plaintext on the config that becomes
2665        // the live in-memory state — same #516 rationale as `update_config`.
2666        new_config.refresh_encrypted_secrets().map_err(|e| {
2667            AppError::InternalError(anyhow::anyhow!("Failed to refresh encrypted secrets: {e}"))
2668        })?;
2669
2670        // Same #126 serialization as update_config: mutate + persist under the
2671        // config-IO lock so a reload can't interleave; effects run unlocked.
2672        let new_config = {
2673            let _io = self.config_io_lock.lock().await;
2674            let was_off = {
2675                let cfg = self.config.read().await;
2676                // Same guard as `update_config` (#153): a full-config replace
2677                // must not silently blow away an unconfirmed recovery either.
2678                reject_if_recovery_pending(&cfg)?;
2679                cfg.plugin_trust.enforcement_is_off()
2680            };
2681            self.persist_config_snapshot(new_config.clone())
2682                .await
2683                .map_err(|e| {
2684                    AppError::InternalError(anyhow::anyhow!("Failed to save config: {e}"))
2685                })?;
2686            let published = self
2687                .config_facade
2688                .as_ref()
2689                .map(|facade| load_facade_effective_config(facade, &self.app_data_dir))
2690                .unwrap_or(new_config);
2691            let enforcement_newly_off = !was_off && published.plugin_trust.enforcement_is_off();
2692            published.publish_env_vars();
2693            *self.config.write().await = published.clone();
2694            // Same live signal as `update_config` — a full-config replace (JSON
2695            // merge / PATCH endpoints) that transitions plugin_trust.enforcement
2696            // into `Off` must warn just as loudly as a targeted set.
2697            if enforcement_newly_off {
2698                warn_plugin_trust_enforcement_off();
2699            }
2700            published
2701        };
2702
2703        self.apply_config_effects(new_config.clone(), effects)
2704            .await?;
2705        Ok(new_config)
2706    }
2707
2708    async fn apply_config_effects(
2709        &self,
2710        new_config: Config,
2711        effects: ConfigUpdateEffects,
2712    ) -> Result<(), AppError> {
2713        if effects.reload_provider {
2714            self.reload_provider().await.map_err(|e| {
2715                AppError::InternalError(anyhow::anyhow!(
2716                    "Failed to reload provider after updating config: {e}"
2717                ))
2718            })?;
2719        }
2720
2721        if effects.reconcile_mcp {
2722            self.mcp_manager
2723                .reconcile_from_config(&new_config.mcp)
2724                .await;
2725        }
2726
2727        Ok(())
2728    }
2729
2730    /// Resolve a pending config-corruption recovery (#153; see
2731    /// [`bamboo_config::ConfigRecoveryStatus`]).
2732    ///
2733    /// - `accept = true`: confirms the recovery and persists it to
2734    ///   `config.json` in the same step ([`Config::confirm_recovery_and_save_to_dir`]),
2735    ///   then clears the pending flag — the config is no longer "pending
2736    ///   confirmation" once this returns `Ok`.
2737    /// - `accept = false`: a no-op that leaves everything untouched — disk,
2738    ///   in-memory config, and the pending flag are all left exactly as they
2739    ///   were. `config.json` stays refused-to-write (see `save_to_dir`) until
2740    ///   either a later `accept = true` call or the user hand-fixes
2741    ///   `config.json` and the process reloads/restarts.
2742    ///
2743    /// Errors with [`AppError::BadRequest`] if there's no pending recovery to
2744    /// resolve.
2745    pub async fn confirm_config_recovery(&self, accept: bool) -> Result<Config, AppError> {
2746        let _io = self.config_io_lock.lock().await;
2747
2748        if !accept {
2749            let cfg = self.config.read().await;
2750            return match cfg.recovery_status() {
2751                Some(_) => Ok(cfg.clone()),
2752                None => Err(AppError::BadRequest(
2753                    "No pending config-corruption recovery to resolve".to_string(),
2754                )),
2755            };
2756        }
2757
2758        let mut candidate = {
2759            let cfg = self.config.read().await;
2760            match cfg.recovery_status() {
2761                Some(_) => cfg.clone(),
2762                None => {
2763                    return Err(AppError::BadRequest(
2764                        "No pending config-corruption recovery to resolve".to_string(),
2765                    ))
2766                }
2767            }
2768        };
2769
2770        let data_dir = self.app_data_dir.clone();
2771        candidate = tokio::task::spawn_blocking(move || {
2772            candidate
2773                .confirm_recovery_and_save_to_dir(data_dir)
2774                .map(|_| candidate)
2775        })
2776        .await
2777        .map_err(|e| {
2778            AppError::InternalError(anyhow::anyhow!("Config recovery-confirm task failed: {e}"))
2779        })?
2780        .map_err(|e| {
2781            AppError::InternalError(anyhow::anyhow!("Failed to save recovered config: {e}"))
2782        })?;
2783
2784        {
2785            let mut cfg = self.config.write().await;
2786            *cfg = candidate.clone();
2787            cfg.publish_env_vars();
2788        }
2789
2790        Ok(candidate)
2791    }
2792}
2793
2794/// Short-circuit config-mutating entrypoints while a config-corruption
2795/// recovery is pending confirmation (#153): `save_to_dir` would refuse the
2796/// disk write anyway, but rejecting here — before any in-memory mutation
2797/// runs — keeps the in-memory config frozen at exactly the recovered state
2798/// instead of drifting further out of sync with what's actually on disk.
2799/// Resolve the pending recovery via `AppState::confirm_config_recovery`
2800/// first.
2801fn reject_if_recovery_pending(cfg: &Config) -> Result<(), AppError> {
2802    if let Some(status) = cfg.recovery_status() {
2803        if !status.confirmed {
2804            return Err(AppError::ConfigRecoveryPending(format!(
2805                "config.json was recovered from corruption ({:?}) and is awaiting \
2806                 confirmation; confirm or reject the recovery (see /bamboo/config/recovery-status \
2807                 and /bamboo/config/recovery/confirm) before changing settings",
2808                status.source
2809            )));
2810        }
2811    }
2812    Ok(())
2813}
2814
2815/// The prominent warning emitted whenever `plugin_trust.enforcement` is (or
2816/// becomes) `Off` — that setting silently downgrades EVERY subsequent `url`
2817/// plugin install/update to skip the host allowlist, signature, and
2818/// checksum-required-by-default layers, with no per-install flag needed (see
2819/// `crate::plugin_source`'s module docs). Factored into one function so the
2820/// boot-time signal (`AppState::new`) and the live-apply signal
2821/// (`update_config`/`replace_config`, covering the HTTP `config set` / PATCH
2822/// paths) emit the EXACT same message — no drift, and no trigger can flip
2823/// enforcement off silently.
2824pub(crate) fn warn_plugin_trust_enforcement_off() {
2825    tracing::warn!(
2826        "plugin_trust.enforcement is OFF — plugin installs from ANY URL are accepted \
2827         without host/signature/checksum verification (config.json plugin_trust.enforcement)"
2828    );
2829}
2830
2831#[cfg(test)]
2832mod live_reload_tests {
2833    use super::*;
2834    use bamboo_agent_core::{Message, ToolSchema};
2835    use bamboo_llm::{LLMError, LLMStream};
2836    use bamboo_mcp::{McpServerConfig, ReconnectConfig, StdioConfig};
2837
2838    struct WorkingProvider;
2839
2840    fn disabled_mcp_config(id: &str) -> McpConfig {
2841        McpConfig {
2842            version: 1,
2843            servers: vec![McpServerConfig {
2844                id: id.to_string(),
2845                name: None,
2846                enabled: false,
2847                transport: TransportConfig::Stdio(StdioConfig {
2848                    command: "unused-disabled-command".to_string(),
2849                    args: vec![],
2850                    cwd: None,
2851                    env: std::collections::HashMap::new(),
2852                    env_encrypted: std::collections::HashMap::new(),
2853                    env_credential_refs: std::collections::HashMap::new(),
2854                    startup_timeout_ms: 100,
2855                }),
2856                request_timeout_ms: 100,
2857                healthcheck_interval_ms: 100,
2858                reconnect: ReconnectConfig::default(),
2859                allowed_tools: vec![],
2860                denied_tools: vec![],
2861            }],
2862        }
2863    }
2864
2865    fn mcp_document_bytes(revision: u64, config: &McpConfig) -> Vec<u8> {
2866        serde_json::to_vec_pretty(&serde_json::json!({
2867            "schema_version": 1,
2868            "revision": revision,
2869            "data": config,
2870        }))
2871        .unwrap()
2872    }
2873
2874    fn install_unrecoverable_pending_provider_migration(dir: &Path) {
2875        let transaction_id = uuid::Uuid::new_v4().to_string();
2876        std::fs::write(
2877            dir.join("config.json"),
2878            br#"{"providers":{"openai":{"model":"root-lkg"}}}"#,
2879        )
2880        .unwrap();
2881        std::fs::write(
2882            dir.join("providers.json"),
2883            br#"{"schema_version":1,"revision":2,"data":{"openai":{"model":"partial-must-not-load","credential_ref":"provider.openai.api_key"}}}"#,
2884        )
2885        .unwrap();
2886        std::fs::write(
2887            dir.join("config-credential-migration.json"),
2888            serde_json::to_vec_pretty(&serde_json::json!({
2889                "version": 1,
2890                "transaction_id": transaction_id.clone(),
2891                "stage_dir": format!(".config-credential-stage-v1-{transaction_id}"),
2892                "state": "pending",
2893                "files": [
2894                    {
2895                        "name": "credentials.json",
2896                        "staged_name": "credentials.json",
2897                        "sha256": "0".repeat(64),
2898                        "sensitive": true
2899                    },
2900                    {
2901                        "name": "providers.json",
2902                        "staged_name": "providers.json",
2903                        "sha256": "1".repeat(64),
2904                        "original_sha256": "2".repeat(64),
2905                        "migration_generation": 2,
2906                        "sensitive": false
2907                    }
2908                ]
2909            }))
2910            .unwrap(),
2911        )
2912        .unwrap();
2913    }
2914
2915    async fn wait_for_mcp_health(
2916        state: &AppState,
2917        status: SectionStatus,
2918        minimum_revision: u64,
2919    ) -> ConfigLiveHealth {
2920        match tokio::time::timeout(Duration::from_secs(4), async {
2921            loop {
2922                let health = state
2923                    .mcp_config_live_health
2924                    .read()
2925                    .unwrap_or_else(|poisoned| poisoned.into_inner())
2926                    .clone();
2927                if health.status == status && health.revision >= minimum_revision {
2928                    break health;
2929                }
2930                tokio::time::sleep(Duration::from_millis(20)).await;
2931            }
2932        })
2933        .await
2934        {
2935            Ok(health) => health,
2936            Err(_) => panic!(
2937                "MCP health transition timed out: {:?}",
2938                state
2939                    .mcp_config_live_health
2940                    .read()
2941                    .unwrap_or_else(|poisoned| poisoned.into_inner())
2942                    .clone()
2943            ),
2944        }
2945    }
2946
2947    async fn next_config_event(
2948        feed: &mut tokio::sync::broadcast::Receiver<Arc<bamboo_engine::events::ChangeEvent>>,
2949        expected_section: &str,
2950    ) -> AgentEvent {
2951        tokio::time::timeout(Duration::from_secs(3), async {
2952            loop {
2953                let envelope = feed.recv().await.expect("account feed remains open");
2954                match &envelope.event {
2955                    AgentEvent::ConfigChanged { section, .. }
2956                    | AgentEvent::ConfigInvalid { section, .. }
2957                    | AgentEvent::ConfigRecovered { section, .. }
2958                        if section == expected_section =>
2959                    {
2960                        break envelope.event.clone();
2961                    }
2962                    _ => {}
2963                }
2964            }
2965        })
2966        .await
2967        .expect("config event timed out")
2968    }
2969
2970    async fn next_mcp_config_event(
2971        feed: &mut tokio::sync::broadcast::Receiver<Arc<bamboo_engine::events::ChangeEvent>>,
2972    ) -> AgentEvent {
2973        next_config_event(feed, "mcp").await
2974    }
2975
2976    async fn wait_for_facade_health(
2977        state: &AppState,
2978        id: SectionId,
2979        status: SectionStatus,
2980        revision: u64,
2981    ) -> bamboo_config::SectionHealth {
2982        tokio::time::timeout(Duration::from_secs(4), async {
2983            loop {
2984                let health = state
2985                    .config_facade
2986                    .as_ref()
2987                    .expect("production state owns a facade")
2988                    .registry()
2989                    .health()
2990                    .unwrap()
2991                    .into_iter()
2992                    .find(|health| health.section == id)
2993                    .unwrap();
2994                if health.status == status && health.revision == revision {
2995                    break health;
2996                }
2997                tokio::time::sleep(Duration::from_millis(20)).await;
2998            }
2999        })
3000        .await
3001        .expect("facade health transition timed out")
3002    }
3003
3004    #[test]
3005    fn initial_provider_health_validates_primary_and_backup() {
3006        let dir = tempfile::tempdir().unwrap();
3007        let store = AtomicJsonStore::new(dir.path().join("providers.json"), 1);
3008        let missing = initial_provider_health(&store);
3009        assert_eq!(missing.status, SectionStatus::Missing);
3010        assert_eq!(missing.source_kind, SectionSourceKind::Default);
3011
3012        std::fs::write(dir.path().join("providers.json"), b"{broken").unwrap();
3013        let invalid = initial_provider_health(&store);
3014        assert_eq!(invalid.status, SectionStatus::Invalid);
3015        assert_eq!(invalid.source_kind, SectionSourceKind::File);
3016
3017        std::fs::write(dir.path().join("providers.json.bak"), b"{}").unwrap();
3018        let recovered = initial_provider_health(&store);
3019        assert_eq!(recovered.status, SectionStatus::Degraded);
3020        assert_eq!(recovered.source_kind, SectionSourceKind::Backup);
3021        assert!(recovered
3022            .last_error
3023            .as_deref()
3024            .unwrap()
3025            .contains("last-known-good backup"));
3026
3027        std::fs::write(dir.path().join("providers.json"), b"{}").unwrap();
3028        let healthy = initial_provider_health(&store);
3029        assert_eq!(healthy.status, SectionStatus::Healthy);
3030        assert_eq!(healthy.source_kind, SectionSourceKind::File);
3031    }
3032
3033    #[tokio::test]
3034    async fn unrecoverable_pending_manifest_never_publishes_partial_provider_state() {
3035        let _key = bamboo_config::encryption::set_test_encryption_key([0x6c; 32]);
3036        let dir = tempfile::tempdir().unwrap();
3037        install_unrecoverable_pending_provider_migration(dir.path());
3038
3039        let loaded = Config::from_data_dir_without_env(Some(dir.path().to_path_buf()));
3040        assert_eq!(
3041            loaded.providers().openai.as_ref().unwrap().model.as_deref(),
3042            Some("root-lkg")
3043        );
3044        let store = AtomicJsonStore::new(dir.path().join("providers.json"), 1);
3045        let health = initial_provider_health(&store);
3046        assert_eq!(health.status, SectionStatus::Degraded);
3047        assert!(health
3048            .last_error
3049            .as_deref()
3050            .unwrap()
3051            .contains("migration is pending"));
3052        let error = match load_and_prepare_provider_candidate(&store, 0, loaded).await {
3053            Ok(_) => panic!("pending migration must reject provider candidate"),
3054            Err(error) => error,
3055        };
3056        assert!(error.message.contains("retaining last-known-good runtime"));
3057        assert!(!error.message.contains("partial-must-not-load"));
3058
3059        let state = AppState::new(dir.path().to_path_buf()).await.unwrap();
3060        assert_eq!(
3061            state
3062                .config
3063                .read()
3064                .await
3065                .providers()
3066                .openai
3067                .as_ref()
3068                .unwrap()
3069                .model
3070                .as_deref(),
3071            Some("root-lkg")
3072        );
3073        assert_eq!(
3074            state
3075                .config_live_health
3076                .read()
3077                .unwrap_or_else(|poisoned| poisoned.into_inner())
3078                .status,
3079            SectionStatus::Degraded
3080        );
3081    }
3082
3083    #[async_trait::async_trait]
3084    impl LLMProvider for WorkingProvider {
3085        async fn chat_stream(
3086            &self,
3087            _messages: &[Message],
3088            _tools: &[ToolSchema],
3089            _max_output_tokens: Option<u32>,
3090            _model: &str,
3091        ) -> Result<LLMStream, LLMError> {
3092            Err(LLMError::Api("working-provider-marker".to_string()))
3093        }
3094    }
3095
3096    #[tokio::test]
3097    async fn cancelled_provider_put_cannot_commit_before_publication_guards() {
3098        let _key = bamboo_config::encryption::set_test_encryption_key([0x53; 32]);
3099        let dir = tempfile::tempdir().unwrap();
3100        let state = AppState::new(dir.path().to_path_buf()).await.unwrap();
3101        let secret = "provider-cancel-secret";
3102        let reference = bamboo_config::credential_ref("provider", "openai", "api_key").unwrap();
3103        bamboo_config::CredentialStore::open(dir.path())
3104            .replace(
3105                reference.clone(),
3106                secret,
3107                bamboo_config::CredentialSource::User,
3108                0,
3109            )
3110            .unwrap();
3111        {
3112            let mut config = state.config.write().await;
3113            config.provider = "openai".to_string();
3114            *config.providers_mut() = ProviderConfigs {
3115                openai: Some(bamboo_config::OpenAIConfig {
3116                    api_key: secret.to_string(),
3117                    credential_ref: Some(reference),
3118                    ..Default::default()
3119                }),
3120                ..Default::default()
3121            };
3122        }
3123        let provider_lock = state.provider.clone();
3124        let held_provider = provider_lock.write().await;
3125        let providers_before = std::fs::read(dir.path().join("providers.json")).unwrap();
3126        let mut operation = Box::pin(state.put_provider_section(
3127            0,
3128            ProviderConfigs {
3129                openai: Some(bamboo_config::OpenAIConfig {
3130                    model: Some("candidate-model".to_string()),
3131                    ..Default::default()
3132                }),
3133                ..Default::default()
3134            },
3135        ));
3136
3137        assert!(
3138            tokio::time::timeout(Duration::from_millis(500), &mut operation)
3139                .await
3140                .is_err()
3141        );
3142        drop(operation);
3143        assert_eq!(
3144            std::fs::read(dir.path().join("providers.json")).unwrap(),
3145            providers_before,
3146            "cancellation while waiting for publication guards must precede durable commit"
3147        );
3148        drop(held_provider);
3149    }
3150
3151    #[test]
3152    fn cancelled_proxy_update_cannot_leave_durable_state_ahead_of_live_snapshot() {
3153        let runtime = tokio::runtime::Builder::new_multi_thread()
3154            .worker_threads(2)
3155            .max_blocking_threads(1)
3156            .enable_all()
3157            .build()
3158            .unwrap();
3159        runtime.block_on(async {
3160            let dir = tempfile::tempdir().unwrap();
3161            let state = Arc::new(AppState::new(dir.path().to_path_buf()).await.unwrap());
3162            wait_for_mcp_health(&state, SectionStatus::Healthy, 0).await;
3163
3164            // Occupy Tokio's only blocking worker. The pre-fix implementation
3165            // queued the durable transaction with `spawn_blocking`, so aborting
3166            // the request detached that queued commit from live publication.
3167            let (started_tx, started_rx) = tokio::sync::oneshot::channel();
3168            let (release_tx, release_rx) = std::sync::mpsc::sync_channel(0);
3169            let blocker = tokio::task::spawn_blocking(move || {
3170                let _ = started_tx.send(());
3171                release_rx.recv().unwrap();
3172            });
3173            started_rx.await.unwrap();
3174
3175            let operation_state = state.clone();
3176            let operation = tokio::spawn(async move {
3177                operation_state
3178                    .update_proxy_auth_credential(
3179                        Some(bamboo_config::ProxyAuth {
3180                            username: "cancel-user".to_string(),
3181                            password: "cancel-secret".to_string(),
3182                        }),
3183                        0,
3184                        ConfigUpdateEffects {
3185                            reload_provider: true,
3186                            reconcile_mcp: true,
3187                        },
3188                    )
3189                    .await
3190            });
3191
3192            // Wait until the owned mutation has acquired config_io_lock and
3193            // queued its blocking transaction. Aborting the caller from this
3194            // exact point reproduced the old detached-commit/live-publication
3195            // split deterministically.
3196            tokio::time::timeout(Duration::from_secs(1), async {
3197                loop {
3198                    if state.config_io_lock.try_lock().is_err() {
3199                        break;
3200                    }
3201                    assert!(!operation.is_finished());
3202                    tokio::task::yield_now().await;
3203                }
3204            })
3205            .await
3206            .expect("proxy mutation acquires the config IO lock");
3207            operation.abort();
3208            let _ = operation.await;
3209            release_tx.send(()).unwrap();
3210            blocker.await.unwrap();
3211
3212            tokio::time::timeout(Duration::from_secs(5), async {
3213                loop {
3214                    let credentials_ready = std::fs::read(dir.path().join("credentials.json"))
3215                        .ok()
3216                        .and_then(|bytes| serde_json::from_slice::<serde_json::Value>(&bytes).ok())
3217                        .and_then(|value| value.get("revision").and_then(|value| value.as_u64()))
3218                        == Some(1);
3219                    let config_ready = std::fs::read(dir.path().join("core.json"))
3220                        .ok()
3221                        .and_then(|bytes| serde_json::from_slice::<serde_json::Value>(&bytes).ok())
3222                        .and_then(|value| {
3223                            value
3224                                .get("data")
3225                                .and_then(|value| value.get("proxy_auth_credential_ref"))
3226                                .and_then(|value| value.as_str())
3227                                .map(str::to_string)
3228                        })
3229                        .as_deref()
3230                        == Some("proxy.default.auth");
3231                    if credentials_ready && config_ready {
3232                        break;
3233                    }
3234                    tokio::task::yield_now().await;
3235                }
3236            })
3237            .await
3238            .expect("owned durable transaction completes after caller cancellation");
3239
3240            // The owner retains config_io_lock through best-effort provider/MCP
3241            // convergence. Acquiring it proves cancellation did not strand the
3242            // post-commit runtime task either.
3243            let converged =
3244                tokio::time::timeout(Duration::from_secs(5), state.config_io_lock.lock())
3245                    .await
3246                    .expect("owned runtime convergence completes after cancellation");
3247            drop(converged);
3248
3249            let live = state.config.read().await;
3250            assert_eq!(
3251                live.proxy_auth_credential_ref
3252                    .as_ref()
3253                    .map(|reference| reference.as_str()),
3254                Some("proxy.default.auth")
3255            );
3256            let auth = live
3257                .proxy_auth
3258                .as_ref()
3259                .expect("durable proxy auth must be published despite cancellation");
3260            assert_eq!(auth.username, "cancel-user");
3261            assert_eq!(auth.password, "cancel-secret");
3262            drop(live);
3263
3264            let root = std::fs::read_to_string(dir.path().join("core.json")).unwrap();
3265            let credentials = std::fs::read_to_string(dir.path().join("credentials.json")).unwrap();
3266            assert!(!root.contains("cancel-secret"));
3267            assert!(!credentials.contains("cancel-secret"));
3268        });
3269    }
3270
3271    #[tokio::test]
3272    async fn missing_or_corrupt_referenced_credentials_reject_candidates_redacted() {
3273        let _key = bamboo_config::encryption::set_test_encryption_key([0x68; 32]);
3274        for corrupt_credentials in [false, true] {
3275            let dir = tempfile::tempdir().unwrap();
3276            let reference = bamboo_config::credential_ref("provider", "openai", "api_key").unwrap();
3277            let providers = ProviderConfigs {
3278                openai: Some(bamboo_config::OpenAIConfig {
3279                    credential_ref: Some(reference),
3280                    model: Some("candidate".to_string()),
3281                    ..Default::default()
3282                }),
3283                ..Default::default()
3284            };
3285            let provider_store = AtomicJsonStore::new(dir.path().join("providers.json"), 1);
3286            provider_store
3287                .commit(0, providers, validate_provider_config)
3288                .unwrap();
3289            if corrupt_credentials {
3290                std::fs::write(dir.path().join("credentials.json"), b"{corrupt-secret").unwrap();
3291            }
3292            let error =
3293                match load_and_prepare_provider_candidate(&provider_store, 0, Config::default())
3294                    .await
3295                {
3296                    Ok(_) => panic!("unavailable credential must reject provider candidate"),
3297                    Err(error) => error,
3298                };
3299            assert_eq!(error.message, "provider credential is unavailable");
3300            assert!(!error
3301                .message
3302                .contains(dir.path().to_string_lossy().as_ref()));
3303
3304            let mut mcp = disabled_mcp_config("credential-lkg");
3305            let TransportConfig::Stdio(stdio) = &mut mcp.servers[0].transport else {
3306                unreachable!()
3307            };
3308            stdio.env_credential_refs.insert(
3309                "TOKEN".to_string(),
3310                bamboo_config::credential_ref("mcp", "credential-lkg", "env_TOKEN")
3311                    .unwrap()
3312                    .as_str()
3313                    .to_string(),
3314            );
3315            let mcp_store = AtomicJsonStore::new(dir.path().join("mcp.json"), 1);
3316            mcp_store.commit(0, mcp, validate_mcp_config).unwrap();
3317            let error = match load_and_validate_mcp_candidate(
3318                &mcp_store,
3319                0,
3320                Config::default(),
3321                false,
3322            )
3323            .await
3324            {
3325                Ok(_) => panic!("unavailable credential must reject MCP candidate"),
3326                Err(error) => error,
3327            };
3328            assert_eq!(error.message, "MCP credential is unavailable");
3329            assert!(!error.message.contains("TOKEN"));
3330            assert!(!error
3331                .message
3332                .contains(dir.path().to_string_lossy().as_ref()));
3333        }
3334    }
3335
3336    #[tokio::test]
3337    async fn typed_provider_put_switches_refs_and_rejects_missing_ref_without_mutation() {
3338        let _key = bamboo_config::encryption::set_test_encryption_key([0x6d; 32]);
3339        let dir = tempfile::tempdir().unwrap();
3340        let state = AppState::new(dir.path().to_path_buf()).await.unwrap();
3341        let ref_a = bamboo_config::credential_ref("provider", "openai-a", "api_key").unwrap();
3342        let ref_b = bamboo_config::credential_ref("provider", "openai-b", "api_key").unwrap();
3343        let missing = bamboo_config::credential_ref("provider", "missing", "api_key").unwrap();
3344        state
3345            .credential_store
3346            .replace(
3347                ref_a.clone(),
3348                "provider-secret-a",
3349                bamboo_config::CredentialSource::User,
3350                0,
3351            )
3352            .unwrap();
3353        state
3354            .credential_store
3355            .replace(
3356                ref_b.clone(),
3357                "provider-secret-b",
3358                bamboo_config::CredentialSource::User,
3359                1,
3360            )
3361            .unwrap();
3362        {
3363            let mut config = state.config.write().await;
3364            config.provider = "openai".to_string();
3365            *config.providers_mut() = ProviderConfigs {
3366                openai: Some(bamboo_config::OpenAIConfig {
3367                    api_key: "provider-secret-a".to_string(),
3368                    credential_ref: Some(ref_a),
3369                    ..Default::default()
3370                }),
3371                ..Default::default()
3372            };
3373        }
3374
3375        let revision = state
3376            .put_provider_section(
3377                0,
3378                ProviderConfigs {
3379                    openai: Some(bamboo_config::OpenAIConfig {
3380                        credential_ref: Some(ref_b.clone()),
3381                        model: Some("switched".to_string()),
3382                        ..Default::default()
3383                    }),
3384                    ..Default::default()
3385                },
3386            )
3387            .await
3388            .unwrap();
3389        assert_eq!(revision, 1);
3390        let runtime = state.config.read().await;
3391        let openai = runtime.providers().openai.as_ref().unwrap();
3392        assert_eq!(openai.credential_ref.as_ref(), Some(&ref_b));
3393        assert_eq!(openai.api_key, "provider-secret-b");
3394        drop(runtime);
3395        let disk_before = std::fs::read(dir.path().join("providers.json")).unwrap();
3396
3397        assert!(state
3398            .put_provider_section(
3399                1,
3400                ProviderConfigs {
3401                    openai: Some(bamboo_config::OpenAIConfig {
3402                        credential_ref: Some(missing),
3403                        model: Some("must-not-publish".to_string()),
3404                        ..Default::default()
3405                    }),
3406                    ..Default::default()
3407                },
3408            )
3409            .await
3410            .is_err());
3411        assert_eq!(
3412            std::fs::read(dir.path().join("providers.json")).unwrap(),
3413            disk_before
3414        );
3415        let runtime = state.config.read().await;
3416        let openai = runtime.providers().openai.as_ref().unwrap();
3417        assert_eq!(openai.credential_ref.as_ref(), Some(&ref_b));
3418        assert_eq!(openai.api_key, "provider-secret-b");
3419    }
3420
3421    #[tokio::test]
3422    async fn typed_mcp_put_switches_stdio_and_header_refs_atomically() {
3423        let _key = bamboo_config::encryption::set_test_encryption_key([0x6e; 32]);
3424        let dir = tempfile::tempdir().unwrap();
3425        let state = AppState::new(dir.path().to_path_buf()).await.unwrap();
3426        let refs = [
3427            bamboo_config::credential_ref("mcp", "stdio-a", "env_TOKEN").unwrap(),
3428            bamboo_config::credential_ref("mcp", "header-a", "header_Authorization").unwrap(),
3429            bamboo_config::credential_ref("mcp", "stdio-b", "env_TOKEN").unwrap(),
3430            bamboo_config::credential_ref("mcp", "header-b", "header_Authorization").unwrap(),
3431        ];
3432        for (revision, (reference, value)) in refs
3433            .iter()
3434            .zip(["env-a", "header-a", "env-b", "header-b"])
3435            .enumerate()
3436        {
3437            state
3438                .credential_store
3439                .replace(
3440                    reference.clone(),
3441                    value,
3442                    bamboo_config::CredentialSource::User,
3443                    revision as u64,
3444                )
3445                .unwrap();
3446        }
3447        let make_config = |env_ref: &bamboo_config::CredentialRef,
3448                           header_ref: &bamboo_config::CredentialRef| {
3449            McpConfig {
3450                version: 1,
3451                servers: vec![
3452                    McpServerConfig {
3453                        id: "switch-stdio".to_string(),
3454                        name: None,
3455                        enabled: false,
3456                        transport: TransportConfig::Stdio(StdioConfig {
3457                            command: "unused-disabled-command".to_string(),
3458                            args: vec![],
3459                            cwd: None,
3460                            env: std::collections::HashMap::new(),
3461                            env_encrypted: std::collections::HashMap::new(),
3462                            env_credential_refs: std::collections::HashMap::from([(
3463                                "TOKEN".to_string(),
3464                                env_ref.as_str().to_string(),
3465                            )]),
3466                            startup_timeout_ms: 100,
3467                        }),
3468                        request_timeout_ms: 100,
3469                        healthcheck_interval_ms: 100,
3470                        reconnect: ReconnectConfig::default(),
3471                        allowed_tools: vec![],
3472                        denied_tools: vec![],
3473                    },
3474                    McpServerConfig {
3475                        id: "switch-header".to_string(),
3476                        name: None,
3477                        enabled: false,
3478                        transport: TransportConfig::Sse(bamboo_mcp::SseConfig {
3479                            url: "https://example.test/sse".to_string(),
3480                            headers: vec![bamboo_mcp::HeaderConfig {
3481                                name: "Authorization".to_string(),
3482                                value: String::new(),
3483                                value_encrypted: None,
3484                                credential_ref: Some(header_ref.as_str().to_string()),
3485                            }],
3486                            connect_timeout_ms: 100,
3487                        }),
3488                        request_timeout_ms: 100,
3489                        healthcheck_interval_ms: 100,
3490                        reconnect: ReconnectConfig::default(),
3491                        allowed_tools: vec![],
3492                        denied_tools: vec![],
3493                    },
3494                ],
3495            }
3496        };
3497        let mut current = make_config(&refs[0], &refs[1]);
3498        if let TransportConfig::Stdio(stdio) = &mut current.servers[0].transport {
3499            stdio.env.insert("TOKEN".to_string(), "env-a".to_string());
3500        }
3501        if let TransportConfig::Sse(sse) = &mut current.servers[1].transport {
3502            sse.headers[0].value = "header-a".to_string();
3503        }
3504        state.config.write().await.mcp = current;
3505
3506        assert_eq!(
3507            state
3508                .put_mcp_section(0, make_config(&refs[2], &refs[3]))
3509                .await
3510                .unwrap(),
3511            1
3512        );
3513        let runtime = state.config.read().await;
3514        let TransportConfig::Stdio(stdio) = &runtime.mcp.servers[0].transport else {
3515            panic!("stdio transport")
3516        };
3517        assert_eq!(stdio.env["TOKEN"], "env-b");
3518        let TransportConfig::Sse(sse) = &runtime.mcp.servers[1].transport else {
3519            panic!("sse transport")
3520        };
3521        assert_eq!(sse.headers[0].value, "header-b");
3522        drop(runtime);
3523        let disk_before = std::fs::read(dir.path().join("mcp.json")).unwrap();
3524        let missing_env = bamboo_config::credential_ref("mcp", "missing", "env_TOKEN").unwrap();
3525        let missing_header =
3526            bamboo_config::credential_ref("mcp", "missing", "header_Authorization").unwrap();
3527        assert!(state
3528            .put_mcp_section(1, make_config(&missing_env, &missing_header))
3529            .await
3530            .is_err());
3531        assert_eq!(
3532            std::fs::read(dir.path().join("mcp.json")).unwrap(),
3533            disk_before
3534        );
3535        let runtime = state.config.read().await;
3536        let TransportConfig::Stdio(stdio) = &runtime.mcp.servers[0].transport else {
3537            panic!("stdio transport")
3538        };
3539        assert_eq!(stdio.env["TOKEN"], "env-b");
3540    }
3541
3542    #[tokio::test]
3543    async fn failed_candidate_keeps_existing_provider_registry_and_runtime() {
3544        let dir = tempfile::tempdir().unwrap();
3545        let state = AppState::new(dir.path().to_path_buf()).await.unwrap();
3546        let working: Arc<dyn LLMProvider> = Arc::new(WorkingProvider);
3547        state
3548            .provider_registry
3549            .insert("working".to_string(), working.clone());
3550        state.provider_registry.set_default("working".to_string());
3551        *state.provider.write().await = working.clone();
3552        state.config.write().await.provider = "openai".to_string();
3553
3554        assert!(state.reload_provider().await.is_err());
3555        assert_eq!(state.provider_registry.default_provider_name(), "working");
3556        assert!(Arc::ptr_eq(
3557            &state.provider_registry.get_default().unwrap(),
3558            &working
3559        ));
3560        let live = state.provider.read().await;
3561        assert!(Arc::ptr_eq(&*live, &working));
3562    }
3563
3564    #[tokio::test]
3565    async fn provider_watcher_retains_lkg_on_invalid_and_recovers_after_repair() {
3566        let _key = bamboo_config::encryption::set_test_encryption_key([0x43; 32]);
3567        let dir = tempfile::tempdir().unwrap();
3568        let state = AppState::new(dir.path().to_path_buf()).await.unwrap();
3569        let working: Arc<dyn LLMProvider> = Arc::new(WorkingProvider);
3570        state
3571            .provider_registry
3572            .insert("working".to_string(), working.clone());
3573        state.provider_registry.set_default("working".to_string());
3574        *state.provider.write().await = working.clone();
3575        state.config.write().await.provider = "openai".to_string();
3576        wait_for_mcp_health(&state, SectionStatus::Healthy, 0).await;
3577        let mut feed = state.account_sink.subscribe();
3578        let providers_path = dir.path().join("providers.json");
3579
3580        std::fs::write(&providers_path, b"{broken").unwrap();
3581        tokio::time::timeout(Duration::from_secs(3), async {
3582            loop {
3583                if state
3584                    .config_live_health
3585                    .read()
3586                    .unwrap_or_else(|poisoned| poisoned.into_inner())
3587                    .status
3588                    == SectionStatus::Invalid
3589                {
3590                    break;
3591                }
3592                tokio::time::sleep(Duration::from_millis(20)).await;
3593            }
3594        })
3595        .await
3596        .unwrap();
3597        assert_eq!(
3598            state
3599                .config_live_health
3600                .read()
3601                .unwrap_or_else(|poisoned| poisoned.into_inner())
3602                .revision,
3603            0,
3604            "invalid edits must not advance the LKG revision"
3605        );
3606        {
3607            let health = state
3608                .config_live_health
3609                .read()
3610                .unwrap_or_else(|poisoned| poisoned.into_inner());
3611            assert_eq!(health.status, SectionStatus::Invalid);
3612            assert_eq!(health.source_kind, SectionSourceKind::File);
3613            assert_eq!(health.source_path, providers_path);
3614        }
3615        assert!(Arc::ptr_eq(
3616            &state.provider_registry.get_default().unwrap(),
3617            &working
3618        ));
3619        let invalid = next_config_event(&mut feed, "providers").await;
3620        assert!(matches!(
3621            invalid,
3622            AgentEvent::ConfigInvalid { revision: 0, .. }
3623        ));
3624
3625        let reference = bamboo_config::credential_ref("provider", "openai", "api_key").unwrap();
3626        let credential_store = bamboo_config::CredentialStore::open(dir.path());
3627        let credential_revision = credential_store.revision().unwrap();
3628        credential_store
3629            .replace(
3630                reference.clone(),
3631                "watcher-test-key",
3632                bamboo_config::CredentialSource::User,
3633                credential_revision,
3634            )
3635            .unwrap();
3636        let providers = ProviderConfigs {
3637            openai: Some(bamboo_config::OpenAIConfig {
3638                credential_ref: Some(reference),
3639                ..Default::default()
3640            }),
3641            ..Default::default()
3642        };
3643        std::fs::write(
3644            &providers_path,
3645            serde_json::to_vec_pretty(&providers).unwrap(),
3646        )
3647        .unwrap();
3648        tokio::time::timeout(Duration::from_secs(3), async {
3649            loop {
3650                let health = state
3651                    .config_live_health
3652                    .read()
3653                    .unwrap_or_else(|poisoned| poisoned.into_inner())
3654                    .clone();
3655                if health.status == SectionStatus::Healthy && health.revision == 1 {
3656                    break;
3657                }
3658                tokio::time::sleep(Duration::from_millis(20)).await;
3659            }
3660        })
3661        .await
3662        .unwrap();
3663        let recovered = next_config_event(&mut feed, "providers").await;
3664        assert!(matches!(
3665            recovered,
3666            AgentEvent::ConfigRecovered { revision: 1, .. }
3667        ));
3668        assert_eq!(state.provider_registry.default_provider_name(), "openai");
3669    }
3670
3671    #[tokio::test]
3672    async fn ordinary_section_watcher_updates_runtime_retains_lkg_and_recovers() {
3673        let _key = bamboo_config::encryption::set_test_encryption_key([0x44; 32]);
3674        let dir = tempfile::tempdir().unwrap();
3675        let state = AppState::new(dir.path().to_path_buf()).await.unwrap();
3676        wait_for_mcp_health(&state, SectionStatus::Healthy, 0).await;
3677        let mut feed = state.account_sink.subscribe();
3678        let path = dir.path().join("core.json");
3679        let mut document: serde_json::Value =
3680            serde_json::from_slice(&std::fs::read(&path).unwrap()).unwrap();
3681        document["revision"] = serde_json::json!(2);
3682        document["data"]["server"]["port"] = serde_json::json!(9876);
3683        std::fs::write(&path, serde_json::to_vec_pretty(&document).unwrap()).unwrap();
3684
3685        wait_for_facade_health(&state, SectionId::Core, SectionStatus::Healthy, 2).await;
3686        tokio::time::timeout(Duration::from_secs(3), async {
3687            loop {
3688                if state.config.read().await.server.port == 9876 {
3689                    break;
3690                }
3691                tokio::time::sleep(Duration::from_millis(20)).await;
3692            }
3693        })
3694        .await
3695        .unwrap();
3696        assert!(matches!(
3697            next_config_event(&mut feed, "core").await,
3698            AgentEvent::ConfigChanged { revision: 2, .. }
3699        ));
3700
3701        std::fs::write(&path, b"{broken").unwrap();
3702        wait_for_facade_health(&state, SectionId::Core, SectionStatus::Invalid, 2).await;
3703        assert_eq!(state.config.read().await.server.port, 9876);
3704        assert!(matches!(
3705            next_config_event(&mut feed, "core").await,
3706            AgentEvent::ConfigInvalid { revision: 2, .. }
3707        ));
3708
3709        document["revision"] = serde_json::json!(3);
3710        document["data"]["server"]["port"] = serde_json::json!(9877);
3711        std::fs::write(&path, serde_json::to_vec_pretty(&document).unwrap()).unwrap();
3712        wait_for_facade_health(&state, SectionId::Core, SectionStatus::Healthy, 3).await;
3713        tokio::time::timeout(Duration::from_secs(3), async {
3714            loop {
3715                if state.config.read().await.server.port == 9877 {
3716                    break;
3717                }
3718                tokio::time::sleep(Duration::from_millis(20)).await;
3719            }
3720        })
3721        .await
3722        .unwrap();
3723        assert!(matches!(
3724            next_config_event(&mut feed, "core").await,
3725            AgentEvent::ConfigRecovered { revision: 3, .. }
3726        ));
3727
3728        std::fs::remove_file(&path).unwrap();
3729        wait_for_facade_health(&state, SectionId::Core, SectionStatus::Missing, 3).await;
3730        assert_eq!(state.config.read().await.server.port, 9877);
3731        assert!(matches!(
3732            next_config_event(&mut feed, "core").await,
3733            AgentEvent::ConfigInvalid { revision: 3, .. }
3734        ));
3735
3736        document["revision"] = serde_json::json!(4);
3737        document["data"]["server"]["port"] = serde_json::json!(9878);
3738        let swap = dir.path().join("core.json.swap");
3739        std::fs::write(&swap, serde_json::to_vec_pretty(&document).unwrap()).unwrap();
3740        std::fs::rename(&swap, &path).unwrap();
3741        wait_for_facade_health(&state, SectionId::Core, SectionStatus::Healthy, 4).await;
3742        tokio::time::timeout(Duration::from_secs(3), async {
3743            loop {
3744                if state.config.read().await.server.port == 9878 {
3745                    break;
3746                }
3747                tokio::time::sleep(Duration::from_millis(20)).await;
3748            }
3749        })
3750        .await
3751        .unwrap();
3752        assert!(matches!(
3753            next_config_event(&mut feed, "core").await,
3754            AgentEvent::ConfigRecovered { revision: 4, .. }
3755        ));
3756    }
3757
3758    #[tokio::test]
3759    async fn mcp_watcher_updates_lkg_rejects_invalid_and_recovers_after_atomic_replace() {
3760        let _key = bamboo_config::encryption::set_test_encryption_key([0x45; 32]);
3761        let dir = tempfile::tempdir().unwrap();
3762        let state = AppState::new(dir.path().to_path_buf()).await.unwrap();
3763        wait_for_mcp_health(&state, SectionStatus::Healthy, 0).await;
3764        let mut feed = state.account_sink.subscribe();
3765        let path = dir.path().join("mcp.json");
3766
3767        std::fs::write(&path, mcp_document_bytes(2, &disabled_mcp_config("first"))).unwrap();
3768        let first = wait_for_mcp_health(&state, SectionStatus::Healthy, 2).await;
3769        assert_eq!(first.revision, 2);
3770        assert_eq!(state.config.read().await.mcp.servers[0].id, "first");
3771        assert!(matches!(
3772            next_mcp_config_event(&mut feed).await,
3773            AgentEvent::ConfigChanged { revision: 2, .. }
3774        ));
3775
3776        std::fs::write(&path, b"{broken").unwrap();
3777        let invalid = wait_for_mcp_health(&state, SectionStatus::Invalid, 2).await;
3778        assert_eq!(invalid.revision, 2, "invalid candidates cannot advance LKG");
3779        assert_eq!(state.config.read().await.mcp.servers[0].id, "first");
3780        assert!(matches!(
3781            next_mcp_config_event(&mut feed).await,
3782            AgentEvent::ConfigInvalid { revision: 2, .. }
3783        ));
3784
3785        // Model an editor's temp-write + atomic rename, with an immediate
3786        // follow-up write in the same debounce burst. The watcher must settle
3787        // on the final complete document rather than treating the rename gap as
3788        // a reset.
3789        let swap = dir.path().join("mcp.json.swap");
3790        std::fs::write(
3791            &swap,
3792            mcp_document_bytes(3, &disabled_mcp_config("intermediate")),
3793        )
3794        .unwrap();
3795        std::fs::rename(&swap, &path).unwrap();
3796        std::fs::write(
3797            &path,
3798            mcp_document_bytes(3, &disabled_mcp_config("recovered")),
3799        )
3800        .unwrap();
3801        let recovered = wait_for_mcp_health(&state, SectionStatus::Healthy, 3).await;
3802        assert_eq!(recovered.revision, 3, "rename burst should coalesce once");
3803        assert_eq!(state.config.read().await.mcp.servers[0].id, "recovered");
3804        assert!(matches!(
3805            next_mcp_config_event(&mut feed).await,
3806            AgentEvent::ConfigRecovered { revision: 3, .. }
3807        ));
3808
3809        // Reusing the live revision with different content forces the shared
3810        // store to normalize it to revision 4. The normalization write itself
3811        // must be suppressed exactly once rather than triggering a duplicate
3812        // reconcile/event.
3813        std::fs::write(
3814            &path,
3815            mcp_document_bytes(3, &disabled_mcp_config("normalized")),
3816        )
3817        .unwrap();
3818        let normalized = wait_for_mcp_health(&state, SectionStatus::Healthy, 4).await;
3819        assert_eq!(normalized.revision, 4);
3820        assert_eq!(state.config.read().await.mcp.servers[0].id, "normalized");
3821        assert!(matches!(
3822            next_mcp_config_event(&mut feed).await,
3823            AgentEvent::ConfigChanged { revision: 4, .. }
3824        ));
3825        assert!(
3826            tokio::time::timeout(Duration::from_millis(500), feed.recv())
3827                .await
3828                .is_err()
3829        );
3830        let persisted: serde_json::Value =
3831            serde_json::from_slice(&std::fs::read(&path).unwrap()).unwrap();
3832        assert_eq!(persisted["revision"], 4);
3833    }
3834
3835    #[tokio::test]
3836    async fn mcp_sidecar_present_at_startup_is_applied_through_runtime_transaction() {
3837        let _key = bamboo_config::encryption::set_test_encryption_key([0x47; 32]);
3838        let dir = tempfile::tempdir().unwrap();
3839        std::fs::write(
3840            dir.path().join("mcp.json"),
3841            mcp_document_bytes(1, &disabled_mcp_config("startup-sidecar")),
3842        )
3843        .unwrap();
3844
3845        let state = AppState::new(dir.path().to_path_buf()).await.unwrap();
3846        let health = wait_for_mcp_health(&state, SectionStatus::Healthy, 1).await;
3847        assert_eq!(health.revision, 1);
3848        assert_eq!(health.source_kind, SectionSourceKind::File);
3849        assert_eq!(
3850            state.config.read().await.mcp.servers[0].id,
3851            "startup-sidecar"
3852        );
3853    }
3854
3855    #[tokio::test]
3856    async fn mcp_startup_uses_valid_backup_and_reports_degraded_invalid_health() {
3857        let _key = bamboo_config::encryption::set_test_encryption_key([0x48; 32]);
3858        let dir = tempfile::tempdir().unwrap();
3859        let path = dir.path().join("mcp.json");
3860        let store = AtomicJsonStore::new(&path, 1);
3861        store
3862            .commit(0, disabled_mcp_config("backup-lkg"), validate_mcp_config)
3863            .unwrap();
3864        store
3865            .commit(1, disabled_mcp_config("new-primary"), validate_mcp_config)
3866            .unwrap();
3867        std::fs::write(&path, b"{corrupt-primary").unwrap();
3868
3869        let state = AppState::new(dir.path().to_path_buf()).await.unwrap();
3870        let health = wait_for_mcp_health(&state, SectionStatus::Degraded, 1).await;
3871        assert_eq!(health.revision, 1);
3872        assert_eq!(health.source_kind, SectionSourceKind::Backup);
3873        assert_eq!(health.source_path, path.with_extension("json.bak"));
3874        assert!(health
3875            .last_error
3876            .as_deref()
3877            .unwrap()
3878            .contains("last-known-good backup runtime"));
3879        assert_eq!(state.config.read().await.mcp.servers[0].id, "backup-lkg");
3880
3881        tokio::time::timeout(Duration::from_secs(3), async {
3882            loop {
3883                if state.account_sink.latest_seq() > 0 {
3884                    break;
3885                }
3886                tokio::time::sleep(Duration::from_millis(20)).await;
3887            }
3888        })
3889        .await
3890        .unwrap();
3891        tokio::time::sleep(Duration::from_millis(500)).await;
3892        let events =
3893            bamboo_engine::events::journal::read_since(state.account_sink.events_dir(), 0).unwrap();
3894        assert_eq!(
3895            events
3896                .iter()
3897                .filter(|event| matches!(
3898                    &event.event,
3899                    AgentEvent::ConfigInvalid { section, revision }
3900                        if section == "mcp" && *revision == 1
3901                ))
3902                .count(),
3903            1
3904        );
3905        let stable_health = state
3906            .mcp_config_live_health
3907            .read()
3908            .unwrap_or_else(|poisoned| poisoned.into_inner())
3909            .clone();
3910        assert_eq!(stable_health.status, SectionStatus::Degraded);
3911        assert_eq!(stable_health.source_kind, SectionSourceKind::Backup);
3912        assert_eq!(stable_health.source_path, path.with_extension("json.bak"));
3913    }
3914
3915    #[tokio::test]
3916    async fn mcp_runtime_init_failure_marks_degraded_and_retains_lkg_config() {
3917        let _key = bamboo_config::encryption::set_test_encryption_key([0x46; 32]);
3918        let dir = tempfile::tempdir().unwrap();
3919        let state = AppState::new(dir.path().to_path_buf()).await.unwrap();
3920        wait_for_mcp_health(&state, SectionStatus::Healthy, 0).await;
3921        let mut feed = state.account_sink.subscribe();
3922        let path = dir.path().join("mcp.json");
3923
3924        std::fs::write(
3925            &path,
3926            mcp_document_bytes(1, &disabled_mcp_config("last-known-good")),
3927        )
3928        .unwrap();
3929        wait_for_mcp_health(&state, SectionStatus::Healthy, 1).await;
3930        let _ = next_mcp_config_event(&mut feed).await;
3931
3932        let mut failing = disabled_mcp_config("candidate");
3933        failing.servers[0].enabled = true;
3934        if let TransportConfig::Stdio(stdio) = &mut failing.servers[0].transport {
3935            stdio.command = "definitely-not-a-real-mcp-command-597".to_string();
3936        }
3937        std::fs::write(&path, mcp_document_bytes(2, &failing)).unwrap();
3938
3939        let degraded = wait_for_mcp_health(&state, SectionStatus::Degraded, 1).await;
3940        assert_eq!(degraded.revision, 1);
3941        assert!(degraded
3942            .last_error
3943            .as_deref()
3944            .unwrap()
3945            .contains("last-known-good runtime"));
3946        assert_eq!(
3947            state.config.read().await.mcp.servers[0].id,
3948            "last-known-good"
3949        );
3950        assert!(state.mcp_manager.list_servers().is_empty());
3951        assert!(matches!(
3952            next_mcp_config_event(&mut feed).await,
3953            AgentEvent::ConfigInvalid { revision: 1, .. }
3954        ));
3955    }
3956}