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
9//! `workflows_dir()`. 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//! Narrower, accepted gap: [`ServerPluginInstaller::register_workflows`] does
56//! NOT get the equivalent live re-check that MCP's step does — there is no
57//! `workflows_io_lock` analogous to `config_io_lock` to re-run
58//! `reconcile_exclusive` inside, because workflow files are plain files under
59//! `workflows_dir()`, not a single locked/rewritten document like
60//! `config.json`. So a concurrent NON-plugin write of a same-named workflow
61//! file landing in the window between `existing_workflow_filenames()` and the
62//! actual `fs::write` below could still be clobbered (and then recorded as
63//! plugin-owned, which a later uninstall would incorrectly delete). Deferred:
64//! this is the same class of race the MCP re-check closes, just for a store
65//! that has no equivalent lock to hook into yet.
66//!
67//! # Crash safety (process killed mid-install)
68//!
69//! In-process rollback (below) only fires on an `Err`. A HARD kill after the
70//! MCP step wrote to `config.json` but before provenance is committed would,
71//! without a journal, leave: `reconcile_exclusive` seeing the orphaned mcp id
72//! as existing-but-not-owned → a false `Conflict` on the retry, AND
73//! `uninstall` returning `NotFound` (no provenance) → the user stuck
74//! hand-editing `config.json`. To prevent that, `install` writes a provenance
75//! row with status [`PluginInstallStatus::Installing`] — recording the
76//! INTENDED ownership set — BEFORE steps 1-4, and flips it to
77//! [`PluginInstallStatus::Installed`] only after step 5 succeeds. On the next
78//! install/upgrade of an id whose row is still `Installing` (a prior crash),
79//! [`load_previous_for_disposition`] returns it as `previous` (it does NOT
80//! trip `AlreadyInstalled`), so its intended set is treated as
81//! this-plugin-owned — the leftover reads as an `OwnedReinstall`, not a
82//! foreign conflict — and is cleaned up as an upgrade-over-incomplete.
83//! `uninstall` works on an `Installing` row too.
84//!
85//! # Atomicity / rollback semantics
86//!
87//! `install()` follows `PLUGIN_PLAN.md`'s numbered sequence exactly:
88//!
89//! 0. **Upgrade drop-diff** (only when upgrading an already-installed id):
90//!    de-register whatever the new manifest no longer declares, computed via
91//!    [`bamboo_plugin::registry::RegisteredCapabilities::removed_since`],
92//!    BEFORE registering anything new. De-registration is idempotent/
93//!    best-effort (see [`ServerPluginInstaller::deregister_capabilities`]) —
94//!    an entry a user already removed by hand never blocks an upgrade.
95//! 1. **MCP** — ownership-checked (REFUSE on a foreign conflict, via
96//!    [`bamboo_plugin::registry::reconcile_exclusive`]), merged into
97//!    `config.json`, started.
98//! 2. **Prompts** — rename-on-collision (never refuse), appended to
99//!    `prompt-presets.json`.
100//! 3. **Workflows** — ownership-checked exactly like MCP, copied into
101//!    `workflows_dir()`.
102//! 4. **Skills** — nothing to register (discovered in place); just recorded.
103//! 5. **Provenance commit** — `installed.json` is only ever upserted after
104//!    steps 0-4 all succeed.
105//!
106//! Steps 1-3 are real, sequential mutations (config write, then file
107//! writes) — NOT a dry-run computed up front — because `PLUGIN_PLAN.md`
108//! requires the ownership pre-checks to run in that exact order against the
109//! LIVE state each step leaves behind. That means a HARD failure at step 2
110//! or 3 (e.g. an MCP conflict is already past, but the workflow conflict
111//! check at step 3 fails) can happen after step 1 already wrote real
112//! entries into `config.json`. [`ServerPluginInstaller::install`] tracks
113//! every already-applied mutation in an [`InstallRollback`] and, on any hard
114//! failure from steps 1-3, best-effort UNDOES them (removes the mcp entries
115//! it just added and stops any it started, removes the presets it just
116//! appended, deletes the workflow files it just copied) before returning the
117//! error — so a caller's retry starts from a clean slate. Provenance is
118//! never written on a failed path (step 5 is the only place `installed.json`
119//! is touched on success), which is the minimum safety bar even if a rollback
120//! step itself only partially succeeds (rollback operations are themselves
121//! idempotent/log-and-continue, so a second rollback attempt via a plain
122//! retry can never fail louder than the first).
123//!
124//! One known, accepted gap: `stage_plugin_source`/`install_plugin_from_source`
125//! in [`crate::plugin_source`] additionally guard the ON-DISK `plugin_dir`
126//! swap itself (an upgrade's new bundle replaces the old one's files at a
127//! fixed path) by moving the previous bundle aside instead of deleting it, and
128//! restoring it if `install()` subsequently fails — see that module's docs.
129//!
130//! Prompt-preset drop-diff caveat: the upgrade drop-diff compares the NEW
131//! manifest's nominal preset ids against the OLD install's ACTUAL (possibly
132//! renamed-on-collision) registered ids. A preset that got renamed at its
133//! original install time and is still declared under its original nominal id
134//! in the new manifest will look "dropped" (the nominal id is absent from the
135//! actual old set) and get re-appended (possibly renamed again). This is
136//! harmless — preset content is just refreshed under a fresh id — and not
137//! worth a stable-id-mapping schema change for what `RegisteredCapabilities`
138//! already documents as the one rename (not refuse) exception.
139
140use std::collections::HashSet;
141use std::path::{Path, PathBuf};
142
143use async_trait::async_trait;
144use chrono::{DateTime, Utc};
145use tokio::fs;
146
147use bamboo_domain::mcp_config::McpServerConfig;
148use bamboo_plugin::installer::{load_previous_for_disposition, preflight_install};
149use bamboo_plugin::registry::{reconcile_exclusive, RegisteredCapabilities};
150use bamboo_plugin::{
151    InstallDisposition, InstalledPlugin, InstalledPlugins, PluginError, PluginInstallStatus,
152    PluginInstaller, PluginManifest, PluginResult, PluginSource,
153};
154
155use crate::app_state::{AppState, ConfigUpdateEffects};
156use crate::error::AppError;
157use crate::handlers::agent::mcp::upsert_server_by_id;
158use crate::handlers::agent::prompt_presets::{
159    ensure_unique_preset_id, load_store, save_store, store_file_path, StoredPromptPreset,
160};
161
162/// Process-wide serialization of plugin install/uninstall operations.
163///
164/// The whole ownership/upgrade machinery is a read-modify-write over shared
165/// stores (`config.json`, `prompt-presets.json`, `installed.json`) with the
166/// ownership pre-check and the eventual mutation in separate steps. Under
167/// CONCURRENT plugin ops (the HTTP agent will expose exactly that) those
168/// interleave badly: two installs of different ids race `installed.json`'s
169/// load/add/save (last save drops the other's row), `prompt-presets.json`'s
170/// load/save (lost update), and the MCP reconcile→write window (a foreign
171/// entry landing mid-window gets clobbered AND recorded as plugin-owned,
172/// re-opening BLOCKER-1). Plugin installs are rare and not perf-sensitive, so
173/// one coarse process-wide lock held across the ENTIRE `install`/`uninstall`
174/// (including rollback) is the right call — it makes each op's
175/// reconcile→mutate→provenance sequence atomic w.r.t. every other plugin op.
176///
177/// Lock ordering: this lock is acquired at the TOP of `install`/`uninstall`,
178/// OUTSIDE any `AppState::update_config` call (which internally takes
179/// `config_io_lock`). So the order is always `PLUGIN_OP_LOCK` →
180/// `config_io_lock`, never the reverse — no deadlock. Nothing acquires
181/// `PLUGIN_OP_LOCK` while holding `config_io_lock`.
182///
183/// # Single-process assumption (deferred: no cross-process lock)
184///
185/// This is a `tokio::sync::Mutex` — IN-PROCESS only. It serializes plugin ops
186/// within one `bamboo serve` process, but two SEPARATE `bamboo serve`
187/// processes pointed at the same `~/.bamboo` data dir would each get their
188/// own independent `PLUGIN_OP_LOCK` and could race each other's
189/// reconcile→mutate→provenance sequence exactly the way this lock exists to
190/// prevent for concurrent ops WITHIN one process. The plugin system assumes
191/// the normal deployment: a SINGLE `bamboo serve` per data directory. True
192/// multi-process safety would need an OS-level file lock (e.g. `flock` on a
193/// lockfile under `plugins_dir()`) instead of/in addition to this `Mutex`;
194/// that's a documented follow-up, not implemented here.
195static PLUGIN_OP_LOCK: tokio::sync::Mutex<()> = tokio::sync::Mutex::const_new(());
196
197/// AppState-backed [`PluginInstaller`]. See the module docs for the full
198/// design rationale (borrowing, path derivation, atomicity).
199pub struct ServerPluginInstaller {
200    state: actix_web::web::Data<AppState>,
201}
202
203/// Mutations already applied by a not-yet-committed `install()`, so a hard
204/// failure partway through steps 1-3 can best-effort undo exactly what has
205/// been done so far. See the module docs' "Atomicity / rollback semantics".
206#[derive(Default)]
207struct InstallRollback {
208    mcp_ids_added: Vec<String>,
209    mcp_ids_started: Vec<String>,
210    preset_ids_added: Vec<String>,
211    workflow_files_added: Vec<String>,
212}
213
214impl ServerPluginInstaller {
215    pub fn new(state: actix_web::web::Data<AppState>) -> Self {
216        Self { state }
217    }
218
219    fn plugins_dir(&self) -> PathBuf {
220        self.state.app_data_dir.join("plugins")
221    }
222
223    fn installed_json_path(&self) -> PathBuf {
224        self.plugins_dir().join("installed.json")
225    }
226
227    fn workflows_dir(&self) -> PathBuf {
228        self.state.app_data_dir.join("workflows")
229    }
230
231    fn prompt_presets_path(&self) -> PathBuf {
232        store_file_path(&self.state.app_data_dir)
233    }
234
235    /// Every `.md` filename directly under `workflows_dir()` (created if
236    /// missing). Mirrors `handlers::settings::workflows::list_workflows`'s
237    /// listing logic but returns bare filenames for
238    /// [`bamboo_plugin::registry::reconcile_exclusive`].
239    async fn existing_workflow_filenames(&self) -> PluginResult<Vec<String>> {
240        let dir = self.workflows_dir();
241        fs::create_dir_all(&dir).await?;
242        let mut entries = fs::read_dir(&dir).await?;
243        let mut names = Vec::new();
244        while let Some(entry) = entries.next_entry().await? {
245            let is_file = entry
246                .file_type()
247                .await
248                .map(|file_type| file_type.is_file())
249                .unwrap_or(false);
250            if !is_file {
251                continue;
252            }
253            if let Some(name) = entry.file_name().to_str() {
254                if name.ends_with(".md") {
255                    names.push(name.to_string());
256                }
257            }
258        }
259        Ok(names)
260    }
261
262    // --- De-registration primitives (shared by upgrade drop-diff, install
263    // rollback, and `uninstall`). Each is individually idempotent/tolerant —
264    // an entry that is already gone (e.g. a user manually deleted it) is
265    // logged and skipped, never a hard failure — matching the requirement
266    // that de-registration never blocks an uninstall/upgrade retry. ---
267
268    async fn remove_mcp_server(&self, id: &str) {
269        let owned_id = id.to_string();
270        let result = self
271            .state
272            .update_config(
273                move |cfg| {
274                    cfg.mcp.servers.retain(|server| server.id != owned_id);
275                    Ok(())
276                },
277                ConfigUpdateEffects::default(),
278            )
279            .await;
280        if let Err(error) = result {
281            tracing::warn!(
282                mcp_server_id = %id,
283                %error,
284                "failed to remove plugin-owned mcp server from config.json; continuing"
285            );
286        }
287        if let Err(error) = self.state.mcp_manager.stop_server(id).await {
288            tracing::warn!(
289                mcp_server_id = %id,
290                %error,
291                "failed to stop plugin-owned mcp server; continuing"
292            );
293        }
294    }
295
296    async fn remove_prompt_preset(&self, preset_id: &str) {
297        let path = self.prompt_presets_path();
298        match load_store(&path).await {
299            Ok(mut store) => {
300                let before = store.prompts.len();
301                store.prompts.retain(|preset| preset.id != preset_id);
302                if store.prompts.len() != before {
303                    if let Err(error) = save_store(&path, &store).await {
304                        tracing::warn!(
305                            %preset_id,
306                            %error,
307                            "failed to persist prompt-presets.json after removing plugin-owned preset; continuing"
308                        );
309                    }
310                }
311            }
312            Err(error) => {
313                tracing::warn!(
314                    %preset_id,
315                    %error,
316                    "failed to load prompt-presets.json while removing plugin-owned preset; continuing"
317                );
318            }
319        }
320    }
321
322    async fn remove_workflow_file(&self, filename: &str) {
323        let path = self.workflows_dir().join(filename);
324        match fs::remove_file(&path).await {
325            Ok(()) => {}
326            Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
327            Err(error) => {
328                tracing::warn!(
329                    %filename,
330                    %error,
331                    "failed to remove plugin-owned workflow file; continuing"
332                );
333            }
334        }
335    }
336
337    /// De-register a whole [`RegisteredCapabilities`] set (used for the
338    /// upgrade drop-diff and for `uninstall`). Skill dirs need no shared-store
339    /// action — they are only ever removed by deleting `plugin_dir` itself.
340    async fn deregister_capabilities(&self, registered: &RegisteredCapabilities) {
341        for mcp_id in &registered.mcp_server_ids {
342            self.remove_mcp_server(mcp_id).await;
343        }
344        for preset_id in &registered.preset_ids {
345            self.remove_prompt_preset(preset_id).await;
346        }
347        for workflow_filename in &registered.workflow_filenames {
348            self.remove_workflow_file(workflow_filename).await;
349        }
350    }
351
352    /// Best-effort undo of an `install()` that failed partway through steps
353    /// 1-3. See the module docs.
354    async fn rollback_partial_install(&self, rollback: &InstallRollback) {
355        for id in &rollback.mcp_ids_started {
356            let _ = self.state.mcp_manager.stop_server(id).await;
357        }
358        for id in &rollback.mcp_ids_added {
359            self.remove_mcp_server(id).await;
360        }
361        for id in &rollback.preset_ids_added {
362            self.remove_prompt_preset(id).await;
363        }
364        for file in &rollback.workflow_files_added {
365            self.remove_workflow_file(file).await;
366        }
367    }
368
369    /// Upsert one provenance row into `installed.json`. Used both for the
370    /// pre-registration `Installing` journal row and the final `Installed`
371    /// commit — the ONLY two writers of `installed.json` in `install`. Both
372    /// run under [`PLUGIN_OP_LOCK`], so the load/add/save is race-free.
373    async fn upsert_provenance(&self, entry: InstalledPlugin, path: &Path) -> PluginResult<()> {
374        let mut store = InstalledPlugins::load(path).await?;
375        store.add(entry);
376        store.save(path).await?;
377        Ok(())
378    }
379
380    /// Abort an in-process `install` failure: best-effort undo of the partial
381    /// registration (steps 1-3), then restore `installed.json` to its
382    /// pre-install state — re-writing the original `previous` row on an
383    /// upgrade/recovery, or removing the id's row entirely on a fresh install
384    /// — so the `Installing` journal row we wrote up front never lingers after
385    /// a clean in-process failure. (A HARD kill is the only path that
386    /// intentionally leaves an `Installing` row, for the next op to recover.)
387    async fn abort_install(
388        &self,
389        rollback: &InstallRollback,
390        previous: &Option<InstalledPlugin>,
391        plugin_id: &str,
392        path: &Path,
393    ) {
394        self.rollback_partial_install(rollback).await;
395        let restore = match previous {
396            Some(prev) => self.upsert_provenance(prev.clone(), path).await,
397            None => match InstalledPlugins::load(path).await {
398                Ok(mut store) => {
399                    store.remove(plugin_id);
400                    store.save(path).await
401                }
402                Err(error) => Err(error),
403            },
404        };
405        if let Err(error) = restore {
406            tracing::warn!(
407                %plugin_id,
408                %error,
409                "failed to restore provenance after aborting a failed install; a stale \
410                 `installing` row may remain (recoverable by a retry)"
411            );
412        }
413    }
414
415    /// Step 1: MCP. Returns the ids actually (re-)registered
416    /// (`reconciliation.to_register`), which — once past the conflict gate —
417    /// is exactly the declared id set.
418    async fn register_mcp(
419        &self,
420        manifest: &PluginManifest,
421        resolved_mcp_servers: Vec<McpServerConfig>,
422        previously_owned: &[String],
423        rollback: &mut InstallRollback,
424    ) -> PluginResult<Vec<String>> {
425        if resolved_mcp_servers.is_empty() {
426            return Ok(Vec::new());
427        }
428
429        let declared_ids: Vec<String> = manifest
430            .provides
431            .mcp_servers
432            .iter()
433            .map(|entry| entry.id.clone())
434            .collect();
435        let existing_ids: Vec<String> = {
436            let config = self.state.config.read().await;
437            config.mcp.servers.iter().map(|s| s.id.clone()).collect()
438        };
439
440        let reconciliation = reconcile_exclusive(&declared_ids, &existing_ids, previously_owned);
441        if !reconciliation.foreign_conflicts.is_empty() {
442            return Err(PluginError::Conflict {
443                kind: "mcp server",
444                name: reconciliation.foreign_conflicts.join(", "),
445                plugin_id: manifest.id.clone(),
446            });
447        }
448
449        let to_register: HashSet<&str> = reconciliation
450            .to_register
451            .iter()
452            .map(String::as_str)
453            .collect();
454        let configs_to_register: Vec<McpServerConfig> = resolved_mcp_servers
455            .into_iter()
456            .filter(|config| to_register.contains(config.id.as_str()))
457            .collect();
458
459        let owned_configs = configs_to_register.clone();
460        let declared_for_recheck = declared_ids.clone();
461        let owned_for_recheck: Vec<String> = previously_owned.to_vec();
462        let plugin_id_for_recheck = manifest.id.clone();
463        self.state
464            .update_config(
465                move |cfg| {
466                    // TOCTOU guard: re-run the ownership pre-check against the
467                    // LIVE config while holding config_io_lock, so a foreign
468                    // entry that landed between our earlier read and now can't
469                    // be silently clobbered (and then recorded as
470                    // plugin-owned, re-opening BLOCKER-1 under a race).
471                    // Concurrent PLUGIN ops are already excluded by
472                    // PLUGIN_OP_LOCK; this closes the residual window against a
473                    // concurrent NON-plugin config write.
474                    let live_existing: Vec<String> =
475                        cfg.mcp.servers.iter().map(|s| s.id.clone()).collect();
476                    let live = reconcile_exclusive(
477                        &declared_for_recheck,
478                        &live_existing,
479                        &owned_for_recheck,
480                    );
481                    if !live.foreign_conflicts.is_empty() {
482                        return Err(AppError::BadRequest(format!(
483                            "mcp server(s) '{}' now conflict with a non-plugin entry (a concurrent \
484                             change landed mid-install); refusing to overwrite for plugin '{}'",
485                            live.foreign_conflicts.join(", "),
486                            plugin_id_for_recheck
487                        )));
488                    }
489                    // Shared by-id merge (same helper import_servers uses).
490                    for server in &owned_configs {
491                        upsert_server_by_id(&mut cfg.mcp.servers, server.clone());
492                    }
493                    Ok(())
494                },
495                ConfigUpdateEffects::default(),
496            )
497            .await
498            .map_err(|error| {
499                PluginError::Registration(format!("failed to write mcp servers to config: {error}"))
500            })?;
501        // Config write for the whole batch succeeded — record ownership now,
502        // regardless of whether individual `start_server` calls below
503        // succeed (matches `import_servers`' best-effort start semantics: a
504        // config entry that fails to start is still a real, plugin-owned
505        // registration a user/CLI can retry starting later).
506        rollback.mcp_ids_added = reconciliation.to_register.clone();
507
508        for server in &configs_to_register {
509            // Stop any stale running instance first, matching the
510            // update/import handlers' pattern.
511            let _ = self.state.mcp_manager.stop_server(&server.id).await;
512            if server.enabled {
513                match self.state.mcp_manager.start_server(server.clone()).await {
514                    Ok(()) => rollback.mcp_ids_started.push(server.id.clone()),
515                    Err(error) => tracing::warn!(
516                        mcp_server_id = %server.id,
517                        %error,
518                        "plugin-registered mcp server failed to start; config entry kept (best-effort)"
519                    ),
520                }
521            }
522        }
523
524        Ok(reconciliation.to_register)
525    }
526
527    /// Step 2: Prompts. Rename-on-collision (never refuses) — returns the
528    /// ACTUAL ids used (after any rename), which is what provenance must
529    /// record.
530    async fn register_prompts(&self, manifest: &PluginManifest) -> PluginResult<Vec<String>> {
531        if manifest.provides.prompts.is_empty() {
532            return Ok(Vec::new());
533        }
534
535        let path = self.prompt_presets_path();
536        let mut store = load_store(&path).await.map_err(|error| {
537            PluginError::Registration(format!("failed to load prompt-presets.json: {error}"))
538        })?;
539
540        let mut existing_ids: HashSet<String> = store
541            .prompts
542            .iter()
543            .map(|preset| preset.id.clone())
544            .collect();
545        // `general_assistant` (bamboo-server's DEFAULT_PRESET_ID) is never a
546        // row in the store, so it wouldn't otherwise appear in `existing_ids`
547        // — but manifest validation already rejects any plugin declaring it
548        // (RESERVED_PRESET_IDS), so no extra guard is needed here.
549
550        let mut actual_ids = Vec::with_capacity(manifest.provides.prompts.len());
551        for preset in &manifest.provides.prompts {
552            let actual_id = ensure_unique_preset_id(&preset.id, &existing_ids);
553            store.prompts.push(StoredPromptPreset {
554                id: actual_id.clone(),
555                name: preset.name.clone(),
556                description: preset.description.clone(),
557                content: preset.content.clone(),
558            });
559            existing_ids.insert(actual_id.clone());
560            actual_ids.push(actual_id);
561        }
562
563        save_store(&path, &store).await.map_err(|error| {
564            PluginError::Registration(format!("failed to persist prompt-presets.json: {error}"))
565        })?;
566
567        Ok(actual_ids)
568    }
569
570    /// Step 3: Workflows. Same REFUSE-on-conflict shape as MCP.
571    ///
572    /// Takes `rollback` directly (unlike [`Self::register_prompts`], which
573    /// commits in one atomic file write) because each workflow file is
574    /// copied with a SEPARATE `fs::write` call: if copying the Nth file
575    /// fails, files 1..N-1 are already really on disk, and `rollback` must
576    /// know about them even though this function returns `Err` — recording
577    /// the whole `to_register` list only on a successful `Ok` return (the
578    /// pattern the caller uses for [`Self::register_mcp`]/
579    /// [`Self::register_prompts`]) would lose that partial progress.
580    async fn register_workflows(
581        &self,
582        manifest: &PluginManifest,
583        plugin_dir: &Path,
584        previously_owned: &[String],
585        rollback: &mut InstallRollback,
586    ) -> PluginResult<Vec<String>> {
587        if manifest.provides.workflows.is_empty() {
588            return Ok(Vec::new());
589        }
590
591        let declared: Vec<String> = manifest.provides.workflows.clone();
592        let existing = self.existing_workflow_filenames().await?;
593        let reconciliation = reconcile_exclusive(&declared, &existing, previously_owned);
594        if !reconciliation.foreign_conflicts.is_empty() {
595            return Err(PluginError::Conflict {
596                kind: "workflow",
597                name: reconciliation.foreign_conflicts.join(", "),
598                plugin_id: manifest.id.clone(),
599            });
600        }
601
602        let dest_dir = self.workflows_dir();
603        for filename in &reconciliation.to_register {
604            let stem = filename.strip_suffix(".md").unwrap_or(filename);
605            if !bamboo_config::paths::is_safe_workflow_name(stem) {
606                return Err(PluginError::InvalidManifest(format!(
607                    "workflow filename '{filename}' is not a safe workflow name"
608                )));
609            }
610            let source_path = plugin_dir.join("workflows").join(filename);
611            let content = fs::read_to_string(&source_path).await?;
612            fs::write(dest_dir.join(filename), content).await?;
613            // Recorded immediately, not after the whole loop: if a LATER
614            // file in this same call fails, this one is already really on
615            // disk and rollback must know to remove it.
616            rollback.workflow_files_added.push(filename.clone());
617        }
618
619        Ok(reconciliation.to_register)
620    }
621}
622
623#[async_trait]
624impl PluginInstaller for ServerPluginInstaller {
625    async fn install(
626        &self,
627        manifest: &PluginManifest,
628        plugin_dir: &Path,
629        source: PluginSource,
630        disposition: InstallDisposition,
631        installed_at: DateTime<Utc>,
632    ) -> PluginResult<InstalledPlugin> {
633        // Serialize the whole op against every other plugin install/uninstall
634        // (process-wide) — held across all steps AND rollback. See module docs
635        // "Concurrency".
636        let _op_guard = PLUGIN_OP_LOCK.lock().await;
637
638        let installed_json_path = self.installed_json_path();
639
640        // Disposition gate (AlreadyInstalled only for a COMPLETED prior
641        // install; an `Installing` leftover is returned for recovery) + the
642        // rest of the pure, AppState-free validation this crate can already
643        // do (manifest shape, platform gate, on-disk skill/workflow
644        // existence, `provides.skills` authoritativeness).
645        let previous =
646            load_previous_for_disposition(&installed_json_path, &manifest.id, disposition).await?;
647        let resolved_mcp_servers = preflight_install(manifest, plugin_dir).await?;
648
649        // The set this install INTENDS to own, by declaration order. Used both
650        // for the crash-safety journal row (below) and the step-0 drop-diff.
651        let intended = RegisteredCapabilities {
652            mcp_server_ids: manifest
653                .provides
654                .mcp_servers
655                .iter()
656                .map(|entry| entry.id.clone())
657                .collect(),
658            skill_dirs: manifest.provides.skills.clone(),
659            preset_ids: manifest
660                .provides
661                .prompts
662                .iter()
663                .map(|preset| preset.id.clone())
664                .collect(),
665            workflow_filenames: manifest.provides.workflows.clone(),
666        };
667
668        // Step 0: upgrade drop-diff. Computed from the NEW manifest's plain
669        // declared ids (see module docs re: the preset-rename caveat) vs the
670        // OLD install's registered set — de-register whatever the new version
671        // no longer declares BEFORE registering anything new (BLOCKER 2). Also
672        // fires for a recovery over an `Installing` leftover: its intended set
673        // is diffed the same way, so a crashed attempt's extra ids get cleaned.
674        if let Some(previous) = &previous {
675            let dropped = intended.removed_since(&previous.registered);
676            if !dropped.is_empty() {
677                tracing::info!(
678                    plugin_id = %manifest.id,
679                    recovering = previous.status == PluginInstallStatus::Installing,
680                    dropped_mcp = ?dropped.mcp_server_ids,
681                    dropped_presets = ?dropped.preset_ids,
682                    dropped_workflows = ?dropped.workflow_filenames,
683                    "install drop-diff: de-registering capabilities the new/completed version no longer declares"
684                );
685                self.deregister_capabilities(&dropped).await;
686            }
687        }
688
689        let previously_owned_mcp = previous
690            .as_ref()
691            .map(|p| p.registered.mcp_server_ids.clone())
692            .unwrap_or_default();
693        let previously_owned_workflows = previous
694            .as_ref()
695            .map(|p| p.registered.workflow_filenames.clone())
696            .unwrap_or_default();
697
698        // Crash-safety journal: write an `Installing` provenance row recording
699        // the INTENDED ownership set BEFORE mutating any shared store, so a
700        // hard kill mid-install leaves a recoverable marker (see module docs
701        // "Crash safety"). On a fresh install this creates the row; on an
702        // upgrade/recovery it overwrites the prior row.
703        self.upsert_provenance(
704            InstalledPlugin {
705                id: manifest.id.clone(),
706                version: manifest.version.clone(),
707                source: source.clone(),
708                plugin_dir: plugin_dir.to_path_buf(),
709                installed_at,
710                status: PluginInstallStatus::Installing,
711                registered: intended.clone(),
712            },
713            &installed_json_path,
714        )
715        .await?;
716
717        let mut rollback = InstallRollback::default();
718
719        // Step 1: MCP.
720        let mcp_server_ids = match self
721            .register_mcp(
722                manifest,
723                resolved_mcp_servers,
724                &previously_owned_mcp,
725                &mut rollback,
726            )
727            .await
728        {
729            Ok(ids) => ids,
730            Err(error) => {
731                self.abort_install(&rollback, &previous, &manifest.id, &installed_json_path)
732                    .await;
733                return Err(error);
734            }
735        };
736
737        // Step 2: Prompts.
738        let preset_ids = match self.register_prompts(manifest).await {
739            Ok(ids) => {
740                rollback.preset_ids_added = ids.clone();
741                ids
742            }
743            Err(error) => {
744                self.abort_install(&rollback, &previous, &manifest.id, &installed_json_path)
745                    .await;
746                return Err(error);
747            }
748        };
749
750        // Step 3: Workflows. `register_workflows` records each copied file
751        // into `rollback` itself as it goes (see its doc comment) — a
752        // partial failure partway through a multi-file copy is still fully
753        // rolled back.
754        let workflow_filenames = match self
755            .register_workflows(
756                manifest,
757                plugin_dir,
758                &previously_owned_workflows,
759                &mut rollback,
760            )
761            .await
762        {
763            Ok(files) => files,
764            Err(error) => {
765                self.abort_install(&rollback, &previous, &manifest.id, &installed_json_path)
766                    .await;
767                return Err(error);
768            }
769        };
770
771        // Step 4: Skills — nothing to register, just record the
772        // declared+validated dir names (preflight_install already confirmed
773        // every declared dir exists and that no undeclared dir is present).
774        let skill_dirs = manifest.provides.skills.clone();
775
776        // Step 5: commit provenance — flip the journal row to `Installed` with
777        // the ACTUAL registered set (renamed preset ids, the to_register mcp/
778        // workflow subsets). Only reached once 0-4 all succeeded.
779        let registered = RegisteredCapabilities {
780            mcp_server_ids,
781            skill_dirs,
782            preset_ids,
783            workflow_filenames,
784        };
785        let entry = InstalledPlugin {
786            id: manifest.id.clone(),
787            version: manifest.version.clone(),
788            source,
789            plugin_dir: plugin_dir.to_path_buf(),
790            installed_at,
791            status: PluginInstallStatus::Installed,
792            registered,
793        };
794        self.upsert_provenance(entry.clone(), &installed_json_path)
795            .await?;
796
797        Ok(entry)
798    }
799
800    async fn uninstall(&self, id: &str) -> PluginResult<()> {
801        // Serialize against every other plugin op (see module docs).
802        let _op_guard = PLUGIN_OP_LOCK.lock().await;
803
804        let installed_json_path = self.installed_json_path();
805        let mut store = InstalledPlugins::load(&installed_json_path).await?;
806        // Works on an `Installing` (crash-leftover) row too, so a crashed
807        // install is never un-uninstallable.
808        let Some(entry) = store.get(id).cloned() else {
809            return Err(PluginError::NotFound(id.to_string()));
810        };
811
812        // De-register everything this plugin's `registered` set names — by
813        // construction (see bamboo-plugin's ownership contract) this can
814        // only ever be entries the plugin itself created. Idempotent: a
815        // manually-removed entry is logged and skipped, never a hard error.
816        self.deregister_capabilities(&entry.registered).await;
817
818        // Remove the plugin's own files BEFORE clearing provenance: if this
819        // fails (e.g. a permission error), provenance is left intact so a
820        // retry is safe (the de-registration above is idempotent, so
821        // re-running it is a harmless no-op) rather than leaving an
822        // unregistered-but-still-on-disk `skills/` dir that discovery would
823        // keep picking up despite `uninstall` having "succeeded".
824        match fs::remove_dir_all(&entry.plugin_dir).await {
825            Ok(()) => {}
826            Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
827            Err(error) => return Err(PluginError::Io(error)),
828        }
829
830        store.remove(id);
831        store.save(&installed_json_path).await?;
832        Ok(())
833    }
834
835    async fn list(&self) -> PluginResult<Vec<InstalledPlugin>> {
836        let store = InstalledPlugins::load(&self.installed_json_path()).await?;
837        Ok(store.plugins)
838    }
839}
840
841#[cfg(test)]
842mod tests;