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 let was_off = cfg.plugin_trust.enforcement_is_off();
154 update(&mut cfg)?;
155 cfg.publish_env_vars();
156 let newly_off = !was_off && cfg.plugin_trust.enforcement_is_off();
157 (cfg.clone(), newly_off)
158 };
159 // Loud signal at the MOMENT plugin_trust.enforcement is flipped off
160 // live (e.g. via `bamboo config set plugin_trust.enforcement off`
161 // over HTTP), mirroring the boot-time warn in `AppState::new` — so
162 // this security-relevant relaxation is never applied silently. Only
163 // on the transition into `Off` (not every unrelated config write
164 // while already off).
165 if enforcement_newly_off {
166 warn_plugin_trust_enforcement_off();
167 }
168 self.persist_config_snapshot(snapshot.clone())
169 .await
170 .map_err(|e| {
171 AppError::InternalError(anyhow::anyhow!("Failed to save config: {e}"))
172 })?;
173 snapshot
174 };
175
176 self.apply_config_effects(snapshot.clone(), effects).await?;
177 Ok(snapshot)
178 }
179
180 /// Replace the full config (used for JSON merge endpoints).
181 pub async fn replace_config(
182 &self,
183 new_config: Config,
184 effects: ConfigUpdateEffects,
185 ) -> Result<Config, AppError> {
186 // Same #126 serialization as update_config: mutate + persist under the
187 // config-IO lock so a reload can't interleave; effects run unlocked.
188 {
189 let _io = self.config_io_lock.lock().await;
190 let enforcement_newly_off = {
191 let mut cfg = self.config.write().await;
192 let was_off = cfg.plugin_trust.enforcement_is_off();
193 *cfg = new_config.clone();
194 cfg.publish_env_vars();
195 !was_off && cfg.plugin_trust.enforcement_is_off()
196 };
197 // Same live signal as `update_config` — a full-config replace (JSON
198 // merge / PATCH endpoints) that transitions plugin_trust.enforcement
199 // into `Off` must warn just as loudly as a targeted set.
200 if enforcement_newly_off {
201 warn_plugin_trust_enforcement_off();
202 }
203 self.persist_config_snapshot(new_config.clone())
204 .await
205 .map_err(|e| {
206 AppError::InternalError(anyhow::anyhow!("Failed to save config: {e}"))
207 })?;
208 }
209
210 self.apply_config_effects(new_config.clone(), effects)
211 .await?;
212 Ok(new_config)
213 }
214
215 async fn apply_config_effects(
216 &self,
217 new_config: Config,
218 effects: ConfigUpdateEffects,
219 ) -> Result<(), AppError> {
220 if effects.reload_provider {
221 self.reload_provider().await.map_err(|e| {
222 AppError::InternalError(anyhow::anyhow!(
223 "Failed to reload provider after updating config: {e}"
224 ))
225 })?;
226 }
227
228 if effects.reconcile_mcp {
229 self.mcp_manager
230 .reconcile_from_config(&new_config.mcp)
231 .await;
232 }
233
234 Ok(())
235 }
236}
237
238/// The prominent warning emitted whenever `plugin_trust.enforcement` is (or
239/// becomes) `Off` — that setting silently downgrades EVERY subsequent `url`
240/// plugin install/update to skip the host allowlist, signature, and
241/// checksum-required-by-default layers, with no per-install flag needed (see
242/// `crate::plugin_source`'s module docs). Factored into one function so the
243/// boot-time signal (`AppState::new`) and the live-apply signal
244/// (`update_config`/`replace_config`, covering the HTTP `config set` / PATCH
245/// paths) emit the EXACT same message — no drift, and no trigger can flip
246/// enforcement off silently.
247pub(crate) fn warn_plugin_trust_enforcement_off() {
248 tracing::warn!(
249 "plugin_trust.enforcement is OFF — plugin installs from ANY URL are accepted \
250 without host/signature/checksum verification (config.json plugin_trust.enforcement)"
251 );
252}