Skip to main content

bamboo_server/app_state/
config_runtime.rs

1use super::*;
2
3impl AppState {
4    /// Reload the provider based on current configuration
5    ///
6    /// Re-reads the configuration and creates a new LLM provider
7    /// instance, allowing runtime switching of providers or models.
8    ///
9    /// # Returns
10    ///
11    /// `Ok(())` if the provider was successfully reloaded.
12    ///
13    /// # Errors
14    ///
15    /// Returns an error if:
16    /// - Configuration cannot be read
17    /// - Provider initialization fails (e.g., invalid API key)
18    ///
19    /// # Example
20    ///
21    /// ```rust,no_run
22    /// use bamboo_server::app_state::AppState;
23    /// use std::path::PathBuf;
24    ///
25    /// #[tokio::main]
26    /// async fn main() {
27    ///     let state = AppState::new(PathBuf::from("/path/to/.bamboo"))
28    ///         .await
29    ///         .expect("failed to initialize app state");
30    ///
31    ///     // User updated config file...
32    ///     state.reload_provider().await.expect("Provider reload failed");
33    /// }
34    /// ```
35    pub async fn reload_provider(&self) -> Result<(), bamboo_llm::LLMError> {
36        let config = self.config.read().await.clone();
37
38        self.provider_registry
39            .reload_from_config(&config, self.app_data_dir.clone())
40            .await?;
41
42        let default_provider_name = self.provider_registry.default_provider_name();
43        tracing::info!(
44            default_provider = %default_provider_name,
45            legacy_provider = %config.provider,
46            has_provider_instances = config.has_provider_instances(),
47            "Reloading provider runtime from current config"
48        );
49
50        let new_provider = self.provider_registry.get_default().unwrap_or_else(|| {
51            let message = if config.has_provider_instances() {
52                format!(
53                    "Default provider instance '{}' is not available or failed to initialize",
54                    default_provider_name
55                )
56            } else {
57                format!(
58                    "Provider '{}' is not available or failed to initialize",
59                    config.provider
60                )
61            };
62            Arc::new(UnconfiguredProvider { message }) as Arc<dyn LLMProvider>
63        });
64
65        let mut provider = self.provider.write().await;
66        *provider = new_provider;
67
68        tracing::info!(
69            default_provider = %default_provider_name,
70            "Provider reloaded successfully"
71        );
72        Ok(())
73    }
74
75    /// Reload the configuration from file
76    ///
77    /// Reads the configuration file again and updates the in-memory
78    /// config. Note: This does NOT automatically reload the provider;
79    /// call `reload_provider()` afterwards if needed.
80    ///
81    /// # Returns
82    ///
83    /// The newly loaded configuration.
84    ///
85    /// # Example
86    ///
87    /// ```rust,no_run
88    /// use bamboo_server::app_state::AppState;
89    /// use std::path::PathBuf;
90    ///
91    /// #[tokio::main]
92    /// async fn main() {
93    ///     let state = AppState::new(PathBuf::from("/path/to/.bamboo"))
94    ///         .await
95    ///         .expect("failed to initialize app state");
96    ///
97    ///     // Reload config from disk
98    ///     let new_config = state.reload_config().await;
99    ///
100    ///     // Optionally reload provider with new config
101    ///     state.reload_provider().await.ok();
102    /// }
103    /// ```
104    pub async fn reload_config(&self) -> Config {
105        // Read from disk INSIDE the write lock. If the disk read happened before
106        // acquiring the lock, a concurrent update_config() could persist new
107        // state in that gap and then be clobbered here by the stale disk copy
108        // (in-memory-only mutations silently lost). Holding the lock across the
109        // read+swap serializes reload with update_config's in-memory mutation.
110        // Config::from_data_dir is a sync read (no await), so this doesn't hold
111        // the lock across an await point. #41.
112        // Hold the config-IO lock across the read+swap so it can't interleave
113        // with a config write's mutate+persist (which would let us read the disk
114        // BEFORE that write persisted, then clobber its in-memory mutation). #126.
115        let _io = self.config_io_lock.lock().await;
116        let mut config = self.config.write().await;
117        let new_config = Config::from_data_dir(Some(self.app_data_dir.clone()));
118        *config = new_config.clone();
119        new_config
120    }
121
122    async fn persist_config_snapshot(&self, config: Config) -> anyhow::Result<()> {
123        let data_dir = self.app_data_dir.clone();
124        tokio::task::spawn_blocking(move || config.save_to_dir(data_dir))
125            .await
126            .map_err(|e| anyhow::anyhow!("Config save task failed: {e}"))??;
127        Ok(())
128    }
129
130    /// Unified config update entrypoint.
131    ///
132    /// Invariants:
133    /// - Update in-memory first
134    /// - Persist to disk
135    /// - Apply runtime side-effects last (provider reload, MCP reconcile)
136    pub async fn update_config<F>(
137        &self,
138        update: F,
139        effects: ConfigUpdateEffects,
140    ) -> Result<Config, AppError>
141    where
142        F: FnOnce(&mut Config) -> Result<(), AppError>,
143    {
144        // Hold the config-IO lock across BOTH the in-memory mutation AND the disk
145        // persist, so a concurrent reload_config can't read the disk in the gap
146        // before we persist and then clobber this mutation with the stale copy
147        // (#126). The lock is dropped before apply_config_effects — slow side
148        // effects (provider reload) don't need to block reloads/other updates.
149        let snapshot = {
150            let _io = self.config_io_lock.lock().await;
151            let (snapshot, enforcement_newly_off) = {
152                let mut cfg = self.config.write().await;
153                // Refuse the whole operation (no in-memory mutation, no disk
154                // write) while a config-corruption recovery is pending
155                // confirmation (#153) — `save_to_dir` would reject the persist
156                // anyway, but checking here BEFORE `update()` runs keeps the
157                // in-memory config frozen exactly at the recovered state
158                // instead of silently drifting further from what's on disk.
159                reject_if_recovery_pending(&cfg)?;
160                let was_off = cfg.plugin_trust.enforcement_is_off();
161                update(&mut cfg)?;
162                // Backfill any missing connect.platforms id (#496) on the live
163                // in-memory config itself — not just inside `save_to_dir`'s
164                // internal save-copy — so the response this update returns
165                // (and any GET immediately after) already reflects the id a
166                // client can round-trip on its next PATCH.
167                cfg.assign_connect_platform_ids();
168                // Same live-vs-save-copy treatment for ciphertext (#516):
169                // `save_to_dir` refreshes `*_encrypted` only on its save-time
170                // clone, so a secret set through this entrypoint (e.g. a
171                // provider instance created over HTTP) would otherwise stay
172                // plaintext-only in memory — and the next settings-PATCH merge
173                // (`build_merged_config`'s serde round-trip drops plaintext)
174                // would lose the key entirely.
175                cfg.refresh_encrypted_secrets().map_err(|e| {
176                    AppError::InternalError(anyhow::anyhow!(
177                        "Failed to refresh encrypted secrets: {e}"
178                    ))
179                })?;
180                cfg.publish_env_vars();
181                let newly_off = !was_off && cfg.plugin_trust.enforcement_is_off();
182                (cfg.clone(), newly_off)
183            };
184            // Loud signal at the MOMENT plugin_trust.enforcement is flipped off
185            // live (e.g. via `bamboo config set plugin_trust.enforcement off`
186            // over HTTP), mirroring the boot-time warn in `AppState::new` — so
187            // this security-relevant relaxation is never applied silently. Only
188            // on the transition into `Off` (not every unrelated config write
189            // while already off).
190            if enforcement_newly_off {
191                warn_plugin_trust_enforcement_off();
192            }
193            self.persist_config_snapshot(snapshot.clone())
194                .await
195                .map_err(|e| {
196                    AppError::InternalError(anyhow::anyhow!("Failed to save config: {e}"))
197                })?;
198            snapshot
199        };
200
201        self.apply_config_effects(snapshot.clone(), effects).await?;
202        Ok(snapshot)
203    }
204
205    /// Replace the full config (used for JSON merge endpoints).
206    pub async fn replace_config(
207        &self,
208        mut new_config: Config,
209        effects: ConfigUpdateEffects,
210    ) -> Result<Config, AppError> {
211        // Backfill any missing connect.platforms id (#496) up front, before
212        // any of the clones below are taken, so the in-memory config, the
213        // disk-persisted snapshot, and the value this call returns to the
214        // caller (the settings-merge HTTP response) all agree on the same
215        // ids — mirrors the `update_config` treatment above.
216        new_config.assign_connect_platform_ids();
217        // Keep ciphertext in sync with plaintext on the config that becomes
218        // the live in-memory state — same #516 rationale as `update_config`.
219        new_config.refresh_encrypted_secrets().map_err(|e| {
220            AppError::InternalError(anyhow::anyhow!("Failed to refresh encrypted secrets: {e}"))
221        })?;
222
223        // Same #126 serialization as update_config: mutate + persist under the
224        // config-IO lock so a reload can't interleave; effects run unlocked.
225        {
226            let _io = self.config_io_lock.lock().await;
227            let enforcement_newly_off = {
228                let mut cfg = self.config.write().await;
229                // Same guard as `update_config` (#153): a full-config replace
230                // must not silently blow away an unconfirmed recovery either.
231                reject_if_recovery_pending(&cfg)?;
232                let was_off = cfg.plugin_trust.enforcement_is_off();
233                *cfg = new_config.clone();
234                cfg.publish_env_vars();
235                !was_off && cfg.plugin_trust.enforcement_is_off()
236            };
237            // Same live signal as `update_config` — a full-config replace (JSON
238            // merge / PATCH endpoints) that transitions plugin_trust.enforcement
239            // into `Off` must warn just as loudly as a targeted set.
240            if enforcement_newly_off {
241                warn_plugin_trust_enforcement_off();
242            }
243            self.persist_config_snapshot(new_config.clone())
244                .await
245                .map_err(|e| {
246                    AppError::InternalError(anyhow::anyhow!("Failed to save config: {e}"))
247                })?;
248        }
249
250        self.apply_config_effects(new_config.clone(), effects)
251            .await?;
252        Ok(new_config)
253    }
254
255    async fn apply_config_effects(
256        &self,
257        new_config: Config,
258        effects: ConfigUpdateEffects,
259    ) -> Result<(), AppError> {
260        if effects.reload_provider {
261            self.reload_provider().await.map_err(|e| {
262                AppError::InternalError(anyhow::anyhow!(
263                    "Failed to reload provider after updating config: {e}"
264                ))
265            })?;
266        }
267
268        if effects.reconcile_mcp {
269            self.mcp_manager
270                .reconcile_from_config(&new_config.mcp)
271                .await;
272        }
273
274        Ok(())
275    }
276
277    /// Resolve a pending config-corruption recovery (#153; see
278    /// [`bamboo_config::ConfigRecoveryStatus`]).
279    ///
280    /// - `accept = true`: confirms the recovery and persists it to
281    ///   `config.json` in the same step ([`Config::confirm_recovery_and_save_to_dir`]),
282    ///   then clears the pending flag — the config is no longer "pending
283    ///   confirmation" once this returns `Ok`.
284    /// - `accept = false`: a no-op that leaves everything untouched — disk,
285    ///   in-memory config, and the pending flag are all left exactly as they
286    ///   were. `config.json` stays refused-to-write (see `save_to_dir`) until
287    ///   either a later `accept = true` call or the user hand-fixes
288    ///   `config.json` and the process reloads/restarts.
289    ///
290    /// Errors with [`AppError::BadRequest`] if there's no pending recovery to
291    /// resolve.
292    pub async fn confirm_config_recovery(&self, accept: bool) -> Result<Config, AppError> {
293        let _io = self.config_io_lock.lock().await;
294
295        if !accept {
296            let cfg = self.config.read().await;
297            return match cfg.recovery_status() {
298                Some(_) => Ok(cfg.clone()),
299                None => Err(AppError::BadRequest(
300                    "No pending config-corruption recovery to resolve".to_string(),
301                )),
302            };
303        }
304
305        let mut candidate = {
306            let cfg = self.config.read().await;
307            match cfg.recovery_status() {
308                Some(_) => cfg.clone(),
309                None => {
310                    return Err(AppError::BadRequest(
311                        "No pending config-corruption recovery to resolve".to_string(),
312                    ))
313                }
314            }
315        };
316
317        let data_dir = self.app_data_dir.clone();
318        candidate = tokio::task::spawn_blocking(move || {
319            candidate
320                .confirm_recovery_and_save_to_dir(data_dir)
321                .map(|_| candidate)
322        })
323        .await
324        .map_err(|e| {
325            AppError::InternalError(anyhow::anyhow!("Config recovery-confirm task failed: {e}"))
326        })?
327        .map_err(|e| {
328            AppError::InternalError(anyhow::anyhow!("Failed to save recovered config: {e}"))
329        })?;
330
331        {
332            let mut cfg = self.config.write().await;
333            *cfg = candidate.clone();
334            cfg.publish_env_vars();
335        }
336
337        Ok(candidate)
338    }
339}
340
341/// Short-circuit config-mutating entrypoints while a config-corruption
342/// recovery is pending confirmation (#153): `save_to_dir` would refuse the
343/// disk write anyway, but rejecting here — before any in-memory mutation
344/// runs — keeps the in-memory config frozen at exactly the recovered state
345/// instead of drifting further out of sync with what's actually on disk.
346/// Resolve the pending recovery via `AppState::confirm_config_recovery`
347/// first.
348fn reject_if_recovery_pending(cfg: &Config) -> Result<(), AppError> {
349    if let Some(status) = cfg.recovery_status() {
350        if !status.confirmed {
351            return Err(AppError::ConfigRecoveryPending(format!(
352                "config.json was recovered from corruption ({:?}) and is awaiting \
353                 confirmation; confirm or reject the recovery (see /bamboo/config/recovery-status \
354                 and /bamboo/config/recovery/confirm) before changing settings",
355                status.source
356            )));
357        }
358    }
359    Ok(())
360}
361
362/// The prominent warning emitted whenever `plugin_trust.enforcement` is (or
363/// becomes) `Off` — that setting silently downgrades EVERY subsequent `url`
364/// plugin install/update to skip the host allowlist, signature, and
365/// checksum-required-by-default layers, with no per-install flag needed (see
366/// `crate::plugin_source`'s module docs). Factored into one function so the
367/// boot-time signal (`AppState::new`) and the live-apply signal
368/// (`update_config`/`replace_config`, covering the HTTP `config set` / PATCH
369/// paths) emit the EXACT same message — no drift, and no trigger can flip
370/// enforcement off silently.
371pub(crate) fn warn_plugin_trust_enforcement_off() {
372    tracing::warn!(
373        "plugin_trust.enforcement is OFF — plugin installs from ANY URL are accepted \
374         without host/signature/checksum verification (config.json plugin_trust.enforcement)"
375    );
376}