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 preset_ids_added: Vec<String>,
204 /// Service ids this install claimed ownership of (whether or not the
205 /// actual `start_service` call succeeded — best-effort).
206 service_ids_added: Vec<String>,
207 /// Subset of `service_ids_added` that actually got a running
208 /// `ServiceManager` runtime started — only these need `stop_service` on
209 /// rollback.
210 service_ids_started: Vec<String>,
211}
212
213impl ServerPluginInstaller {
214 pub fn new(state: actix_web::web::Data<AppState>) -> Self {
215 Self { state }
216 }
217
218 fn plugins_dir(&self) -> PathBuf {
219 self.state.app_data_dir.join("plugins")
220 }
221
222 fn installed_json_path(&self) -> PathBuf {
223 self.plugins_dir().join("installed.json")
224 }
225
226 fn workflows_dir(&self) -> PathBuf {
227 self.state.app_data_dir.join("workflows")
228 }
229
230 fn prompt_presets_path(&self) -> PathBuf {
231 store_file_path(&self.state.app_data_dir)
232 }
233
234 /// `<data_dir>/plugin_service_config/<plugin_id>/config.json` — the
235 /// per-service user config path passed to services as
236 /// `BAMBOO_PLUGIN_SERVICE_CONFIG` (issue #479 open question 2).
237 ///
238 /// Deliberately NOT under `plugins_dir()/<plugin_id>/` (the
239 /// swap-managed `plugin_dir`): `plugin_source::stage_plugin_source`
240 /// upgrades a plugin by renaming the ENTIRE old `plugin_dir` aside and
241 /// swapping a freshly-staged directory into its place — any file living
242 /// inside `plugin_dir` would be swept away with the old bundle on
243 /// upgrade (or deleted outright on uninstall) unless bamboo specifically
244 /// carried it forward, which it does not. A sibling directory, named
245 /// only by `plugin_id`, is untouched by that swap and by
246 /// `uninstall`'s `remove_dir_all(plugin_dir)` — so a service's own
247 /// config (which may carry tokens/secrets a connector needs) survives
248 /// both an upgrade and an uninstall. bamboo only ever creates the PARENT
249 /// directory here (see [`Self::ensure_service_config_parent_dir`]) —
250 /// never writes or deletes `config.json` itself; on uninstall it is
251 /// deliberately left in place (not part of `remove_dir_all`), so a
252 /// later re-install of the same plugin id picks its old config back up
253 /// automatically.
254 fn service_config_path(&self, plugin_id: &str) -> PathBuf {
255 service_config_path_under(&self.state.app_data_dir, plugin_id)
256 }
257
258 async fn ensure_service_config_parent_dir(&self, plugin_id: &str) -> PluginResult<()> {
259 let path = self.service_config_path(plugin_id);
260 if let Some(parent) = path.parent() {
261 fs::create_dir_all(parent).await?;
262 }
263 Ok(())
264 }
265
266 /// Every service id currently owned by any OTHER installed plugin — the
267 /// "existing" side of [`reconcile_exclusive`] for services. There is no
268 /// single shared document analogous to `config.json` for services, so
269 /// provenance itself is the source of truth for "who owns this id".
270 ///
271 /// `exclude_plugin_id` MUST be the id of the plugin currently being
272 /// installed/upgraded and is always excluded — NOT an optional
273 /// nicety: by the time [`Self::register_services`] calls this,
274 /// `install()`'s crash-safety journal write has ALREADY upserted THIS
275 /// plugin's `Installing` row into `installed.json` with its full
276 /// INTENDED `service_ids` (see `install()`'s "Crash-safety journal"
277 /// step, which runs before every registration step). Without excluding
278 /// it, a plain fresh install would see its own not-yet-committed row as
279 /// a foreign owner of its own declared ids and refuse itself.
280 /// `previously_owned` (computed separately, from `previous` BEFORE that
281 /// journal write) is what still correctly classifies an upgrade's
282 /// re-declared ids as `OwnedReinstall` rather than `New`.
283 async fn existing_service_ids(&self, exclude_plugin_id: &str) -> PluginResult<Vec<String>> {
284 let store = InstalledPlugins::load(&self.installed_json_path()).await?;
285 Ok(store
286 .list()
287 .iter()
288 .filter(|plugin| plugin.id != exclude_plugin_id)
289 .flat_map(|plugin| plugin.registered.service_ids.iter().cloned())
290 .collect())
291 }
292
293 /// Resolve one manifest-declared service entry into a
294 /// [`ServiceRuntimeConfig`] ready for `ServiceManager::start_service`.
295 fn resolve_service_config(
296 &self,
297 plugin_id: &str,
298 entry: &ServiceManifestEntry,
299 plugin_dir: &Path,
300 platform: Platform,
301 ) -> ServiceRuntimeConfig {
302 resolve_service_config_under(
303 &self.state.app_data_dir,
304 plugin_id,
305 entry,
306 plugin_dir,
307 platform,
308 )
309 }
310
311 // --- De-registration primitives (shared by upgrade drop-diff, install
312 // rollback, and `uninstall`). Each is individually idempotent/tolerant —
313 // an entry that is already gone (e.g. a user manually deleted it) is
314 // logged and skipped, never a hard failure — matching the requirement
315 // that de-registration never blocks an uninstall/upgrade retry. ---
316
317 async fn remove_mcp_server(&self, id: &str) {
318 let owned_id = id.to_string();
319 let result = self
320 .state
321 .update_config(
322 move |config| {
323 config.mcp.servers.retain(|server| server.id != owned_id);
324 Ok(())
325 },
326 ConfigUpdateEffects {
327 reload_provider: bamboo_config::patch::ReloadMode::None,
328 reconcile_mcp: bamboo_config::patch::ReloadMode::BestEffort,
329 },
330 )
331 .await;
332 if let Err(error) = result {
333 tracing::warn!(
334 mcp_server_id = %id,
335 %error,
336 "failed to remove plugin-owned mcp server from config.json; continuing"
337 );
338 }
339 }
340
341 async fn remove_prompt_preset(&self, preset_id: &str) {
342 let path = self.prompt_presets_path();
343 match load_store(&path).await {
344 Ok(mut store) => {
345 let before = store.prompts.len();
346 store.prompts.retain(|preset| preset.id != preset_id);
347 if store.prompts.len() != before {
348 if let Err(error) = save_store(&path, &store).await {
349 tracing::warn!(
350 %preset_id,
351 %error,
352 "failed to persist prompt-presets.json after removing plugin-owned preset; continuing"
353 );
354 }
355 }
356 }
357 Err(error) => {
358 tracing::warn!(
359 %preset_id,
360 %error,
361 "failed to load prompt-presets.json while removing plugin-owned preset; continuing"
362 );
363 }
364 }
365 }
366
367 async fn remove_service(&self, id: &str) {
368 if let Err(error) = self.state.service_manager.stop_service(id).await {
369 tracing::warn!(
370 service_id = %id,
371 %error,
372 "failed to stop plugin-owned service; continuing"
373 );
374 }
375 }
376
377 async fn remove_workflow_file(&self, filename: &str) {
378 let path = self.workflows_dir().join(filename);
379 match fs::remove_file(&path).await {
380 Ok(()) => {}
381 Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
382 Err(error) => {
383 tracing::warn!(
384 %filename,
385 %error,
386 "failed to remove plugin-owned workflow file; continuing"
387 );
388 }
389 }
390 }
391
392 /// De-register a whole [`RegisteredCapabilities`] set (used for the
393 /// upgrade drop-diff and for `uninstall`). Skill dirs need no shared-store
394 /// action — they are only ever removed by deleting `plugin_dir` itself.
395 async fn deregister_capabilities(&self, registered: &RegisteredCapabilities) {
396 for mcp_id in ®istered.mcp_server_ids {
397 self.remove_mcp_server(mcp_id).await;
398 }
399 for preset_id in ®istered.preset_ids {
400 self.remove_prompt_preset(preset_id).await;
401 }
402 for workflow_filename in ®istered.workflow_filenames {
403 self.remove_workflow_file(workflow_filename).await;
404 }
405 for service_id in ®istered.service_ids {
406 self.remove_service(service_id).await;
407 }
408 }
409
410 /// Best-effort undo of an `install()` that failed partway through steps
411 /// 1-3. See the module docs.
412 async fn rollback_partial_install(&self, rollback: &InstallRollback) {
413 for id in &rollback.mcp_ids_added {
414 self.remove_mcp_server(id).await;
415 }
416 for id in &rollback.preset_ids_added {
417 self.remove_prompt_preset(id).await;
418 }
419 for id in &rollback.service_ids_started {
420 let _ = self.state.service_manager.stop_service(id).await;
421 }
422 }
423
424 /// Upsert one provenance row into `installed.json`. Used both for the
425 /// pre-registration `Installing` journal row and the final `Installed`
426 /// commit — the ONLY two writers of `installed.json` in `install`. Both
427 /// run under [`PLUGIN_OP_LOCK`], so the load/add/save is race-free.
428 async fn upsert_provenance(&self, entry: InstalledPlugin, path: &Path) -> PluginResult<()> {
429 let mut store = InstalledPlugins::load(path).await?;
430 store.add(entry);
431 store.save(path).await?;
432 Ok(())
433 }
434
435 /// Abort an in-process `install` failure: best-effort undo of the partial
436 /// registration (steps 1-3), then restore `installed.json` to its
437 /// pre-install state — re-writing the original `previous` row on an
438 /// upgrade/recovery, or removing the id's row entirely on a fresh install
439 /// — so the `Installing` journal row we wrote up front never lingers after
440 /// a clean in-process failure. (A HARD kill is the only path that
441 /// intentionally leaves an `Installing` row, for the next op to recover.)
442 async fn abort_install(
443 &self,
444 rollback: &InstallRollback,
445 previous: &Option<InstalledPlugin>,
446 plugin_id: &str,
447 path: &Path,
448 ) {
449 self.rollback_partial_install(rollback).await;
450 let restore = match previous {
451 Some(prev) => self.upsert_provenance(prev.clone(), path).await,
452 None => match InstalledPlugins::load(path).await {
453 Ok(mut store) => {
454 store.remove(plugin_id);
455 store.save(path).await
456 }
457 Err(error) => Err(error),
458 },
459 };
460 if let Err(error) = restore {
461 tracing::warn!(
462 %plugin_id,
463 %error,
464 "failed to restore provenance after aborting a failed install; a stale \
465 `installing` row may remain (recoverable by a retry)"
466 );
467 }
468 }
469
470 /// Step 1: MCP. Returns the ids actually (re-)registered
471 /// (`reconciliation.to_register`), which — once past the conflict gate —
472 /// is exactly the declared id set.
473 async fn register_mcp(
474 &self,
475 manifest: &PluginManifest,
476 resolved_mcp_servers: Vec<McpServerConfig>,
477 previously_owned: &[String],
478 rollback: &mut InstallRollback,
479 ) -> PluginResult<Vec<String>> {
480 if resolved_mcp_servers.is_empty() {
481 return Ok(Vec::new());
482 }
483
484 let declared_ids: Vec<String> = manifest
485 .provides
486 .mcp_servers
487 .iter()
488 .map(|entry| entry.id.clone())
489 .collect();
490 let existing_ids: Vec<String> = {
491 let config = self.state.config.read().await;
492 config.mcp.servers.iter().map(|s| s.id.clone()).collect()
493 };
494
495 let reconciliation = reconcile_exclusive(&declared_ids, &existing_ids, previously_owned);
496 if !reconciliation.foreign_conflicts.is_empty() {
497 return Err(PluginError::Conflict {
498 kind: "mcp server",
499 name: reconciliation.foreign_conflicts.join(", "),
500 plugin_id: manifest.id.clone(),
501 });
502 }
503
504 let to_register: HashSet<&str> = reconciliation
505 .to_register
506 .iter()
507 .map(String::as_str)
508 .collect();
509 let configs_to_register: Vec<McpServerConfig> = resolved_mcp_servers
510 .into_iter()
511 .filter(|config| to_register.contains(config.id.as_str()))
512 .collect();
513
514 let owned_configs = configs_to_register.clone();
515 let declared_for_recheck = declared_ids.clone();
516 let owned_for_recheck: Vec<String> = previously_owned.to_vec();
517 let plugin_id_for_recheck = manifest.id.clone();
518 let forced_mcp_replacements = reconciliation.to_register.iter().cloned().collect();
519 self.state
520 .update_config_with_forced_mcp_replacements(
521 move |config| {
522 // TOCTOU guard: re-run the ownership pre-check against the
523 // LIVE config while holding config_io_lock, so a foreign
524 // entry that landed between our earlier read and now can't
525 // be silently clobbered (and then recorded as
526 // plugin-owned, re-opening BLOCKER-1 under a race).
527 // Concurrent PLUGIN ops are already excluded by
528 // PLUGIN_OP_LOCK; this closes the residual window against a
529 // concurrent NON-plugin config write.
530 let live_existing: Vec<String> =
531 config.mcp.servers.iter().map(|s| s.id.clone()).collect();
532 let live = reconcile_exclusive(
533 &declared_for_recheck,
534 &live_existing,
535 &owned_for_recheck,
536 );
537 if !live.foreign_conflicts.is_empty() {
538 return Err(AppError::BadRequest(format!(
539 "mcp server(s) '{}' now conflict with a non-plugin entry (a concurrent \
540 change landed mid-install); refusing to overwrite for plugin '{}'",
541 live.foreign_conflicts.join(", "),
542 plugin_id_for_recheck
543 )));
544 }
545 // Shared by-id merge (same helper import_servers uses).
546 for server in &owned_configs {
547 upsert_server_by_id(&mut config.mcp.servers, server.clone());
548 }
549 Ok(())
550 },
551 ConfigUpdateEffects {
552 reload_provider: bamboo_config::patch::ReloadMode::None,
553 reconcile_mcp: bamboo_config::patch::ReloadMode::BestEffort,
554 },
555 forced_mcp_replacements,
556 )
557 .await
558 .map_err(|error| {
559 PluginError::Registration(format!("failed to write mcp servers to config: {error}"))
560 })?;
561 // The config generation is committed and live before ownership is
562 // claimed. Runtime activation is generation-serialized and reports
563 // degraded MCP health on failure, preserving the installer's existing
564 // best-effort activation contract without an out-of-lock start.
565 rollback.mcp_ids_added = reconciliation.to_register.clone();
566
567 Ok(reconciliation.to_register)
568 }
569
570 /// Step 1b: Services (issue #479, prereq for epic #477). Same
571 /// REFUSE-on-conflict + best-effort-start shape as [`Self::register_mcp`],
572 /// against [`Self::existing_service_ids`] instead of `config.json` (there
573 /// is no shared config document for services — provenance itself is the
574 /// ownership store, see that method's doc comment).
575 async fn register_services(
576 &self,
577 manifest: &PluginManifest,
578 plugin_dir: &Path,
579 previously_owned: &[String],
580 rollback: &mut InstallRollback,
581 ) -> PluginResult<Vec<String>> {
582 if manifest.provides.services.is_empty() {
583 return Ok(Vec::new());
584 }
585
586 let declared_ids: Vec<String> = manifest
587 .provides
588 .services
589 .iter()
590 .map(|entry| entry.id.clone())
591 .collect();
592 let existing_ids = self.existing_service_ids(&manifest.id).await?;
593 let reconciliation = reconcile_exclusive(&declared_ids, &existing_ids, previously_owned);
594 if !reconciliation.foreign_conflicts.is_empty() {
595 return Err(PluginError::Conflict {
596 kind: "service",
597 name: reconciliation.foreign_conflicts.join(", "),
598 plugin_id: manifest.id.clone(),
599 });
600 }
601
602 // Ownership is claimed regardless of individual start outcomes below
603 // (matches `register_mcp`'s "config write succeeded, so record
604 // ownership" contract — here there is no config write, so this is
605 // simply claimed up front).
606 rollback.service_ids_added = reconciliation.to_register.clone();
607
608 self.ensure_service_config_parent_dir(&manifest.id).await?;
609 let platform = Platform::current().unwrap_or(Platform::Linux);
610 let to_register: HashSet<&str> = reconciliation
611 .to_register
612 .iter()
613 .map(String::as_str)
614 .collect();
615
616 for entry in &manifest.provides.services {
617 if !to_register.contains(entry.id.as_str()) {
618 continue;
619 }
620 // Stop any stale running instance first — covers a leftover
621 // from a crashed install/upgrade recovery (a genuine same-id
622 // upgrade already had its old service stopped BEFORE staging by
623 // `stop_services_for_upgrade`, see the module docs' "Same-id
624 // upgrade ordering").
625 let _ = self.state.service_manager.stop_service(&entry.id).await;
626 if !entry.enabled {
627 continue;
628 }
629 let config = self.resolve_service_config(&manifest.id, entry, plugin_dir, platform);
630 match self.state.service_manager.start_service(config).await {
631 Ok(()) => rollback.service_ids_started.push(entry.id.clone()),
632 Err(error) => tracing::warn!(
633 service_id = %entry.id,
634 %error,
635 "plugin-registered service failed to start; ownership kept (best-effort, matches mcp)"
636 ),
637 }
638 }
639
640 Ok(reconciliation.to_register)
641 }
642
643 /// Step 2: Prompts. Rename-on-collision (never refuses) — returns the
644 /// ACTUAL ids used (after any rename), which is what provenance must
645 /// record.
646 async fn register_prompts(&self, manifest: &PluginManifest) -> PluginResult<Vec<String>> {
647 if manifest.provides.prompts.is_empty() {
648 return Ok(Vec::new());
649 }
650
651 let path = self.prompt_presets_path();
652 let mut store = load_store(&path).await.map_err(|error| {
653 PluginError::Registration(format!("failed to load prompt-presets.json: {error}"))
654 })?;
655
656 let mut existing_ids: HashSet<String> = store
657 .prompts
658 .iter()
659 .map(|preset| preset.id.clone())
660 .collect();
661 // `general_assistant` (bamboo-server's DEFAULT_PRESET_ID) is never a
662 // row in the store, so it wouldn't otherwise appear in `existing_ids`
663 // — but manifest validation already rejects any plugin declaring it
664 // (RESERVED_PRESET_IDS), so no extra guard is needed here.
665
666 let mut actual_ids = Vec::with_capacity(manifest.provides.prompts.len());
667 for preset in &manifest.provides.prompts {
668 let actual_id = ensure_unique_preset_id(&preset.id, &existing_ids);
669 store.prompts.push(StoredPromptPreset {
670 id: actual_id.clone(),
671 name: preset.name.clone(),
672 description: preset.description.clone(),
673 content: preset.content.clone(),
674 });
675 existing_ids.insert(actual_id.clone());
676 actual_ids.push(actual_id);
677 }
678
679 save_store(&path, &store).await.map_err(|error| {
680 PluginError::Registration(format!("failed to persist prompt-presets.json: {error}"))
681 })?;
682
683 Ok(actual_ids)
684 }
685
686 /// Step 3: validate legacy plugin workflows for in-place discovery.
687 ///
688 /// Workflow markdown stays under `<plugin_dir>/workflows`; the SkillStore
689 /// discovers it as a read-only legacy adapter. Nothing is copied into the
690 /// shared global workflows directory, so a plugin filename can never
691 /// conflict with or overwrite a user's own legacy workflow.
692 async fn validate_workflows_in_place(
693 &self,
694 manifest: &PluginManifest,
695 plugin_dir: &Path,
696 ) -> PluginResult<()> {
697 if manifest.provides.workflows.is_empty() {
698 return Ok(());
699 }
700
701 let workflows_dir = plugin_dir.join("workflows");
702 let directory_metadata = fs::symlink_metadata(&workflows_dir).await?;
703 if !directory_metadata.is_dir() || directory_metadata.file_type().is_symlink() {
704 return Err(PluginError::InvalidManifest(
705 "plugin workflows must live in a real workflows directory".to_string(),
706 ));
707 }
708 let declared: HashSet<&str> = manifest
709 .provides
710 .workflows
711 .iter()
712 .map(String::as_str)
713 .collect();
714 let mut actual = HashSet::new();
715 let mut entries = fs::read_dir(&workflows_dir).await?;
716 while let Some(entry) = entries.next_entry().await? {
717 let path = entry.path();
718 if path.extension().and_then(|value| value.to_str()) != Some("md") {
719 continue;
720 }
721 let file_type = entry.file_type().await?;
722 let Some(filename) = entry.file_name().to_str().map(str::to_string) else {
723 return Err(PluginError::InvalidManifest(
724 "plugin workflow filename must be UTF-8".to_string(),
725 ));
726 };
727 if !file_type.is_file() || file_type.is_symlink() {
728 return Err(PluginError::InvalidManifest(format!(
729 "workflow '{filename}' must be a regular in-place markdown file"
730 )));
731 }
732 actual.insert(filename);
733 }
734 if actual.len() != declared.len()
735 || !actual.iter().all(|name| declared.contains(name.as_str()))
736 {
737 return Err(PluginError::InvalidManifest(
738 "provides.workflows must declare every workflows/*.md file exactly once"
739 .to_string(),
740 ));
741 }
742
743 for filename in &manifest.provides.workflows {
744 let stem = filename.strip_suffix(".md").unwrap_or(filename);
745 if !bamboo_config::paths::is_safe_workflow_name(stem) {
746 return Err(PluginError::InvalidManifest(format!(
747 "workflow filename '{filename}' is not a safe workflow name"
748 )));
749 }
750 let source_path = workflows_dir.join(filename);
751 let metadata = fs::symlink_metadata(&source_path).await?;
752 if !metadata.file_type().is_file() || metadata.file_type().is_symlink() {
753 return Err(PluginError::InvalidManifest(format!(
754 "workflow '{filename}' must be a regular in-place markdown file"
755 )));
756 }
757 }
758 Ok(())
759 }
760
761 /// **Same-id upgrade ordering** (issue #479 "Install-flow deltas" /
762 /// "Same-id upgrade ordering bug risk"): `plugin_source::stage_plugin_source`
763 /// swaps `plugin_dir`'s ENTIRE contents (old bundle moved to a
764 /// `.backup-*` dir, staged bundle renamed into place) BEFORE
765 /// `install()` — and therefore [`Self::register_services`] — ever runs.
766 /// A still-running old service process holding the old binary open
767 /// during that swap is at best running stale code post-swap and at
768 /// worst (Windows) blocks the rename outright. The minimal seam that
769 /// fixes the ordering without restructuring `stage_plugin_source`/
770 /// `install()`: the HTTP `update_plugin` handler calls this BEFORE
771 /// `stage_plugin_source`, using the URL path's target id (known up
772 /// front for an upgrade, unlike a fresh `install`) to look up the
773 /// CURRENTLY-installed row's `registered.service_ids` and stop each one.
774 /// Net effect: stop (old binary) → swap (new binary) → start (new
775 /// binary, via `register_services` inside `install()`), exactly the
776 /// sequencing the issue calls for.
777 ///
778 /// Best-effort: returns exactly the ids that were actually running and
779 /// got stopped (not e.g. an already-stopped or unknown id), so a
780 /// subsequently-failed upgrade can restart precisely those — see
781 /// [`Self::restart_services_after_failed_upgrade`]. A plugin with no
782 /// prior install (or no services) returns an empty vec and stops
783 /// nothing.
784 pub async fn stop_services_for_upgrade(&self, plugin_id: &str) -> Vec<String> {
785 let store = match InstalledPlugins::load(&self.installed_json_path()).await {
786 Ok(store) => store,
787 Err(error) => {
788 tracing::warn!(
789 %plugin_id,
790 %error,
791 "stop_services_for_upgrade: failed to load installed.json; skipping"
792 );
793 return Vec::new();
794 }
795 };
796 let Some(entry) = store.get(plugin_id) else {
797 return Vec::new();
798 };
799 let mut stopped = Vec::with_capacity(entry.registered.service_ids.len());
800 for service_id in &entry.registered.service_ids {
801 match self.state.service_manager.stop_service(service_id).await {
802 Ok(()) => stopped.push(service_id.clone()),
803 Err(error) => tracing::debug!(
804 service_id = %service_id,
805 %error,
806 "stop_services_for_upgrade: service was not running; nothing to stop"
807 ),
808 }
809 }
810 stopped
811 }
812
813 /// Counterpart to [`Self::stop_services_for_upgrade`]: called after a
814 /// FAILED upgrade whose `StagedPlugin::rollback()` already restored
815 /// `plugin_dir` to the pre-upgrade bundle's bytes — re-reads that
816 /// (now-restored) OLD `plugin.json` and restarts exactly the services in
817 /// `stopped` that it still declares as `enabled`. Best-effort/
818 /// log-and-continue: a failure here leaves the affected service stopped
819 /// (a degraded-but-safe outcome — never silently double-runs an old and
820 /// a new instance) rather than panicking the request.
821 pub async fn restart_services_after_failed_upgrade(&self, plugin_id: &str, stopped: &[String]) {
822 if stopped.is_empty() {
823 return;
824 }
825 let store = match InstalledPlugins::load(&self.installed_json_path()).await {
826 Ok(store) => store,
827 Err(error) => {
828 tracing::warn!(
829 %plugin_id,
830 %error,
831 "restart_services_after_failed_upgrade: failed to load installed.json"
832 );
833 return;
834 }
835 };
836 let Some(entry) = store.get(plugin_id) else {
837 return;
838 };
839 let manifest_path = entry.plugin_dir.join("plugin.json");
840 let manifest = match fs::read_to_string(&manifest_path)
841 .await
842 .ok()
843 .and_then(|raw| PluginManifest::parse_str(&raw).ok())
844 {
845 Some(manifest) => manifest,
846 None => {
847 tracing::warn!(
848 %plugin_id,
849 path = %manifest_path.display(),
850 "restart_services_after_failed_upgrade: failed to read/parse the \
851 rolled-back plugin.json; affected service(s) remain stopped"
852 );
853 return;
854 }
855 };
856 let platform = Platform::current().unwrap_or(Platform::Linux);
857 for svc in &manifest.provides.services {
858 if !stopped.contains(&svc.id) || !svc.enabled {
859 continue;
860 }
861 let config = self.resolve_service_config(plugin_id, svc, &entry.plugin_dir, platform);
862 if let Err(error) = self.state.service_manager.start_service(config).await {
863 tracing::warn!(
864 service_id = %svc.id,
865 %plugin_id,
866 %error,
867 "failed to restart service after a failed upgrade rolled back to the \
868 previous plugin bundle; service remains stopped"
869 );
870 }
871 }
872 }
873}
874
875#[async_trait]
876impl PluginInstaller for ServerPluginInstaller {
877 async fn install(
878 &self,
879 manifest: &PluginManifest,
880 plugin_dir: &Path,
881 source: PluginSource,
882 disposition: InstallDisposition,
883 installed_at: DateTime<Utc>,
884 ) -> PluginResult<InstalledPlugin> {
885 // Serialize the whole op against every other plugin install/uninstall
886 // (process-wide) — held across all steps AND rollback. See module docs
887 // "Concurrency".
888 let _op_guard = PLUGIN_OP_LOCK.lock().await;
889
890 let installed_json_path = self.installed_json_path();
891
892 // Disposition gate (AlreadyInstalled only for a COMPLETED prior
893 // install; an `Installing` leftover is returned for recovery) + the
894 // rest of the pure, AppState-free validation this crate can already
895 // do (manifest shape, platform gate, on-disk skill/workflow
896 // existence, `provides.skills` authoritativeness).
897 let previous =
898 load_previous_for_disposition(&installed_json_path, &manifest.id, disposition).await?;
899 let resolved_mcp_servers = preflight_install(manifest, plugin_dir).await?;
900
901 // The set this install INTENDS to own, by declaration order. Used both
902 // for the crash-safety journal row (below) and the step-0 drop-diff.
903 let intended = RegisteredCapabilities {
904 mcp_server_ids: manifest
905 .provides
906 .mcp_servers
907 .iter()
908 .map(|entry| entry.id.clone())
909 .collect(),
910 skill_dirs: manifest.provides.skills.clone(),
911 preset_ids: manifest
912 .provides
913 .prompts
914 .iter()
915 .map(|preset| preset.id.clone())
916 .collect(),
917 // New workflow publications are discovered in place. Keeping this
918 // legacy copied-file field empty also makes an upgrade clean up
919 // files copied by pre-#561 installs through the drop-diff below.
920 workflow_filenames: Vec::new(),
921 service_ids: manifest
922 .provides
923 .services
924 .iter()
925 .map(|entry| entry.id.clone())
926 .collect(),
927 };
928
929 // Step 0: upgrade drop-diff. Computed from the NEW manifest's plain
930 // declared ids (see module docs re: the preset-rename caveat) vs the
931 // OLD install's registered set — de-register whatever the new version
932 // no longer declares BEFORE registering anything new (BLOCKER 2). Also
933 // fires for a recovery over an `Installing` leftover: its intended set
934 // is diffed the same way, so a crashed attempt's extra ids get cleaned.
935 if let Some(previous) = &previous {
936 let dropped = intended.removed_since(&previous.registered);
937 if !dropped.is_empty() {
938 tracing::info!(
939 plugin_id = %manifest.id,
940 recovering = previous.status == PluginInstallStatus::Installing,
941 dropped_mcp = ?dropped.mcp_server_ids,
942 dropped_presets = ?dropped.preset_ids,
943 dropped_workflows = ?dropped.workflow_filenames,
944 dropped_services = ?dropped.service_ids,
945 "install drop-diff: de-registering capabilities the new/completed version no longer declares"
946 );
947 self.deregister_capabilities(&dropped).await;
948 }
949 }
950
951 let previously_owned_mcp = previous
952 .as_ref()
953 .map(|p| p.registered.mcp_server_ids.clone())
954 .unwrap_or_default();
955 let previously_owned_services = previous
956 .as_ref()
957 .map(|p| p.registered.service_ids.clone())
958 .unwrap_or_default();
959
960 // Crash-safety journal: write an `Installing` provenance row recording
961 // the INTENDED ownership set BEFORE mutating any shared store, so a
962 // hard kill mid-install leaves a recoverable marker (see module docs
963 // "Crash safety"). On a fresh install this creates the row; on an
964 // upgrade/recovery it overwrites the prior row.
965 self.upsert_provenance(
966 InstalledPlugin {
967 id: manifest.id.clone(),
968 version: manifest.version.clone(),
969 source: source.clone(),
970 plugin_dir: plugin_dir.to_path_buf(),
971 installed_at,
972 status: PluginInstallStatus::Installing,
973 registered: intended.clone(),
974 },
975 &installed_json_path,
976 )
977 .await?;
978
979 let mut rollback = InstallRollback::default();
980
981 // Step 1: MCP.
982 let mcp_server_ids = match self
983 .register_mcp(
984 manifest,
985 resolved_mcp_servers,
986 &previously_owned_mcp,
987 &mut rollback,
988 )
989 .await
990 {
991 Ok(ids) => ids,
992 Err(error) => {
993 self.abort_install(&rollback, &previous, &manifest.id, &installed_json_path)
994 .await;
995 return Err(error);
996 }
997 };
998
999 // Step 1b: Services (issue #479). Runs right after MCP, before the
1000 // never-refusing Prompts step, so a services conflict fails the
1001 // install as early as the other REFUSE-on-conflict kinds do. Note:
1002 // for a same-id UPGRADE, the OLD service (if any) was already
1003 // stopped by `stop_services_for_upgrade` before `stage_plugin_source`
1004 // swapped `plugin_dir` — see that method's doc comment on the
1005 // stop→swap→start sequencing.
1006 let service_ids = match self
1007 .register_services(
1008 manifest,
1009 plugin_dir,
1010 &previously_owned_services,
1011 &mut rollback,
1012 )
1013 .await
1014 {
1015 Ok(ids) => ids,
1016 Err(error) => {
1017 self.abort_install(&rollback, &previous, &manifest.id, &installed_json_path)
1018 .await;
1019 return Err(error);
1020 }
1021 };
1022
1023 // Step 2: Prompts.
1024 let preset_ids = match self.register_prompts(manifest).await {
1025 Ok(ids) => {
1026 rollback.preset_ids_added = ids.clone();
1027 ids
1028 }
1029 Err(error) => {
1030 self.abort_install(&rollback, &previous, &manifest.id, &installed_json_path)
1031 .await;
1032 return Err(error);
1033 }
1034 };
1035
1036 // Step 3: Workflows remain inside the plugin bundle and are discovered
1037 // by the shared SkillStore as read-only legacy adapters.
1038 let workflow_filenames = match self.validate_workflows_in_place(manifest, plugin_dir).await
1039 {
1040 Ok(()) => Vec::new(),
1041 Err(error) => {
1042 self.abort_install(&rollback, &previous, &manifest.id, &installed_json_path)
1043 .await;
1044 return Err(error);
1045 }
1046 };
1047
1048 // Step 4: Skills — nothing to register, just record the
1049 // declared+validated dir names (preflight_install already confirmed
1050 // every declared dir exists and that no undeclared dir is present).
1051 let skill_dirs = manifest.provides.skills.clone();
1052
1053 // Step 5: commit provenance — flip the journal row to `Installed` with
1054 // the ACTUAL registered set (renamed preset ids, the to_register mcp/
1055 // workflow subsets). Only reached once 0-4 all succeeded.
1056 let registered = RegisteredCapabilities {
1057 mcp_server_ids,
1058 skill_dirs,
1059 preset_ids,
1060 workflow_filenames,
1061 service_ids,
1062 };
1063 let entry = InstalledPlugin {
1064 id: manifest.id.clone(),
1065 version: manifest.version.clone(),
1066 source,
1067 plugin_dir: plugin_dir.to_path_buf(),
1068 installed_at,
1069 status: PluginInstallStatus::Installed,
1070 registered,
1071 };
1072 self.upsert_provenance(entry.clone(), &installed_json_path)
1073 .await?;
1074
1075 Ok(entry)
1076 }
1077
1078 async fn uninstall(&self, id: &str) -> PluginResult<()> {
1079 // Serialize against every other plugin op (see module docs).
1080 let _op_guard = PLUGIN_OP_LOCK.lock().await;
1081
1082 let installed_json_path = self.installed_json_path();
1083 let mut store = InstalledPlugins::load(&installed_json_path).await?;
1084 // Works on an `Installing` (crash-leftover) row too, so a crashed
1085 // install is never un-uninstallable.
1086 let Some(entry) = store.get(id).cloned() else {
1087 return Err(PluginError::NotFound(id.to_string()));
1088 };
1089
1090 // De-register everything this plugin's `registered` set names — by
1091 // construction (see bamboo-plugin's ownership contract) this can
1092 // only ever be entries the plugin itself created. Idempotent: a
1093 // manually-removed entry is logged and skipped, never a hard error.
1094 self.deregister_capabilities(&entry.registered).await;
1095
1096 // Remove the plugin's own files BEFORE clearing provenance: if this
1097 // fails (e.g. a permission error), provenance is left intact so a
1098 // retry is safe (the de-registration above is idempotent, so
1099 // re-running it is a harmless no-op) rather than leaving an
1100 // unregistered-but-still-on-disk `skills/` dir that discovery would
1101 // keep picking up despite `uninstall` having "succeeded".
1102 match fs::remove_dir_all(&entry.plugin_dir).await {
1103 Ok(()) => {}
1104 Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
1105 Err(error) => return Err(PluginError::Io(error)),
1106 }
1107
1108 store.remove(id);
1109 store.save(&installed_json_path).await?;
1110 Ok(())
1111 }
1112
1113 async fn list(&self) -> PluginResult<Vec<InstalledPlugin>> {
1114 let store = InstalledPlugins::load(&self.installed_json_path()).await?;
1115 Ok(store.plugins)
1116 }
1117}
1118
1119// ---------------------------------------------------------------------
1120// Free helpers shared between `ServerPluginInstaller` (instance methods
1121// above, which delegate here) and `boot_reconcile_services` (which has no
1122// `ServerPluginInstaller`/`AppState` handle to call instance methods on —
1123// see `app_state::builder`, which calls it before `AppState` finishes
1124// constructing).
1125// ---------------------------------------------------------------------
1126
1127/// See `ServerPluginInstaller::service_config_path`'s doc comment for the
1128/// full rationale (kept there since that's the reader's first encounter).
1129fn service_config_path_under(app_data_dir: &Path, plugin_id: &str) -> PathBuf {
1130 app_data_dir
1131 .join("plugin_service_config")
1132 .join(plugin_id)
1133 .join("config.json")
1134}
1135
1136fn resolve_service_config_under(
1137 app_data_dir: &Path,
1138 plugin_id: &str,
1139 entry: &ServiceManifestEntry,
1140 plugin_dir: &Path,
1141 platform: Platform,
1142) -> ServiceRuntimeConfig {
1143 let resolved = entry.resolve(plugin_dir, plugin_id, platform);
1144 ServiceRuntimeConfig {
1145 id: resolved.id,
1146 plugin_id: plugin_id.to_string(),
1147 name: resolved.name,
1148 command: resolved.command,
1149 args: resolved.args,
1150 cwd: resolved.cwd,
1151 env: resolved.env,
1152 health_check: resolved.health_check,
1153 restart_policy: resolved.restart_policy,
1154 graceful_shutdown: resolved.graceful_shutdown,
1155 user_config_path: service_config_path_under(app_data_dir, plugin_id),
1156 }
1157}
1158
1159/// Boot-time reconcile (issue #479): start every ENABLED, plugin-owned
1160/// service that `installed.json` says should be running but has no live
1161/// [`ServiceManager`] runtime — the previous `bamboo serve` process (if any)
1162/// died along with every service it supervised (child processes are spawned
1163/// `kill_on_drop`, and nothing about a running service persists
1164/// cross-process). Called from `app_state::builder` the same way
1165/// `app_state::init::init_mcp_manager` kicks off its background MCP
1166/// bootstrap — the caller is expected to `tokio::spawn` this, NOT await it
1167/// inline, so server startup is never blocked on plugin service spawns.
1168///
1169/// Deliberately reads `installed.json` + each plugin's on-disk
1170/// `plugin.json` directly rather than going through `ServerPluginInstaller`
1171/// (which needs a fully-built `web::Data<AppState>` this runs before).
1172pub async fn boot_reconcile_services(app_data_dir: &Path, service_manager: &ServiceManager) {
1173 let installed_json_path = app_data_dir.join("plugins").join("installed.json");
1174 let store = match InstalledPlugins::load(&installed_json_path).await {
1175 Ok(store) => store,
1176 Err(error) => {
1177 tracing::warn!(
1178 %error,
1179 "service boot-reconcile: failed to load installed.json; skipping"
1180 );
1181 return;
1182 }
1183 };
1184
1185 let platform = Platform::current().unwrap_or(Platform::Linux);
1186 for plugin in store.list() {
1187 if plugin.registered.service_ids.is_empty() {
1188 continue;
1189 }
1190 let manifest_path = plugin.plugin_dir.join("plugin.json");
1191 let manifest = match fs::read_to_string(&manifest_path)
1192 .await
1193 .ok()
1194 .and_then(|raw| PluginManifest::parse_str(&raw).ok())
1195 {
1196 Some(manifest) => manifest,
1197 None => {
1198 tracing::warn!(
1199 plugin_id = %plugin.id,
1200 path = %manifest_path.display(),
1201 "service boot-reconcile: failed to read/parse plugin.json; skipping this \
1202 plugin's services"
1203 );
1204 continue;
1205 }
1206 };
1207
1208 let owned: HashSet<&str> = plugin
1209 .registered
1210 .service_ids
1211 .iter()
1212 .map(String::as_str)
1213 .collect();
1214 for entry in &manifest.provides.services {
1215 if !entry.enabled || !owned.contains(entry.id.as_str()) {
1216 continue;
1217 }
1218 if service_manager.is_running(&entry.id) {
1219 continue;
1220 }
1221 let config = resolve_service_config_under(
1222 app_data_dir,
1223 &plugin.id,
1224 entry,
1225 &plugin.plugin_dir,
1226 platform,
1227 );
1228 if let Some(parent) = config.user_config_path.parent() {
1229 if let Err(error) = fs::create_dir_all(parent).await {
1230 tracing::warn!(
1231 service_id = %entry.id,
1232 plugin_id = %plugin.id,
1233 %error,
1234 "service boot-reconcile: failed to create service config parent dir"
1235 );
1236 }
1237 }
1238 match service_manager.start_service(config).await {
1239 Ok(()) => tracing::info!(
1240 service_id = %entry.id,
1241 plugin_id = %plugin.id,
1242 "service boot-reconcile: started"
1243 ),
1244 Err(error) => tracing::warn!(
1245 service_id = %entry.id,
1246 plugin_id = %plugin.id,
1247 %error,
1248 "service boot-reconcile: failed to start"
1249 ),
1250 }
1251 }
1252 }
1253}
1254
1255#[cfg(test)]
1256mod tests;