Skip to main content

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 &registered.mcp_server_ids {
403            self.remove_mcp_server(mcp_id).await;
404        }
405        for preset_id in &registered.preset_ids {
406            self.remove_prompt_preset(preset_id).await;
407        }
408        for workflow_filename in &registered.workflow_filenames {
409            self.remove_workflow_file(workflow_filename).await;
410        }
411        for service_id in &registered.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;