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