bamboo_server/plugin_installer.rs
1//! `ServerPluginInstaller` — the `AppState`-backed implementation of
2//! `bamboo_plugin::PluginInstaller` (Wave 2 § Installer-core agent,
3//! `PLUGIN_PLAN.md`).
4//!
5//! `bamboo-plugin` is an `infra`-layer crate with no access to `AppState`, so
6//! its `LocalPluginInstaller` reference skeleton stops at
7//! `PluginError::NotImplemented` exactly where capability registration needs
8//! `config.json`, `mcp_manager`, `prompt-presets.json`, and plugin discovery
9//! roots. This type is the real implementation: an ordinary
10//! downstream `impl PluginInstaller for ServerPluginInstaller` (the trait is
11//! foreign, the type is local — no orphan-rule issue).
12//!
13//! # Why a borrowed `web::Data<AppState>` and no `AppState` struct change
14//!
15//! `ServerPluginInstaller` holds a `web::Data<AppState>` clone — the exact
16//! handle every HTTP handler in this crate already receives as an argument
17//! (`web::Data` is `Arc`-backed, so cloning it is cheap). An HTTP handler
18//! constructs one per request: `ServerPluginInstaller::new(state.clone())`.
19//! `AppState` itself is intentionally untouched — no new field, no
20//! coordinated append to `app_state/mod.rs` / `app_state/builder.rs` — so
21//! this branch can never conflict with the other Wave-2 branches that also
22//! stack on `feat/plugin-framework`.
23//!
24//! # Path derivation: `state.app_data_dir`, not the `bamboo_config::paths` globals
25//!
26//! `bamboo_config::paths::{plugins_dir, workflows_dir, plugins_installed_json_path, ...}`
27//! all resolve through a process-wide `OnceLock` that `AppState::new` seeds
28//! ONCE per process (first caller wins — see its doc comment). That is
29//! correct for the single production `AppState` per process, but this
30//! crate's own test suite already builds many `AppState`s over different
31//! `tempfile::tempdir()`s in the same test binary (e.g.
32//! `app_state::tests::test_app_state_creation` and friends) — if this type
33//! read the global helpers, every one of those `AppState`s would silently
34//! share whichever tempdir happened to construct the first one. Every path
35//! below is instead derived from the borrowed `state.app_data_dir` field
36//! directly, exactly the pattern `handlers::settings::workflows` and
37//! `prompt_presets::storage::store_file_path` already use. In production,
38//! where there is exactly one `AppState`, this resolves to the identical
39//! path the global helpers would have produced.
40//!
41//! # Concurrency
42//!
43//! Every `install`/`uninstall` runs under a single process-wide async lock
44//! ([`PLUGIN_OP_LOCK`]), held for the ENTIRE operation including rollback, so
45//! the reconcile→mutate→provenance sequence is atomic w.r.t. any other plugin
46//! op. This closes three concurrency gaps at once: the `installed.json` and
47//! `prompt-presets.json` load/modify/save lost-update races, and the MCP
48//! reconcile→config-write TOCTOU. As additional defense against a concurrent
49//! NON-plugin config write (which does not take this lock), the MCP step also
50//! RE-runs its ownership pre-check INSIDE the `update_config` closure, under
51//! `config_io_lock`, and aborts rather than clobbering if a foreign entry
52//! appeared. Lock ordering is `PLUGIN_OP_LOCK` → `config_io_lock` (never the
53//! reverse) — see [`PLUGIN_OP_LOCK`].
54//!
55//! Plugin workflow markdown is never copied into the user's global workflow
56//! directory. It remains inside the plugin bundle and is discovered in place
57//! by the SkillStore, so plugin install cannot overwrite a same-named user
58//! source and needs no shared workflow-file lock.
59//!
60//! # Crash safety (process killed mid-install)
61//!
62//! In-process rollback (below) only fires on an `Err`. A HARD kill after the
63//! MCP step wrote to `config.json` but before provenance is committed would,
64//! without a journal, leave: `reconcile_exclusive` seeing the orphaned mcp id
65//! as existing-but-not-owned → a false `Conflict` on the retry, AND
66//! `uninstall` returning `NotFound` (no provenance) → the user stuck
67//! hand-editing `config.json`. To prevent that, `install` writes a provenance
68//! row with status [`PluginInstallStatus::Installing`] — recording the
69//! INTENDED ownership set — BEFORE steps 1-4, and flips it to
70//! [`PluginInstallStatus::Installed`] only after step 5 succeeds. On the next
71//! install/upgrade of an id whose row is still `Installing` (a prior crash),
72//! [`load_previous_for_disposition`] returns it as `previous` (it does NOT
73//! trip `AlreadyInstalled`), so its intended set is treated as
74//! this-plugin-owned — the leftover reads as an `OwnedReinstall`, not a
75//! foreign conflict — and is cleaned up as an upgrade-over-incomplete.
76//! `uninstall` works on an `Installing` row too.
77//!
78//! # Atomicity / rollback semantics
79//!
80//! `install()` follows `PLUGIN_PLAN.md`'s numbered sequence exactly:
81//!
82//! 0. **Upgrade drop-diff** (only when upgrading an already-installed id):
83//! de-register whatever the new manifest no longer declares, computed via
84//! [`bamboo_plugin::registry::RegisteredCapabilities::removed_since`],
85//! BEFORE registering anything new. De-registration is idempotent/
86//! best-effort (see [`ServerPluginInstaller::deregister_capabilities`]) —
87//! an entry a user already removed by hand never blocks an upgrade.
88//! 1. **MCP** — ownership-checked (REFUSE on a foreign conflict, via
89//! [`bamboo_plugin::registry::reconcile_exclusive`]), merged into
90//! `config.json`, started.
91//! 2. **Prompts** — rename-on-collision (never refuse), appended to
92//! `prompt-presets.json`.
93//! 3. **Workflows** — validated for safe in-place discovery; no shared-store
94//! copy or ownership mutation.
95//! 4. **Skills** — nothing to register (discovered in place); just recorded.
96//! 5. **Provenance commit** — `installed.json` is only ever upserted after
97//! steps 0-4 all succeed.
98//!
99//! Steps 1-2 are real, sequential mutations (config then prompt-store writes)
100//! — NOT a dry-run computed up front — because `PLUGIN_PLAN.md`
101//! requires the ownership pre-checks to run in that exact order against the
102//! LIVE state each step leaves behind. That means a HARD failure at step 2
103//! or the workflow validation in step 3 can happen after step 1 already wrote
104//! real entries into `config.json`. [`ServerPluginInstaller::install`] tracks
105//! every already-applied mutation in an [`InstallRollback`] and, on any hard
106//! failure from steps 1-3, best-effort UNDOES them (removes the mcp entries
107//! it just added and stops any it started, removes the presets it just
108//! appended, and removes services it started) before returning the
109//! error — so a caller's retry starts from a clean slate. Provenance is
110//! never written on a failed path (step 5 is the only place `installed.json`
111//! is touched on success), which is the minimum safety bar even if a rollback
112//! step itself only partially succeeds (rollback operations are themselves
113//! idempotent/log-and-continue, so a second rollback attempt via a plain
114//! retry can never fail louder than the first).
115//!
116//! One known, accepted gap: `stage_plugin_source`/`install_plugin_from_source`
117//! in [`crate::plugin_source`] additionally guard the ON-DISK `plugin_dir`
118//! swap itself (an upgrade's new bundle replaces the old one's files at a
119//! fixed path) by moving the previous bundle aside instead of deleting it, and
120//! restoring it if `install()` subsequently fails — see that module's docs.
121//!
122//! Prompt-preset drop-diff caveat: the upgrade drop-diff compares the NEW
123//! manifest's nominal preset ids against the OLD install's ACTUAL (possibly
124//! renamed-on-collision) registered ids. A preset that got renamed at its
125//! original install time and is still declared under its original nominal id
126//! in the new manifest will look "dropped" (the nominal id is absent from the
127//! actual old set) and get re-appended (possibly renamed again). This is
128//! harmless — preset content is just refreshed under a fresh id — and not
129//! worth a stable-id-mapping schema change for what `RegisteredCapabilities`
130//! already documents as the one rename (not refuse) exception.
131
132use std::collections::HashSet;
133use std::path::{Path, PathBuf};
134
135use async_trait::async_trait;
136use chrono::{DateTime, Utc};
137use tokio::fs;
138
139use bamboo_domain::mcp_config::McpServerConfig;
140use bamboo_plugin::installer::{load_previous_for_disposition, preflight_install};
141use bamboo_plugin::manifest::{Platform, ServiceManifestEntry};
142use bamboo_plugin::registry::{reconcile_exclusive, RegisteredCapabilities};
143use bamboo_plugin::{
144 InstallDisposition, InstalledPlugin, InstalledPlugins, PluginError, PluginInstallStatus,
145 PluginInstaller, PluginManifest, PluginResult, PluginSource,
146};
147
148use crate::app_state::{AppState, ConfigUpdateEffects};
149use crate::error::AppError;
150use crate::handlers::agent::mcp::upsert_server_by_id;
151use crate::handlers::agent::prompt_presets::{
152 ensure_unique_preset_id, load_store, save_store, store_file_path, StoredPromptPreset,
153};
154use crate::service_manager::{ServiceManager, ServiceRuntimeConfig};
155
156/// Process-wide serialization of plugin install/uninstall operations.
157///
158/// The whole ownership/upgrade machinery is a read-modify-write over shared
159/// stores (`config.json`, `prompt-presets.json`, `installed.json`) with the
160/// ownership pre-check and the eventual mutation in separate steps. Under
161/// CONCURRENT plugin ops (the HTTP agent will expose exactly that) those
162/// interleave badly: two installs of different ids race `installed.json`'s
163/// load/add/save (last save drops the other's row), `prompt-presets.json`'s
164/// load/save (lost update), and the MCP reconcile→write window (a foreign
165/// entry landing mid-window gets clobbered AND recorded as plugin-owned,
166/// re-opening BLOCKER-1). Plugin installs are rare and not perf-sensitive, so
167/// one coarse process-wide lock held across the ENTIRE `install`/`uninstall`
168/// (including rollback) is the right call — it makes each op's
169/// reconcile→mutate→provenance sequence atomic w.r.t. every other plugin op.
170///
171/// Lock ordering: this lock is acquired at the TOP of `install`/`uninstall`,
172/// OUTSIDE any `AppState::update_config` call (which internally takes
173/// `config_io_lock`). So the order is always `PLUGIN_OP_LOCK` →
174/// `config_io_lock`, never the reverse — no deadlock. Nothing acquires
175/// `PLUGIN_OP_LOCK` while holding `config_io_lock`.
176///
177/// # Single-process assumption (deferred: no cross-process lock)
178///
179/// This is a `tokio::sync::Mutex` — IN-PROCESS only. It serializes plugin ops
180/// within one `bamboo serve` process, but two SEPARATE `bamboo serve`
181/// processes pointed at the same `~/.bamboo` data dir would each get their
182/// own independent `PLUGIN_OP_LOCK` and could race each other's
183/// reconcile→mutate→provenance sequence exactly the way this lock exists to
184/// prevent for concurrent ops WITHIN one process. The plugin system assumes
185/// the normal deployment: a SINGLE `bamboo serve` per data directory. True
186/// multi-process safety would need an OS-level file lock (e.g. `flock` on a
187/// lockfile under `plugins_dir()`) instead of/in addition to this `Mutex`;
188/// that's a documented follow-up, not implemented here.
189static PLUGIN_OP_LOCK: tokio::sync::Mutex<()> = tokio::sync::Mutex::const_new(());
190
191/// AppState-backed [`PluginInstaller`]. See the module docs for the full
192/// design rationale (borrowing, path derivation, atomicity).
193pub struct ServerPluginInstaller {
194 state: actix_web::web::Data<AppState>,
195}
196
197/// Mutations already applied by a not-yet-committed `install()`, so a hard
198/// failure partway through steps 1-3 can best-effort undo exactly what has
199/// been done so far. See the module docs' "Atomicity / rollback semantics".
200#[derive(Default)]
201struct InstallRollback {
202 mcp_ids_added: Vec<String>,
203 mcp_ids_started: Vec<String>,
204 preset_ids_added: Vec<String>,
205 /// Service ids this install claimed ownership of (whether or not the
206 /// actual `start_service` call succeeded — best-effort, same contract as
207 /// `mcp_ids_added`/`mcp_ids_started`).
208 service_ids_added: Vec<String>,
209 /// Subset of `service_ids_added` that actually got a running
210 /// `ServiceManager` runtime started — only these need `stop_service` on
211 /// rollback.
212 service_ids_started: Vec<String>,
213}
214
215impl ServerPluginInstaller {
216 pub fn new(state: actix_web::web::Data<AppState>) -> Self {
217 Self { state }
218 }
219
220 fn plugins_dir(&self) -> PathBuf {
221 self.state.app_data_dir.join("plugins")
222 }
223
224 fn installed_json_path(&self) -> PathBuf {
225 self.plugins_dir().join("installed.json")
226 }
227
228 fn workflows_dir(&self) -> PathBuf {
229 self.state.app_data_dir.join("workflows")
230 }
231
232 fn prompt_presets_path(&self) -> PathBuf {
233 store_file_path(&self.state.app_data_dir)
234 }
235
236 /// `<data_dir>/plugin_service_config/<plugin_id>/config.json` — the
237 /// per-service user config path passed to services as
238 /// `BAMBOO_PLUGIN_SERVICE_CONFIG` (issue #479 open question 2).
239 ///
240 /// Deliberately NOT under `plugins_dir()/<plugin_id>/` (the
241 /// swap-managed `plugin_dir`): `plugin_source::stage_plugin_source`
242 /// upgrades a plugin by renaming the ENTIRE old `plugin_dir` aside and
243 /// swapping a freshly-staged directory into its place — any file living
244 /// inside `plugin_dir` would be swept away with the old bundle on
245 /// upgrade (or deleted outright on uninstall) unless bamboo specifically
246 /// carried it forward, which it does not. A sibling directory, named
247 /// only by `plugin_id`, is untouched by that swap and by
248 /// `uninstall`'s `remove_dir_all(plugin_dir)` — so a service's own
249 /// config (which may carry tokens/secrets a connector needs) survives
250 /// both an upgrade and an uninstall. bamboo only ever creates the PARENT
251 /// directory here (see [`Self::ensure_service_config_parent_dir`]) —
252 /// never writes or deletes `config.json` itself; on uninstall it is
253 /// deliberately left in place (not part of `remove_dir_all`), so a
254 /// later re-install of the same plugin id picks its old config back up
255 /// automatically.
256 fn service_config_path(&self, plugin_id: &str) -> PathBuf {
257 service_config_path_under(&self.state.app_data_dir, plugin_id)
258 }
259
260 async fn ensure_service_config_parent_dir(&self, plugin_id: &str) -> PluginResult<()> {
261 let path = self.service_config_path(plugin_id);
262 if let Some(parent) = path.parent() {
263 fs::create_dir_all(parent).await?;
264 }
265 Ok(())
266 }
267
268 /// Every service id currently owned by any OTHER installed plugin — the
269 /// "existing" side of [`reconcile_exclusive`] for services. There is no
270 /// single shared document analogous to `config.json` for services, so
271 /// provenance itself is the source of truth for "who owns this id".
272 ///
273 /// `exclude_plugin_id` MUST be the id of the plugin currently being
274 /// installed/upgraded and is always excluded — NOT an optional
275 /// nicety: by the time [`Self::register_services`] calls this,
276 /// `install()`'s crash-safety journal write has ALREADY upserted THIS
277 /// plugin's `Installing` row into `installed.json` with its full
278 /// INTENDED `service_ids` (see `install()`'s "Crash-safety journal"
279 /// step, which runs before every registration step). Without excluding
280 /// it, a plain fresh install would see its own not-yet-committed row as
281 /// a foreign owner of its own declared ids and refuse itself.
282 /// `previously_owned` (computed separately, from `previous` BEFORE that
283 /// journal write) is what still correctly classifies an upgrade's
284 /// re-declared ids as `OwnedReinstall` rather than `New`.
285 async fn existing_service_ids(&self, exclude_plugin_id: &str) -> PluginResult<Vec<String>> {
286 let store = InstalledPlugins::load(&self.installed_json_path()).await?;
287 Ok(store
288 .list()
289 .iter()
290 .filter(|plugin| plugin.id != exclude_plugin_id)
291 .flat_map(|plugin| plugin.registered.service_ids.iter().cloned())
292 .collect())
293 }
294
295 /// Resolve one manifest-declared service entry into a
296 /// [`ServiceRuntimeConfig`] ready for `ServiceManager::start_service`.
297 fn resolve_service_config(
298 &self,
299 plugin_id: &str,
300 entry: &ServiceManifestEntry,
301 plugin_dir: &Path,
302 platform: Platform,
303 ) -> ServiceRuntimeConfig {
304 resolve_service_config_under(
305 &self.state.app_data_dir,
306 plugin_id,
307 entry,
308 plugin_dir,
309 platform,
310 )
311 }
312
313 // --- De-registration primitives (shared by upgrade drop-diff, install
314 // rollback, and `uninstall`). Each is individually idempotent/tolerant —
315 // an entry that is already gone (e.g. a user manually deleted it) is
316 // logged and skipped, never a hard failure — matching the requirement
317 // that de-registration never blocks an uninstall/upgrade retry. ---
318
319 async fn remove_mcp_server(&self, id: &str) {
320 let owned_id = id.to_string();
321 let result = self
322 .state
323 .update_config(
324 move |cfg| {
325 cfg.mcp.servers.retain(|server| server.id != owned_id);
326 Ok(())
327 },
328 ConfigUpdateEffects::default(),
329 )
330 .await;
331 if let Err(error) = result {
332 tracing::warn!(
333 mcp_server_id = %id,
334 %error,
335 "failed to remove plugin-owned mcp server from config.json; continuing"
336 );
337 }
338 if let Err(error) = self.state.mcp_manager.stop_server(id).await {
339 tracing::warn!(
340 mcp_server_id = %id,
341 %error,
342 "failed to stop plugin-owned mcp server; continuing"
343 );
344 }
345 }
346
347 async fn remove_prompt_preset(&self, preset_id: &str) {
348 let path = self.prompt_presets_path();
349 match load_store(&path).await {
350 Ok(mut store) => {
351 let before = store.prompts.len();
352 store.prompts.retain(|preset| preset.id != preset_id);
353 if store.prompts.len() != before {
354 if let Err(error) = save_store(&path, &store).await {
355 tracing::warn!(
356 %preset_id,
357 %error,
358 "failed to persist prompt-presets.json after removing plugin-owned preset; continuing"
359 );
360 }
361 }
362 }
363 Err(error) => {
364 tracing::warn!(
365 %preset_id,
366 %error,
367 "failed to load prompt-presets.json while removing plugin-owned preset; continuing"
368 );
369 }
370 }
371 }
372
373 async fn remove_service(&self, id: &str) {
374 if let Err(error) = self.state.service_manager.stop_service(id).await {
375 tracing::warn!(
376 service_id = %id,
377 %error,
378 "failed to stop plugin-owned service; continuing"
379 );
380 }
381 }
382
383 async fn remove_workflow_file(&self, filename: &str) {
384 let path = self.workflows_dir().join(filename);
385 match fs::remove_file(&path).await {
386 Ok(()) => {}
387 Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
388 Err(error) => {
389 tracing::warn!(
390 %filename,
391 %error,
392 "failed to remove plugin-owned workflow file; continuing"
393 );
394 }
395 }
396 }
397
398 /// De-register a whole [`RegisteredCapabilities`] set (used for the
399 /// upgrade drop-diff and for `uninstall`). Skill dirs need no shared-store
400 /// action — they are only ever removed by deleting `plugin_dir` itself.
401 async fn deregister_capabilities(&self, registered: &RegisteredCapabilities) {
402 for mcp_id in ®istered.mcp_server_ids {
403 self.remove_mcp_server(mcp_id).await;
404 }
405 for preset_id in ®istered.preset_ids {
406 self.remove_prompt_preset(preset_id).await;
407 }
408 for workflow_filename in ®istered.workflow_filenames {
409 self.remove_workflow_file(workflow_filename).await;
410 }
411 for service_id in ®istered.service_ids {
412 self.remove_service(service_id).await;
413 }
414 }
415
416 /// Best-effort undo of an `install()` that failed partway through steps
417 /// 1-3. See the module docs.
418 async fn rollback_partial_install(&self, rollback: &InstallRollback) {
419 for id in &rollback.mcp_ids_started {
420 let _ = self.state.mcp_manager.stop_server(id).await;
421 }
422 for id in &rollback.mcp_ids_added {
423 self.remove_mcp_server(id).await;
424 }
425 for id in &rollback.preset_ids_added {
426 self.remove_prompt_preset(id).await;
427 }
428 for id in &rollback.service_ids_started {
429 let _ = self.state.service_manager.stop_service(id).await;
430 }
431 }
432
433 /// Upsert one provenance row into `installed.json`. Used both for the
434 /// pre-registration `Installing` journal row and the final `Installed`
435 /// commit — the ONLY two writers of `installed.json` in `install`. Both
436 /// run under [`PLUGIN_OP_LOCK`], so the load/add/save is race-free.
437 async fn upsert_provenance(&self, entry: InstalledPlugin, path: &Path) -> PluginResult<()> {
438 let mut store = InstalledPlugins::load(path).await?;
439 store.add(entry);
440 store.save(path).await?;
441 Ok(())
442 }
443
444 /// Abort an in-process `install` failure: best-effort undo of the partial
445 /// registration (steps 1-3), then restore `installed.json` to its
446 /// pre-install state — re-writing the original `previous` row on an
447 /// upgrade/recovery, or removing the id's row entirely on a fresh install
448 /// — so the `Installing` journal row we wrote up front never lingers after
449 /// a clean in-process failure. (A HARD kill is the only path that
450 /// intentionally leaves an `Installing` row, for the next op to recover.)
451 async fn abort_install(
452 &self,
453 rollback: &InstallRollback,
454 previous: &Option<InstalledPlugin>,
455 plugin_id: &str,
456 path: &Path,
457 ) {
458 self.rollback_partial_install(rollback).await;
459 let restore = match previous {
460 Some(prev) => self.upsert_provenance(prev.clone(), path).await,
461 None => match InstalledPlugins::load(path).await {
462 Ok(mut store) => {
463 store.remove(plugin_id);
464 store.save(path).await
465 }
466 Err(error) => Err(error),
467 },
468 };
469 if let Err(error) = restore {
470 tracing::warn!(
471 %plugin_id,
472 %error,
473 "failed to restore provenance after aborting a failed install; a stale \
474 `installing` row may remain (recoverable by a retry)"
475 );
476 }
477 }
478
479 /// Step 1: MCP. Returns the ids actually (re-)registered
480 /// (`reconciliation.to_register`), which — once past the conflict gate —
481 /// is exactly the declared id set.
482 async fn register_mcp(
483 &self,
484 manifest: &PluginManifest,
485 resolved_mcp_servers: Vec<McpServerConfig>,
486 previously_owned: &[String],
487 rollback: &mut InstallRollback,
488 ) -> PluginResult<Vec<String>> {
489 if resolved_mcp_servers.is_empty() {
490 return Ok(Vec::new());
491 }
492
493 let declared_ids: Vec<String> = manifest
494 .provides
495 .mcp_servers
496 .iter()
497 .map(|entry| entry.id.clone())
498 .collect();
499 let existing_ids: Vec<String> = {
500 let config = self.state.config.read().await;
501 config.mcp.servers.iter().map(|s| s.id.clone()).collect()
502 };
503
504 let reconciliation = reconcile_exclusive(&declared_ids, &existing_ids, previously_owned);
505 if !reconciliation.foreign_conflicts.is_empty() {
506 return Err(PluginError::Conflict {
507 kind: "mcp server",
508 name: reconciliation.foreign_conflicts.join(", "),
509 plugin_id: manifest.id.clone(),
510 });
511 }
512
513 let to_register: HashSet<&str> = reconciliation
514 .to_register
515 .iter()
516 .map(String::as_str)
517 .collect();
518 let configs_to_register: Vec<McpServerConfig> = resolved_mcp_servers
519 .into_iter()
520 .filter(|config| to_register.contains(config.id.as_str()))
521 .collect();
522
523 let owned_configs = configs_to_register.clone();
524 let declared_for_recheck = declared_ids.clone();
525 let owned_for_recheck: Vec<String> = previously_owned.to_vec();
526 let plugin_id_for_recheck = manifest.id.clone();
527 self.state
528 .update_config(
529 move |cfg| {
530 // TOCTOU guard: re-run the ownership pre-check against the
531 // LIVE config while holding config_io_lock, so a foreign
532 // entry that landed between our earlier read and now can't
533 // be silently clobbered (and then recorded as
534 // plugin-owned, re-opening BLOCKER-1 under a race).
535 // Concurrent PLUGIN ops are already excluded by
536 // PLUGIN_OP_LOCK; this closes the residual window against a
537 // concurrent NON-plugin config write.
538 let live_existing: Vec<String> =
539 cfg.mcp.servers.iter().map(|s| s.id.clone()).collect();
540 let live = reconcile_exclusive(
541 &declared_for_recheck,
542 &live_existing,
543 &owned_for_recheck,
544 );
545 if !live.foreign_conflicts.is_empty() {
546 return Err(AppError::BadRequest(format!(
547 "mcp server(s) '{}' now conflict with a non-plugin entry (a concurrent \
548 change landed mid-install); refusing to overwrite for plugin '{}'",
549 live.foreign_conflicts.join(", "),
550 plugin_id_for_recheck
551 )));
552 }
553 // Shared by-id merge (same helper import_servers uses).
554 for server in &owned_configs {
555 upsert_server_by_id(&mut cfg.mcp.servers, server.clone());
556 }
557 Ok(())
558 },
559 ConfigUpdateEffects::default(),
560 )
561 .await
562 .map_err(|error| {
563 PluginError::Registration(format!("failed to write mcp servers to config: {error}"))
564 })?;
565 // Config write for the whole batch succeeded — record ownership now,
566 // regardless of whether individual `start_server` calls below
567 // succeed (matches `import_servers`' best-effort start semantics: a
568 // config entry that fails to start is still a real, plugin-owned
569 // registration a user/CLI can retry starting later).
570 rollback.mcp_ids_added = reconciliation.to_register.clone();
571
572 for server in &configs_to_register {
573 // Stop any stale running instance first, matching the
574 // update/import handlers' pattern.
575 let _ = self.state.mcp_manager.stop_server(&server.id).await;
576 if server.enabled {
577 match self.state.mcp_manager.start_server(server.clone()).await {
578 Ok(()) => rollback.mcp_ids_started.push(server.id.clone()),
579 Err(error) => tracing::warn!(
580 mcp_server_id = %server.id,
581 %error,
582 "plugin-registered mcp server failed to start; config entry kept (best-effort)"
583 ),
584 }
585 }
586 }
587
588 Ok(reconciliation.to_register)
589 }
590
591 /// Step 1b: Services (issue #479, prereq for epic #477). Same
592 /// REFUSE-on-conflict + best-effort-start shape as [`Self::register_mcp`],
593 /// against [`Self::existing_service_ids`] instead of `config.json` (there
594 /// is no shared config document for services — provenance itself is the
595 /// ownership store, see that method's doc comment).
596 async fn register_services(
597 &self,
598 manifest: &PluginManifest,
599 plugin_dir: &Path,
600 previously_owned: &[String],
601 rollback: &mut InstallRollback,
602 ) -> PluginResult<Vec<String>> {
603 if manifest.provides.services.is_empty() {
604 return Ok(Vec::new());
605 }
606
607 let declared_ids: Vec<String> = manifest
608 .provides
609 .services
610 .iter()
611 .map(|entry| entry.id.clone())
612 .collect();
613 let existing_ids = self.existing_service_ids(&manifest.id).await?;
614 let reconciliation = reconcile_exclusive(&declared_ids, &existing_ids, previously_owned);
615 if !reconciliation.foreign_conflicts.is_empty() {
616 return Err(PluginError::Conflict {
617 kind: "service",
618 name: reconciliation.foreign_conflicts.join(", "),
619 plugin_id: manifest.id.clone(),
620 });
621 }
622
623 // Ownership is claimed regardless of individual start outcomes below
624 // (matches `register_mcp`'s "config write succeeded, so record
625 // ownership" contract — here there is no config write, so this is
626 // simply claimed up front).
627 rollback.service_ids_added = reconciliation.to_register.clone();
628
629 self.ensure_service_config_parent_dir(&manifest.id).await?;
630 let platform = Platform::current().unwrap_or(Platform::Linux);
631 let to_register: HashSet<&str> = reconciliation
632 .to_register
633 .iter()
634 .map(String::as_str)
635 .collect();
636
637 for entry in &manifest.provides.services {
638 if !to_register.contains(entry.id.as_str()) {
639 continue;
640 }
641 // Stop any stale running instance first — covers a leftover
642 // from a crashed install/upgrade recovery (a genuine same-id
643 // upgrade already had its old service stopped BEFORE staging by
644 // `stop_services_for_upgrade`, see the module docs' "Same-id
645 // upgrade ordering").
646 let _ = self.state.service_manager.stop_service(&entry.id).await;
647 if !entry.enabled {
648 continue;
649 }
650 let config = self.resolve_service_config(&manifest.id, entry, plugin_dir, platform);
651 match self.state.service_manager.start_service(config).await {
652 Ok(()) => rollback.service_ids_started.push(entry.id.clone()),
653 Err(error) => tracing::warn!(
654 service_id = %entry.id,
655 %error,
656 "plugin-registered service failed to start; ownership kept (best-effort, matches mcp)"
657 ),
658 }
659 }
660
661 Ok(reconciliation.to_register)
662 }
663
664 /// Step 2: Prompts. Rename-on-collision (never refuses) — returns the
665 /// ACTUAL ids used (after any rename), which is what provenance must
666 /// record.
667 async fn register_prompts(&self, manifest: &PluginManifest) -> PluginResult<Vec<String>> {
668 if manifest.provides.prompts.is_empty() {
669 return Ok(Vec::new());
670 }
671
672 let path = self.prompt_presets_path();
673 let mut store = load_store(&path).await.map_err(|error| {
674 PluginError::Registration(format!("failed to load prompt-presets.json: {error}"))
675 })?;
676
677 let mut existing_ids: HashSet<String> = store
678 .prompts
679 .iter()
680 .map(|preset| preset.id.clone())
681 .collect();
682 // `general_assistant` (bamboo-server's DEFAULT_PRESET_ID) is never a
683 // row in the store, so it wouldn't otherwise appear in `existing_ids`
684 // — but manifest validation already rejects any plugin declaring it
685 // (RESERVED_PRESET_IDS), so no extra guard is needed here.
686
687 let mut actual_ids = Vec::with_capacity(manifest.provides.prompts.len());
688 for preset in &manifest.provides.prompts {
689 let actual_id = ensure_unique_preset_id(&preset.id, &existing_ids);
690 store.prompts.push(StoredPromptPreset {
691 id: actual_id.clone(),
692 name: preset.name.clone(),
693 description: preset.description.clone(),
694 content: preset.content.clone(),
695 });
696 existing_ids.insert(actual_id.clone());
697 actual_ids.push(actual_id);
698 }
699
700 save_store(&path, &store).await.map_err(|error| {
701 PluginError::Registration(format!("failed to persist prompt-presets.json: {error}"))
702 })?;
703
704 Ok(actual_ids)
705 }
706
707 /// Step 3: validate legacy plugin workflows for in-place discovery.
708 ///
709 /// Workflow markdown stays under `<plugin_dir>/workflows`; the SkillStore
710 /// discovers it as a read-only legacy adapter. Nothing is copied into the
711 /// shared global workflows directory, so a plugin filename can never
712 /// conflict with or overwrite a user's own legacy workflow.
713 async fn validate_workflows_in_place(
714 &self,
715 manifest: &PluginManifest,
716 plugin_dir: &Path,
717 ) -> PluginResult<()> {
718 if manifest.provides.workflows.is_empty() {
719 return Ok(());
720 }
721
722 let workflows_dir = plugin_dir.join("workflows");
723 let directory_metadata = fs::symlink_metadata(&workflows_dir).await?;
724 if !directory_metadata.is_dir() || directory_metadata.file_type().is_symlink() {
725 return Err(PluginError::InvalidManifest(
726 "plugin workflows must live in a real workflows directory".to_string(),
727 ));
728 }
729 let declared: HashSet<&str> = manifest
730 .provides
731 .workflows
732 .iter()
733 .map(String::as_str)
734 .collect();
735 let mut actual = HashSet::new();
736 let mut entries = fs::read_dir(&workflows_dir).await?;
737 while let Some(entry) = entries.next_entry().await? {
738 let path = entry.path();
739 if path.extension().and_then(|value| value.to_str()) != Some("md") {
740 continue;
741 }
742 let file_type = entry.file_type().await?;
743 let Some(filename) = entry.file_name().to_str().map(str::to_string) else {
744 return Err(PluginError::InvalidManifest(
745 "plugin workflow filename must be UTF-8".to_string(),
746 ));
747 };
748 if !file_type.is_file() || file_type.is_symlink() {
749 return Err(PluginError::InvalidManifest(format!(
750 "workflow '{filename}' must be a regular in-place markdown file"
751 )));
752 }
753 actual.insert(filename);
754 }
755 if actual.len() != declared.len()
756 || !actual.iter().all(|name| declared.contains(name.as_str()))
757 {
758 return Err(PluginError::InvalidManifest(
759 "provides.workflows must declare every workflows/*.md file exactly once"
760 .to_string(),
761 ));
762 }
763
764 for filename in &manifest.provides.workflows {
765 let stem = filename.strip_suffix(".md").unwrap_or(filename);
766 if !bamboo_config::paths::is_safe_workflow_name(stem) {
767 return Err(PluginError::InvalidManifest(format!(
768 "workflow filename '{filename}' is not a safe workflow name"
769 )));
770 }
771 let source_path = workflows_dir.join(filename);
772 let metadata = fs::symlink_metadata(&source_path).await?;
773 if !metadata.file_type().is_file() || metadata.file_type().is_symlink() {
774 return Err(PluginError::InvalidManifest(format!(
775 "workflow '{filename}' must be a regular in-place markdown file"
776 )));
777 }
778 }
779 Ok(())
780 }
781
782 /// **Same-id upgrade ordering** (issue #479 "Install-flow deltas" /
783 /// "Same-id upgrade ordering bug risk"): `plugin_source::stage_plugin_source`
784 /// swaps `plugin_dir`'s ENTIRE contents (old bundle moved to a
785 /// `.backup-*` dir, staged bundle renamed into place) BEFORE
786 /// `install()` — and therefore [`Self::register_services`] — ever runs.
787 /// A still-running old service process holding the old binary open
788 /// during that swap is at best running stale code post-swap and at
789 /// worst (Windows) blocks the rename outright. The minimal seam that
790 /// fixes the ordering without restructuring `stage_plugin_source`/
791 /// `install()`: the HTTP `update_plugin` handler calls this BEFORE
792 /// `stage_plugin_source`, using the URL path's target id (known up
793 /// front for an upgrade, unlike a fresh `install`) to look up the
794 /// CURRENTLY-installed row's `registered.service_ids` and stop each one.
795 /// Net effect: stop (old binary) → swap (new binary) → start (new
796 /// binary, via `register_services` inside `install()`), exactly the
797 /// sequencing the issue calls for.
798 ///
799 /// Best-effort: returns exactly the ids that were actually running and
800 /// got stopped (not e.g. an already-stopped or unknown id), so a
801 /// subsequently-failed upgrade can restart precisely those — see
802 /// [`Self::restart_services_after_failed_upgrade`]. A plugin with no
803 /// prior install (or no services) returns an empty vec and stops
804 /// nothing.
805 pub async fn stop_services_for_upgrade(&self, plugin_id: &str) -> Vec<String> {
806 let store = match InstalledPlugins::load(&self.installed_json_path()).await {
807 Ok(store) => store,
808 Err(error) => {
809 tracing::warn!(
810 %plugin_id,
811 %error,
812 "stop_services_for_upgrade: failed to load installed.json; skipping"
813 );
814 return Vec::new();
815 }
816 };
817 let Some(entry) = store.get(plugin_id) else {
818 return Vec::new();
819 };
820 let mut stopped = Vec::with_capacity(entry.registered.service_ids.len());
821 for service_id in &entry.registered.service_ids {
822 match self.state.service_manager.stop_service(service_id).await {
823 Ok(()) => stopped.push(service_id.clone()),
824 Err(error) => tracing::debug!(
825 service_id = %service_id,
826 %error,
827 "stop_services_for_upgrade: service was not running; nothing to stop"
828 ),
829 }
830 }
831 stopped
832 }
833
834 /// Counterpart to [`Self::stop_services_for_upgrade`]: called after a
835 /// FAILED upgrade whose `StagedPlugin::rollback()` already restored
836 /// `plugin_dir` to the pre-upgrade bundle's bytes — re-reads that
837 /// (now-restored) OLD `plugin.json` and restarts exactly the services in
838 /// `stopped` that it still declares as `enabled`. Best-effort/
839 /// log-and-continue: a failure here leaves the affected service stopped
840 /// (a degraded-but-safe outcome — never silently double-runs an old and
841 /// a new instance) rather than panicking the request.
842 pub async fn restart_services_after_failed_upgrade(&self, plugin_id: &str, stopped: &[String]) {
843 if stopped.is_empty() {
844 return;
845 }
846 let store = match InstalledPlugins::load(&self.installed_json_path()).await {
847 Ok(store) => store,
848 Err(error) => {
849 tracing::warn!(
850 %plugin_id,
851 %error,
852 "restart_services_after_failed_upgrade: failed to load installed.json"
853 );
854 return;
855 }
856 };
857 let Some(entry) = store.get(plugin_id) else {
858 return;
859 };
860 let manifest_path = entry.plugin_dir.join("plugin.json");
861 let manifest = match fs::read_to_string(&manifest_path)
862 .await
863 .ok()
864 .and_then(|raw| PluginManifest::parse_str(&raw).ok())
865 {
866 Some(manifest) => manifest,
867 None => {
868 tracing::warn!(
869 %plugin_id,
870 path = %manifest_path.display(),
871 "restart_services_after_failed_upgrade: failed to read/parse the \
872 rolled-back plugin.json; affected service(s) remain stopped"
873 );
874 return;
875 }
876 };
877 let platform = Platform::current().unwrap_or(Platform::Linux);
878 for svc in &manifest.provides.services {
879 if !stopped.contains(&svc.id) || !svc.enabled {
880 continue;
881 }
882 let config = self.resolve_service_config(plugin_id, svc, &entry.plugin_dir, platform);
883 if let Err(error) = self.state.service_manager.start_service(config).await {
884 tracing::warn!(
885 service_id = %svc.id,
886 %plugin_id,
887 %error,
888 "failed to restart service after a failed upgrade rolled back to the \
889 previous plugin bundle; service remains stopped"
890 );
891 }
892 }
893 }
894}
895
896#[async_trait]
897impl PluginInstaller for ServerPluginInstaller {
898 async fn install(
899 &self,
900 manifest: &PluginManifest,
901 plugin_dir: &Path,
902 source: PluginSource,
903 disposition: InstallDisposition,
904 installed_at: DateTime<Utc>,
905 ) -> PluginResult<InstalledPlugin> {
906 // Serialize the whole op against every other plugin install/uninstall
907 // (process-wide) — held across all steps AND rollback. See module docs
908 // "Concurrency".
909 let _op_guard = PLUGIN_OP_LOCK.lock().await;
910
911 let installed_json_path = self.installed_json_path();
912
913 // Disposition gate (AlreadyInstalled only for a COMPLETED prior
914 // install; an `Installing` leftover is returned for recovery) + the
915 // rest of the pure, AppState-free validation this crate can already
916 // do (manifest shape, platform gate, on-disk skill/workflow
917 // existence, `provides.skills` authoritativeness).
918 let previous =
919 load_previous_for_disposition(&installed_json_path, &manifest.id, disposition).await?;
920 let resolved_mcp_servers = preflight_install(manifest, plugin_dir).await?;
921
922 // The set this install INTENDS to own, by declaration order. Used both
923 // for the crash-safety journal row (below) and the step-0 drop-diff.
924 let intended = RegisteredCapabilities {
925 mcp_server_ids: manifest
926 .provides
927 .mcp_servers
928 .iter()
929 .map(|entry| entry.id.clone())
930 .collect(),
931 skill_dirs: manifest.provides.skills.clone(),
932 preset_ids: manifest
933 .provides
934 .prompts
935 .iter()
936 .map(|preset| preset.id.clone())
937 .collect(),
938 // New workflow publications are discovered in place. Keeping this
939 // legacy copied-file field empty also makes an upgrade clean up
940 // files copied by pre-#561 installs through the drop-diff below.
941 workflow_filenames: Vec::new(),
942 service_ids: manifest
943 .provides
944 .services
945 .iter()
946 .map(|entry| entry.id.clone())
947 .collect(),
948 };
949
950 // Step 0: upgrade drop-diff. Computed from the NEW manifest's plain
951 // declared ids (see module docs re: the preset-rename caveat) vs the
952 // OLD install's registered set — de-register whatever the new version
953 // no longer declares BEFORE registering anything new (BLOCKER 2). Also
954 // fires for a recovery over an `Installing` leftover: its intended set
955 // is diffed the same way, so a crashed attempt's extra ids get cleaned.
956 if let Some(previous) = &previous {
957 let dropped = intended.removed_since(&previous.registered);
958 if !dropped.is_empty() {
959 tracing::info!(
960 plugin_id = %manifest.id,
961 recovering = previous.status == PluginInstallStatus::Installing,
962 dropped_mcp = ?dropped.mcp_server_ids,
963 dropped_presets = ?dropped.preset_ids,
964 dropped_workflows = ?dropped.workflow_filenames,
965 dropped_services = ?dropped.service_ids,
966 "install drop-diff: de-registering capabilities the new/completed version no longer declares"
967 );
968 self.deregister_capabilities(&dropped).await;
969 }
970 }
971
972 let previously_owned_mcp = previous
973 .as_ref()
974 .map(|p| p.registered.mcp_server_ids.clone())
975 .unwrap_or_default();
976 let previously_owned_services = previous
977 .as_ref()
978 .map(|p| p.registered.service_ids.clone())
979 .unwrap_or_default();
980
981 // Crash-safety journal: write an `Installing` provenance row recording
982 // the INTENDED ownership set BEFORE mutating any shared store, so a
983 // hard kill mid-install leaves a recoverable marker (see module docs
984 // "Crash safety"). On a fresh install this creates the row; on an
985 // upgrade/recovery it overwrites the prior row.
986 self.upsert_provenance(
987 InstalledPlugin {
988 id: manifest.id.clone(),
989 version: manifest.version.clone(),
990 source: source.clone(),
991 plugin_dir: plugin_dir.to_path_buf(),
992 installed_at,
993 status: PluginInstallStatus::Installing,
994 registered: intended.clone(),
995 },
996 &installed_json_path,
997 )
998 .await?;
999
1000 let mut rollback = InstallRollback::default();
1001
1002 // Step 1: MCP.
1003 let mcp_server_ids = match self
1004 .register_mcp(
1005 manifest,
1006 resolved_mcp_servers,
1007 &previously_owned_mcp,
1008 &mut rollback,
1009 )
1010 .await
1011 {
1012 Ok(ids) => ids,
1013 Err(error) => {
1014 self.abort_install(&rollback, &previous, &manifest.id, &installed_json_path)
1015 .await;
1016 return Err(error);
1017 }
1018 };
1019
1020 // Step 1b: Services (issue #479). Runs right after MCP, before the
1021 // never-refusing Prompts step, so a services conflict fails the
1022 // install as early as the other REFUSE-on-conflict kinds do. Note:
1023 // for a same-id UPGRADE, the OLD service (if any) was already
1024 // stopped by `stop_services_for_upgrade` before `stage_plugin_source`
1025 // swapped `plugin_dir` — see that method's doc comment on the
1026 // stop→swap→start sequencing.
1027 let service_ids = match self
1028 .register_services(
1029 manifest,
1030 plugin_dir,
1031 &previously_owned_services,
1032 &mut rollback,
1033 )
1034 .await
1035 {
1036 Ok(ids) => ids,
1037 Err(error) => {
1038 self.abort_install(&rollback, &previous, &manifest.id, &installed_json_path)
1039 .await;
1040 return Err(error);
1041 }
1042 };
1043
1044 // Step 2: Prompts.
1045 let preset_ids = match self.register_prompts(manifest).await {
1046 Ok(ids) => {
1047 rollback.preset_ids_added = ids.clone();
1048 ids
1049 }
1050 Err(error) => {
1051 self.abort_install(&rollback, &previous, &manifest.id, &installed_json_path)
1052 .await;
1053 return Err(error);
1054 }
1055 };
1056
1057 // Step 3: Workflows remain inside the plugin bundle and are discovered
1058 // by the shared SkillStore as read-only legacy adapters.
1059 let workflow_filenames = match self.validate_workflows_in_place(manifest, plugin_dir).await
1060 {
1061 Ok(()) => Vec::new(),
1062 Err(error) => {
1063 self.abort_install(&rollback, &previous, &manifest.id, &installed_json_path)
1064 .await;
1065 return Err(error);
1066 }
1067 };
1068
1069 // Step 4: Skills — nothing to register, just record the
1070 // declared+validated dir names (preflight_install already confirmed
1071 // every declared dir exists and that no undeclared dir is present).
1072 let skill_dirs = manifest.provides.skills.clone();
1073
1074 // Step 5: commit provenance — flip the journal row to `Installed` with
1075 // the ACTUAL registered set (renamed preset ids, the to_register mcp/
1076 // workflow subsets). Only reached once 0-4 all succeeded.
1077 let registered = RegisteredCapabilities {
1078 mcp_server_ids,
1079 skill_dirs,
1080 preset_ids,
1081 workflow_filenames,
1082 service_ids,
1083 };
1084 let entry = InstalledPlugin {
1085 id: manifest.id.clone(),
1086 version: manifest.version.clone(),
1087 source,
1088 plugin_dir: plugin_dir.to_path_buf(),
1089 installed_at,
1090 status: PluginInstallStatus::Installed,
1091 registered,
1092 };
1093 self.upsert_provenance(entry.clone(), &installed_json_path)
1094 .await?;
1095
1096 Ok(entry)
1097 }
1098
1099 async fn uninstall(&self, id: &str) -> PluginResult<()> {
1100 // Serialize against every other plugin op (see module docs).
1101 let _op_guard = PLUGIN_OP_LOCK.lock().await;
1102
1103 let installed_json_path = self.installed_json_path();
1104 let mut store = InstalledPlugins::load(&installed_json_path).await?;
1105 // Works on an `Installing` (crash-leftover) row too, so a crashed
1106 // install is never un-uninstallable.
1107 let Some(entry) = store.get(id).cloned() else {
1108 return Err(PluginError::NotFound(id.to_string()));
1109 };
1110
1111 // De-register everything this plugin's `registered` set names — by
1112 // construction (see bamboo-plugin's ownership contract) this can
1113 // only ever be entries the plugin itself created. Idempotent: a
1114 // manually-removed entry is logged and skipped, never a hard error.
1115 self.deregister_capabilities(&entry.registered).await;
1116
1117 // Remove the plugin's own files BEFORE clearing provenance: if this
1118 // fails (e.g. a permission error), provenance is left intact so a
1119 // retry is safe (the de-registration above is idempotent, so
1120 // re-running it is a harmless no-op) rather than leaving an
1121 // unregistered-but-still-on-disk `skills/` dir that discovery would
1122 // keep picking up despite `uninstall` having "succeeded".
1123 match fs::remove_dir_all(&entry.plugin_dir).await {
1124 Ok(()) => {}
1125 Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
1126 Err(error) => return Err(PluginError::Io(error)),
1127 }
1128
1129 store.remove(id);
1130 store.save(&installed_json_path).await?;
1131 Ok(())
1132 }
1133
1134 async fn list(&self) -> PluginResult<Vec<InstalledPlugin>> {
1135 let store = InstalledPlugins::load(&self.installed_json_path()).await?;
1136 Ok(store.plugins)
1137 }
1138}
1139
1140// ---------------------------------------------------------------------
1141// Free helpers shared between `ServerPluginInstaller` (instance methods
1142// above, which delegate here) and `boot_reconcile_services` (which has no
1143// `ServerPluginInstaller`/`AppState` handle to call instance methods on —
1144// see `app_state::builder`, which calls it before `AppState` finishes
1145// constructing).
1146// ---------------------------------------------------------------------
1147
1148/// See `ServerPluginInstaller::service_config_path`'s doc comment for the
1149/// full rationale (kept there since that's the reader's first encounter).
1150fn service_config_path_under(app_data_dir: &Path, plugin_id: &str) -> PathBuf {
1151 app_data_dir
1152 .join("plugin_service_config")
1153 .join(plugin_id)
1154 .join("config.json")
1155}
1156
1157fn resolve_service_config_under(
1158 app_data_dir: &Path,
1159 plugin_id: &str,
1160 entry: &ServiceManifestEntry,
1161 plugin_dir: &Path,
1162 platform: Platform,
1163) -> ServiceRuntimeConfig {
1164 let resolved = entry.resolve(plugin_dir, plugin_id, platform);
1165 ServiceRuntimeConfig {
1166 id: resolved.id,
1167 plugin_id: plugin_id.to_string(),
1168 name: resolved.name,
1169 command: resolved.command,
1170 args: resolved.args,
1171 cwd: resolved.cwd,
1172 env: resolved.env,
1173 health_check: resolved.health_check,
1174 restart_policy: resolved.restart_policy,
1175 graceful_shutdown: resolved.graceful_shutdown,
1176 user_config_path: service_config_path_under(app_data_dir, plugin_id),
1177 }
1178}
1179
1180/// Boot-time reconcile (issue #479): start every ENABLED, plugin-owned
1181/// service that `installed.json` says should be running but has no live
1182/// [`ServiceManager`] runtime — the previous `bamboo serve` process (if any)
1183/// died along with every service it supervised (child processes are spawned
1184/// `kill_on_drop`, and nothing about a running service persists
1185/// cross-process). Called from `app_state::builder` the same way
1186/// `app_state::init::init_mcp_manager` kicks off its background MCP
1187/// bootstrap — the caller is expected to `tokio::spawn` this, NOT await it
1188/// inline, so server startup is never blocked on plugin service spawns.
1189///
1190/// Deliberately reads `installed.json` + each plugin's on-disk
1191/// `plugin.json` directly rather than going through `ServerPluginInstaller`
1192/// (which needs a fully-built `web::Data<AppState>` this runs before).
1193pub async fn boot_reconcile_services(app_data_dir: &Path, service_manager: &ServiceManager) {
1194 let installed_json_path = app_data_dir.join("plugins").join("installed.json");
1195 let store = match InstalledPlugins::load(&installed_json_path).await {
1196 Ok(store) => store,
1197 Err(error) => {
1198 tracing::warn!(
1199 %error,
1200 "service boot-reconcile: failed to load installed.json; skipping"
1201 );
1202 return;
1203 }
1204 };
1205
1206 let platform = Platform::current().unwrap_or(Platform::Linux);
1207 for plugin in store.list() {
1208 if plugin.registered.service_ids.is_empty() {
1209 continue;
1210 }
1211 let manifest_path = plugin.plugin_dir.join("plugin.json");
1212 let manifest = match fs::read_to_string(&manifest_path)
1213 .await
1214 .ok()
1215 .and_then(|raw| PluginManifest::parse_str(&raw).ok())
1216 {
1217 Some(manifest) => manifest,
1218 None => {
1219 tracing::warn!(
1220 plugin_id = %plugin.id,
1221 path = %manifest_path.display(),
1222 "service boot-reconcile: failed to read/parse plugin.json; skipping this \
1223 plugin's services"
1224 );
1225 continue;
1226 }
1227 };
1228
1229 let owned: HashSet<&str> = plugin
1230 .registered
1231 .service_ids
1232 .iter()
1233 .map(String::as_str)
1234 .collect();
1235 for entry in &manifest.provides.services {
1236 if !entry.enabled || !owned.contains(entry.id.as_str()) {
1237 continue;
1238 }
1239 if service_manager.is_running(&entry.id) {
1240 continue;
1241 }
1242 let config = resolve_service_config_under(
1243 app_data_dir,
1244 &plugin.id,
1245 entry,
1246 &plugin.plugin_dir,
1247 platform,
1248 );
1249 if let Some(parent) = config.user_config_path.parent() {
1250 if let Err(error) = fs::create_dir_all(parent).await {
1251 tracing::warn!(
1252 service_id = %entry.id,
1253 plugin_id = %plugin.id,
1254 %error,
1255 "service boot-reconcile: failed to create service config parent dir"
1256 );
1257 }
1258 }
1259 match service_manager.start_service(config).await {
1260 Ok(()) => tracing::info!(
1261 service_id = %entry.id,
1262 plugin_id = %plugin.id,
1263 "service boot-reconcile: started"
1264 ),
1265 Err(error) => tracing::warn!(
1266 service_id = %entry.id,
1267 plugin_id = %plugin.id,
1268 %error,
1269 "service boot-reconcile: failed to start"
1270 ),
1271 }
1272 }
1273 }
1274}
1275
1276#[cfg(test)]
1277mod tests;