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>`
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//! The installer coordinates the AppState-owned service manager and ToolEvent
20//! router, so runtime registration and revocation share the same lifecycle
21//! boundary as durable plugin provenance.
22//!
23//! # Path derivation: `state.app_data_dir`, not the `bamboo_config::paths` globals
24//!
25//! `bamboo_config::paths::{plugins_dir, workflows_dir, plugins_installed_json_path, ...}`
26//! all resolve through a process-wide `OnceLock` that `AppState::new` seeds
27//! ONCE per process (first caller wins — see its doc comment). That is
28//! correct for the single production `AppState` per process, but this
29//! crate's own test suite already builds many `AppState`s over different
30//! `tempfile::tempdir()`s in the same test binary (e.g.
31//! `app_state::tests::test_app_state_creation` and friends) — if this type
32//! read the global helpers, every one of those `AppState`s would silently
33//! share whichever tempdir happened to construct the first one. Every path
34//! below is instead derived from the borrowed `state.app_data_dir` field
35//! directly, exactly the pattern `handlers::settings::workflows` and
36//! `prompt_presets::storage::store_file_path` already use. In production,
37//! where there is exactly one `AppState`, this resolves to the identical
38//! path the global helpers would have produced.
39//!
40//! # Concurrency
41//!
42//! Every `install`/`uninstall` runs under a single process-wide async lock
43//! ([`PLUGIN_OP_LOCK`]), held for the ENTIRE operation including rollback, so
44//! the reconcile→mutate→provenance sequence is atomic w.r.t. any other plugin
45//! op. This closes three concurrency gaps at once: the `installed.json` and
46//! `prompt-presets.json` load/modify/save lost-update races, and the MCP
47//! reconcile→config-write TOCTOU. As additional defense against a concurrent
48//! NON-plugin config write (which does not take this lock), the MCP step also
49//! RE-runs its ownership pre-check INSIDE the `update_config` closure, under
50//! `config_io_lock`, and aborts rather than clobbering if a foreign entry
51//! appeared. Lock ordering is `PLUGIN_OP_LOCK` → `config_io_lock` (never the
52//! reverse) — see [`PLUGIN_OP_LOCK`].
53//!
54//! Plugin workflow markdown is never copied into the user's global workflow
55//! directory. It remains inside the plugin bundle and is discovered in place
56//! by the SkillStore, so plugin install cannot overwrite a same-named user
57//! source and needs no shared workflow-file lock.
58//!
59//! # Crash safety (process killed mid-install)
60//!
61//! In-process rollback (below) only fires on an `Err`. A HARD kill after the
62//! MCP step wrote to `config.json` but before provenance is committed would,
63//! without a journal, leave: `reconcile_exclusive` seeing the orphaned mcp id
64//! as existing-but-not-owned → a false `Conflict` on the retry, AND
65//! `uninstall` returning `NotFound` (no provenance) → the user stuck
66//! hand-editing `config.json`. To prevent that, `install` writes a provenance
67//! row with status [`PluginInstallStatus::Installing`] — recording the
68//! INTENDED ownership set — BEFORE steps 1-4, and flips it to
69//! [`PluginInstallStatus::Installed`] only after step 5 succeeds. On the next
70//! install/upgrade of an id whose row is still `Installing` (a prior crash),
71//! [`load_previous_for_disposition`] returns it as `previous` (it does NOT
72//! trip `AlreadyInstalled`), so its intended set is treated as
73//! this-plugin-owned — the leftover reads as an `OwnedReinstall`, not a
74//! foreign conflict — and is cleaned up as an upgrade-over-incomplete.
75//! `uninstall` works on an `Installing` row too.
76//!
77//! # Atomicity / rollback semantics
78//!
79//! `install()` follows `PLUGIN_PLAN.md`'s numbered sequence exactly:
80//!
81//! 0. **Upgrade drop-diff** (only when upgrading an already-installed id):
82//!    de-register whatever the new manifest no longer declares, computed via
83//!    [`bamboo_plugin::registry::RegisteredCapabilities::removed_since`],
84//!    BEFORE registering anything new. De-registration is idempotent/
85//!    best-effort (see [`ServerPluginInstaller::deregister_capabilities`]) —
86//!    an entry a user already removed by hand never blocks an upgrade.
87//! 1. **MCP** — ownership-checked (REFUSE on a foreign conflict, via
88//!    [`bamboo_plugin::registry::reconcile_exclusive`]), merged into
89//!    `config.json`, started.
90//! 2. **Prompts** — rename-on-collision (never refuse), appended to
91//!    `prompt-presets.json`.
92//! 3. **Workflows** — validated for safe in-place discovery; no shared-store
93//!    copy or ownership mutation.
94//! 4. **Skills** — nothing to register (discovered in place); just recorded.
95//! 5. **Provenance commit** — `installed.json` is only ever upserted after
96//!    steps 0-4 all succeed.
97//!
98//! Steps 1-2 are real, sequential mutations (config then prompt-store writes)
99//! — NOT a dry-run computed up front — because `PLUGIN_PLAN.md`
100//! requires the ownership pre-checks to run in that exact order against the
101//! LIVE state each step leaves behind. That means a HARD failure at step 2
102//! or the workflow validation in step 3 can happen after step 1 already wrote
103//! real entries into `config.json`. [`ServerPluginInstaller::install`] tracks
104//! every already-applied mutation in an [`InstallRollback`] and, on any hard
105//! failure from steps 1-3, best-effort UNDOES them (removes the mcp entries
106//! it just added and stops any it started, removes the presets it just
107//! appended, and removes services it started) before returning the
108//! error — so a caller's retry starts from a clean slate. Provenance is
109//! never written on a failed path (step 5 is the only place `installed.json`
110//! is touched on success), which is the minimum safety bar even if a rollback
111//! step itself only partially succeeds (rollback operations are themselves
112//! idempotent/log-and-continue, so a second rollback attempt via a plain
113//! retry can never fail louder than the first).
114//!
115//! The production HTTP path prepares source bytes in an isolated directory,
116//! then retains [`PluginOperationGuard`] across global ownership preflight,
117//! old-service shutdown, bundle activation, registration, and rollback. The
118//! on-disk swap therefore shares the same serialization boundary as the
119//! provenance/config mutations. Standalone callers of the lower-level trait
120//! remain responsible for staging serialization; see `crate::plugin_source`.
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::{
143    reconcile_event_sinks, reconcile_exclusive, reconcile_plugin_boot, PluginBootCandidate,
144    RegisteredCapabilities,
145};
146use bamboo_plugin::{
147    EventSinkPermissionGrants, InstallDisposition, InstalledPlugin, InstalledPlugins, PluginError,
148    PluginInstallStatus, PluginInstaller, PluginManifest, PluginResult, PluginSource,
149};
150
151use crate::app_state::{AppState, ConfigUpdateEffects};
152use crate::error::AppError;
153use crate::handlers::agent::mcp::upsert_server_by_id;
154use crate::handlers::agent::prompt_presets::{
155    ensure_unique_preset_id, load_store, save_store, store_file_path, StoredPromptPreset,
156};
157use crate::service_manager::{ServiceManager, ServiceRuntimeConfig};
158use crate::tool_event_policy::{
159    canonicalize_persisted_event_sink_grants, resolve_event_sink_grants,
160};
161use crate::tool_event_router::ToolEventRouter;
162
163/// Process-wide serialization of plugin install/uninstall operations.
164///
165/// The whole ownership/upgrade machinery is a read-modify-write over shared
166/// stores (`config.json`, `prompt-presets.json`, `installed.json`) with the
167/// ownership pre-check and the eventual mutation in separate steps. Under
168/// CONCURRENT plugin ops (the HTTP agent will expose exactly that) those
169/// interleave badly: two installs of different ids race `installed.json`'s
170/// load/add/save (last save drops the other's row), `prompt-presets.json`'s
171/// load/save (lost update), and the MCP reconcile→write window (a foreign
172/// entry landing mid-window gets clobbered AND recorded as plugin-owned,
173/// re-opening BLOCKER-1). Plugin installs are rare and not perf-sensitive, so
174/// one coarse process-wide lock held across the ENTIRE `install`/`uninstall`
175/// (including rollback) is the right call — it makes each op's
176/// reconcile→mutate→provenance sequence atomic w.r.t. every other plugin op.
177///
178/// Lock ordering: this lock is acquired at the TOP of `install`/`uninstall`,
179/// OUTSIDE any `AppState::update_config` call (which internally takes
180/// `config_io_lock`). So the order is always `PLUGIN_OP_LOCK` →
181/// `config_io_lock`, never the reverse — no deadlock. Nothing acquires
182/// `PLUGIN_OP_LOCK` while holding `config_io_lock`.
183///
184/// # Single-process assumption (deferred: no cross-process lock)
185///
186/// This is a `tokio::sync::Mutex` — IN-PROCESS only. It serializes plugin ops
187/// within one `bamboo serve` process, but two SEPARATE `bamboo serve`
188/// processes pointed at the same `~/.bamboo` data dir would each get their
189/// own independent `PLUGIN_OP_LOCK` and could race each other's
190/// reconcile→mutate→provenance sequence exactly the way this lock exists to
191/// prevent for concurrent ops WITHIN one process. The plugin system assumes
192/// the normal deployment: a SINGLE `bamboo serve` per data directory. True
193/// multi-process safety would need an OS-level file lock (e.g. `flock` on a
194/// lockfile under `plugins_dir()`) instead of/in addition to this `Mutex`;
195/// that's a documented follow-up, not implemented here.
196static PLUGIN_OP_LOCK: tokio::sync::Mutex<()> = tokio::sync::Mutex::const_new(());
197
198/// Proof that the caller holds the process-wide plugin-operation boundary.
199/// HTTP source preparation uses this guard across ownership preflight, old
200/// service shutdown, bundle activation, installer mutation, and rollback.
201pub(crate) struct PluginOperationGuard {
202    _guard: tokio::sync::MutexGuard<'static, ()>,
203}
204
205/// AppState-backed [`PluginInstaller`]. See the module docs for the full
206/// design rationale (borrowing, path derivation, atomicity).
207pub struct ServerPluginInstaller {
208    state: actix_web::web::Data<AppState>,
209}
210
211/// Mutations already applied by a not-yet-committed `install()`, so a hard
212/// failure partway through steps 1-3 can best-effort undo exactly what has
213/// been done so far. See the module docs' "Atomicity / rollback semantics".
214#[derive(Default)]
215struct InstallRollback {
216    mcp_ids_added: Vec<String>,
217    preset_ids_added: Vec<String>,
218    /// Service ids this install claimed ownership of (whether or not the
219    /// actual `start_service` call succeeded — best-effort).
220    service_ids_added: Vec<String>,
221    /// Subset of `service_ids_added` that actually got a running
222    /// `ServiceManager` runtime started — only these need `stop_service` on
223    /// rollback.
224    service_ids_started: Vec<String>,
225}
226
227#[derive(Default)]
228struct InstallFailureInjection {
229    before_service_replacement: bool,
230    final_provenance_commit: bool,
231}
232
233impl ServerPluginInstaller {
234    pub fn new(state: actix_web::web::Data<AppState>) -> Self {
235        Self { state }
236    }
237
238    pub(crate) async fn begin_operation(&self) -> PluginOperationGuard {
239        PluginOperationGuard {
240            _guard: PLUGIN_OP_LOCK.lock().await,
241        }
242    }
243
244    async fn preflight_provenance_ownership(
245        &self,
246        manifest: &PluginManifest,
247    ) -> PluginResult<bamboo_plugin::registry::ExclusiveReconciliation> {
248        let declared_event_sink_ids: Vec<String> = manifest
249            .provides
250            .event_sinks
251            .iter()
252            .map(|sink| sink.id.clone())
253            .collect();
254        // `existing_*` contains ONLY other plugin rows. Any hit is therefore
255        // foreign even when a corrupt current row also claims the same id;
256        // current previous ownership must never override it.
257        let event_sinks = reconcile_exclusive(
258            &declared_event_sink_ids,
259            &self.existing_event_sink_ids(&manifest.id).await?,
260            &[],
261        );
262        if !event_sinks.foreign_conflicts.is_empty() {
263            return Err(PluginError::Conflict {
264                kind: "event sink",
265                name: event_sinks.foreign_conflicts.join(", "),
266                plugin_id: manifest.id.clone(),
267            });
268        }
269
270        let declared_service_ids: Vec<String> = manifest
271            .provides
272            .services
273            .iter()
274            .map(|service| service.id.clone())
275            .collect();
276        let services = reconcile_exclusive(
277            &declared_service_ids,
278            &self.existing_service_ids(&manifest.id).await?,
279            &[],
280        );
281        if !services.foreign_conflicts.is_empty() {
282            return Err(PluginError::Conflict {
283                kind: if manifest.provides.event_sinks.iter().any(|sink| {
284                    services
285                        .foreign_conflicts
286                        .iter()
287                        .any(|id| id == &sink.service_id)
288                }) {
289                    "event sink service"
290                } else {
291                    "service"
292                },
293                name: services.foreign_conflicts.join(", "),
294                plugin_id: manifest.id.clone(),
295            });
296        }
297        Ok(event_sinks)
298    }
299
300    /// Validate an isolated candidate while holding the same operation lock
301    /// that will cover activation and install. No live bundle, service,
302    /// config, or provenance state is mutated here.
303    pub(crate) async fn preflight_prepared_candidate(
304        &self,
305        manifest: &PluginManifest,
306        prepared_dir: &Path,
307        disposition: InstallDisposition,
308        _guard: &PluginOperationGuard,
309    ) -> PluginResult<Option<InstalledPlugin>> {
310        let previous =
311            load_previous_for_disposition(&self.installed_json_path(), &manifest.id, disposition)
312                .await?;
313        preflight_install(manifest, prepared_dir).await?;
314        self.preflight_provenance_ownership(manifest).await?;
315        Ok(previous)
316    }
317
318    fn plugins_dir(&self) -> PathBuf {
319        self.state.app_data_dir.join("plugins")
320    }
321
322    fn installed_json_path(&self) -> PathBuf {
323        self.plugins_dir().join("installed.json")
324    }
325
326    fn workflows_dir(&self) -> PathBuf {
327        self.state.app_data_dir.join("workflows")
328    }
329
330    fn prompt_presets_path(&self) -> PathBuf {
331        store_file_path(&self.state.app_data_dir)
332    }
333
334    /// `<data_dir>/plugin_service_config/<plugin_id>/config.json` — the
335    /// per-service user config path passed to services as
336    /// `BAMBOO_PLUGIN_SERVICE_CONFIG` (issue #479 open question 2).
337    ///
338    /// Deliberately NOT under `plugins_dir()/<plugin_id>/` (the
339    /// swap-managed `plugin_dir`): plugin-source activation upgrades a plugin
340    /// by renaming the ENTIRE old `plugin_dir` aside and swapping a prepared
341    /// directory into its place — any file living
342    /// inside `plugin_dir` would be swept away with the old bundle on
343    /// upgrade (or deleted outright on uninstall) unless bamboo specifically
344    /// carried it forward, which it does not. A sibling directory, named
345    /// only by `plugin_id`, is untouched by that swap and by
346    /// `uninstall`'s `remove_dir_all(plugin_dir)` — so a service's own
347    /// config (which may carry tokens/secrets a connector needs) survives
348    /// both an upgrade and an uninstall. bamboo only ever creates the PARENT
349    /// directory here (see [`Self::ensure_service_config_parent_dir`]) —
350    /// never writes or deletes `config.json` itself; on uninstall it is
351    /// deliberately left in place (not part of `remove_dir_all`), so a
352    /// later re-install of the same plugin id picks its old config back up
353    /// automatically.
354    fn service_config_path(&self, plugin_id: &str) -> PathBuf {
355        service_config_path_under(&self.state.app_data_dir, plugin_id)
356    }
357
358    async fn ensure_service_config_parent_dir(&self, plugin_id: &str) -> PluginResult<()> {
359        let path = self.service_config_path(plugin_id);
360        if let Some(parent) = path.parent() {
361            fs::create_dir_all(parent).await?;
362        }
363        Ok(())
364    }
365
366    /// Every service id currently owned by any OTHER installed plugin — the
367    /// "existing" side of [`reconcile_exclusive`] for services. There is no
368    /// single shared document analogous to `config.json` for services, so
369    /// provenance itself is the source of truth for "who owns this id".
370    ///
371    /// `exclude_plugin_id` MUST be the id of the plugin currently being
372    /// installed/upgraded and is always excluded — NOT an optional
373    /// nicety: by the time [`Self::register_services`] calls this,
374    /// `install()`'s crash-safety journal write has ALREADY upserted THIS
375    /// plugin's `Installing` row into `installed.json` with its full
376    /// INTENDED `service_ids` (see `install()`'s "Crash-safety journal"
377    /// step, which runs before every registration step). Without excluding
378    /// it, a plain fresh install would see its own not-yet-committed row as
379    /// a foreign owner of its own declared ids and refuse itself.
380    /// Because this query excludes the current row, every returned id is
381    /// unambiguously foreign. Re-declared ids from the current plugin remain
382    /// absent here and are recorded from the new manifest after preflight.
383    async fn existing_service_ids(&self, exclude_plugin_id: &str) -> PluginResult<Vec<String>> {
384        let store = InstalledPlugins::load(&self.installed_json_path()).await?;
385        store.get_unique(exclude_plugin_id)?;
386        Ok(store
387            .list()
388            .iter()
389            .filter(|plugin| plugin.id != exclude_plugin_id)
390            .flat_map(|plugin| plugin.registered.service_ids.iter().cloned())
391            .collect())
392    }
393
394    /// Event-sink ids are process/AppState registration keys. Installed
395    /// provenance remains the authoritative global ownership index; the live
396    /// router only activates a reconciliation plan after this check has
397    /// rejected cross-plugin borrowing before any install mutation.
398    async fn existing_event_sink_ids(&self, exclude_plugin_id: &str) -> PluginResult<Vec<String>> {
399        let store = InstalledPlugins::load(&self.installed_json_path()).await?;
400        store.get_unique(exclude_plugin_id)?;
401        Ok(store
402            .list()
403            .iter()
404            .filter(|plugin| plugin.id != exclude_plugin_id)
405            .flat_map(|plugin| plugin.registered.event_sink_ids.iter().cloned())
406            .collect())
407    }
408
409    /// Resolve one manifest-declared service entry into a
410    /// [`ServiceRuntimeConfig`] ready for `ServiceManager::start_service`.
411    fn resolve_service_config(
412        &self,
413        plugin_id: &str,
414        entry: &ServiceManifestEntry,
415        plugin_dir: &Path,
416        platform: Platform,
417    ) -> ServiceRuntimeConfig {
418        resolve_service_config_under(
419            &self.state.app_data_dir,
420            plugin_id,
421            entry,
422            plugin_dir,
423            platform,
424        )
425    }
426
427    // --- De-registration primitives (shared by upgrade drop-diff, install
428    // rollback, and `uninstall`). Each is individually idempotent/tolerant —
429    // an entry that is already gone (e.g. a user manually deleted it) is
430    // logged and skipped, never a hard failure — matching the requirement
431    // that de-registration never blocks an uninstall/upgrade retry. ---
432
433    async fn remove_mcp_server(&self, id: &str) {
434        let owned_id = id.to_string();
435        let result = self
436            .state
437            .update_config(
438                move |config| {
439                    config.mcp.servers.retain(|server| server.id != owned_id);
440                    Ok(())
441                },
442                ConfigUpdateEffects {
443                    reload_provider: bamboo_config::patch::ReloadMode::None,
444                    reconcile_mcp: bamboo_config::patch::ReloadMode::BestEffort,
445                },
446            )
447            .await;
448        if let Err(error) = result {
449            tracing::warn!(
450                mcp_server_id = %id,
451                %error,
452                "failed to remove plugin-owned mcp server from config.json; continuing"
453            );
454        }
455    }
456
457    async fn remove_prompt_preset(&self, preset_id: &str) {
458        let path = self.prompt_presets_path();
459        match load_store(&path).await {
460            Ok(mut store) => {
461                let before = store.prompts.len();
462                store.prompts.retain(|preset| preset.id != preset_id);
463                if store.prompts.len() != before {
464                    if let Err(error) = save_store(&path, &store).await {
465                        tracing::warn!(
466                            %preset_id,
467                            %error,
468                            "failed to persist prompt-presets.json after removing plugin-owned preset; continuing"
469                        );
470                    }
471                }
472            }
473            Err(error) => {
474                tracing::warn!(
475                    %preset_id,
476                    %error,
477                    "failed to load prompt-presets.json while removing plugin-owned preset; continuing"
478                );
479            }
480        }
481    }
482
483    async fn remove_service(&self, id: &str) {
484        if let Err(error) = self.state.service_manager.stop_service(id).await {
485            tracing::warn!(
486                service_id = %id,
487                %error,
488                "failed to stop plugin-owned service; continuing"
489            );
490        }
491    }
492
493    async fn remove_workflow_file(&self, filename: &str) {
494        let path = self.workflows_dir().join(filename);
495        match fs::remove_file(&path).await {
496            Ok(()) => {}
497            Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
498            Err(error) => {
499                tracing::warn!(
500                    %filename,
501                    %error,
502                    "failed to remove plugin-owned workflow file; continuing"
503                );
504            }
505        }
506    }
507
508    /// De-register a whole [`RegisteredCapabilities`] set (used for the
509    /// upgrade drop-diff and for `uninstall`). Skill dirs need no shared-store
510    /// action — they are only ever removed by deleting `plugin_dir` itself.
511    async fn deregister_capabilities(&self, registered: &RegisteredCapabilities) {
512        // #903's removal contract is authority-bearing: revoke the hot routing
513        // snapshot and await every exact-generation worker before the backing
514        // service can be stopped by the loop below.
515        self.state
516            .tool_event_router
517            .unregister_sinks(&registered.removal_order().event_sink_ids_before_services)
518            .await;
519        for mcp_id in &registered.mcp_server_ids {
520            self.remove_mcp_server(mcp_id).await;
521        }
522        for preset_id in &registered.preset_ids {
523            self.remove_prompt_preset(preset_id).await;
524        }
525        for workflow_filename in &registered.workflow_filenames {
526            self.remove_workflow_file(workflow_filename).await;
527        }
528        for service_id in &registered.service_ids {
529            self.remove_service(service_id).await;
530        }
531    }
532
533    /// Apply an upgrade's id-level drop-diff without ever stopping a service
534    /// beneath a still-live sink generation. A retained sink id may change
535    /// its backing service id, which `RegisteredCapabilities::removed_since`
536    /// cannot express because provenance stores capability ids rather than
537    /// sink-to-service edges. Before stopping dropped services, revoke only
538    /// prior sinks whose current router declaration is actually backed by one
539    /// of those services. Unrelated retained routes must survive failures
540    /// before the later full service-replacement seam.
541    async fn deregister_upgrade_drop_diff(
542        &self,
543        plugin_id: &str,
544        previous: &RegisteredCapabilities,
545        dropped: &RegisteredCapabilities,
546    ) {
547        self.state
548            .tool_event_router
549            .unregister_plugin_sinks_backed_by_services(
550                plugin_id,
551                &previous.event_sink_ids,
552                &dropped.service_ids,
553            )
554            .await;
555        self.deregister_capabilities(dropped).await;
556    }
557
558    /// Best-effort undo of an `install()` that failed partway through steps
559    /// 1-3. See the module docs.
560    async fn rollback_partial_install(&self, rollback: &InstallRollback) {
561        for id in &rollback.mcp_ids_added {
562            self.remove_mcp_server(id).await;
563        }
564        for id in &rollback.preset_ids_added {
565            self.remove_prompt_preset(id).await;
566        }
567        for id in &rollback.service_ids_started {
568            let _ = self.state.service_manager.stop_service(id).await;
569        }
570    }
571
572    /// Upsert one provenance row into `installed.json`. Used both for the
573    /// pre-registration `Installing` journal row and the final `Installed`
574    /// commit — the ONLY two writers of `installed.json` in `install`. Both
575    /// run under [`PLUGIN_OP_LOCK`], so the load/add/save is race-free.
576    async fn upsert_provenance(&self, entry: InstalledPlugin, path: &Path) -> PluginResult<()> {
577        let mut store = InstalledPlugins::load(path).await?;
578        store.get_unique(&entry.id)?;
579        store.add(entry);
580        store.save(path).await?;
581        Ok(())
582    }
583
584    /// Abort an in-process `install` failure: best-effort undo of the partial
585    /// registration (steps 1-3), then restore `installed.json` to its
586    /// pre-install state — re-writing the original `previous` row on an
587    /// upgrade/recovery, or removing the id's row entirely on a fresh install
588    /// — so the `Installing` journal row we wrote up front never lingers after
589    /// a clean in-process failure. (A HARD kill is the only path that
590    /// intentionally leaves an `Installing` row, for the next op to recover.)
591    async fn abort_install(
592        &self,
593        rollback: &InstallRollback,
594        previous: &Option<InstalledPlugin>,
595        plugin_id: &str,
596        path: &Path,
597    ) {
598        self.rollback_partial_install(rollback).await;
599        let restore = match previous {
600            Some(prev) => self.upsert_provenance(prev.clone(), path).await,
601            None => match InstalledPlugins::load(path).await {
602                Ok(mut store) => {
603                    store.remove(plugin_id);
604                    store.save(path).await
605                }
606                Err(error) => Err(error),
607            },
608        };
609        if let Err(error) = restore {
610            tracing::warn!(
611                %plugin_id,
612                %error,
613                "failed to restore provenance after aborting a failed install; a stale \
614                 `installing` row may remain (recoverable by a retry)"
615            );
616        }
617    }
618
619    /// Step 1: MCP. Returns the ids actually (re-)registered
620    /// (`reconciliation.to_register`), which — once past the conflict gate —
621    /// is exactly the declared id set.
622    async fn register_mcp(
623        &self,
624        manifest: &PluginManifest,
625        resolved_mcp_servers: Vec<McpServerConfig>,
626        previously_owned: &[String],
627        rollback: &mut InstallRollback,
628    ) -> PluginResult<Vec<String>> {
629        if resolved_mcp_servers.is_empty() {
630            return Ok(Vec::new());
631        }
632
633        let declared_ids: Vec<String> = manifest
634            .provides
635            .mcp_servers
636            .iter()
637            .map(|entry| entry.id.clone())
638            .collect();
639        let existing_ids: Vec<String> = {
640            let config = self.state.config.read().await;
641            config.mcp.servers.iter().map(|s| s.id.clone()).collect()
642        };
643
644        let reconciliation = reconcile_exclusive(&declared_ids, &existing_ids, previously_owned);
645        if !reconciliation.foreign_conflicts.is_empty() {
646            return Err(PluginError::Conflict {
647                kind: "mcp server",
648                name: reconciliation.foreign_conflicts.join(", "),
649                plugin_id: manifest.id.clone(),
650            });
651        }
652
653        let to_register: HashSet<&str> = reconciliation
654            .to_register
655            .iter()
656            .map(String::as_str)
657            .collect();
658        let configs_to_register: Vec<McpServerConfig> = resolved_mcp_servers
659            .into_iter()
660            .filter(|config| to_register.contains(config.id.as_str()))
661            .collect();
662
663        let owned_configs = configs_to_register.clone();
664        let declared_for_recheck = declared_ids.clone();
665        let owned_for_recheck: Vec<String> = previously_owned.to_vec();
666        let plugin_id_for_recheck = manifest.id.clone();
667        let forced_mcp_replacements = reconciliation.to_register.iter().cloned().collect();
668        self.state
669            .update_config_with_forced_mcp_replacements(
670                move |config| {
671                    // TOCTOU guard: re-run the ownership pre-check against the
672                    // LIVE config while holding config_io_lock, so a foreign
673                    // entry that landed between our earlier read and now can't
674                    // be silently clobbered (and then recorded as
675                    // plugin-owned, re-opening BLOCKER-1 under a race).
676                    // Concurrent PLUGIN ops are already excluded by
677                    // PLUGIN_OP_LOCK; this closes the residual window against a
678                    // concurrent NON-plugin config write.
679                    let live_existing: Vec<String> =
680                        config.mcp.servers.iter().map(|s| s.id.clone()).collect();
681                    let live = reconcile_exclusive(
682                        &declared_for_recheck,
683                        &live_existing,
684                        &owned_for_recheck,
685                    );
686                    if !live.foreign_conflicts.is_empty() {
687                        return Err(AppError::BadRequest(format!(
688                            "mcp server(s) '{}' now conflict with a non-plugin entry (a concurrent \
689                             change landed mid-install); refusing to overwrite for plugin '{}'",
690                            live.foreign_conflicts.join(", "),
691                            plugin_id_for_recheck
692                        )));
693                    }
694                    // Shared by-id merge (same helper import_servers uses).
695                    for server in &owned_configs {
696                        upsert_server_by_id(&mut config.mcp.servers, server.clone());
697                    }
698                    Ok(())
699                },
700                ConfigUpdateEffects {
701                    reload_provider: bamboo_config::patch::ReloadMode::None,
702                    reconcile_mcp: bamboo_config::patch::ReloadMode::BestEffort,
703                },
704                forced_mcp_replacements,
705            )
706            .await
707            .map_err(|error| {
708                PluginError::Registration(format!("failed to write mcp servers to config: {error}"))
709            })?;
710        // The config generation is committed and live before ownership is
711        // claimed. Runtime activation is generation-serialized and reports
712        // degraded MCP health on failure, preserving the installer's existing
713        // best-effort activation contract without an out-of-lock start.
714        rollback.mcp_ids_added = reconciliation.to_register.clone();
715
716        Ok(reconciliation.to_register)
717    }
718
719    /// Step 1b: Services (issue #479, prereq for epic #477). Same
720    /// REFUSE-on-conflict + best-effort-start shape as [`Self::register_mcp`],
721    /// against [`Self::existing_service_ids`] instead of `config.json` (there
722    /// is no shared config document for services — provenance itself is the
723    /// ownership store, see that method's doc comment).
724    async fn register_services(
725        &self,
726        manifest: &PluginManifest,
727        plugin_dir: &Path,
728        rollback: &mut InstallRollback,
729    ) -> PluginResult<Vec<String>> {
730        if manifest.provides.services.is_empty() {
731            return Ok(Vec::new());
732        }
733
734        let declared_ids: Vec<String> = manifest
735            .provides
736            .services
737            .iter()
738            .map(|entry| entry.id.clone())
739            .collect();
740        let existing_ids = self.existing_service_ids(&manifest.id).await?;
741        // `existing_ids` excludes this plugin row, so every collision is
742        // foreign even if corrupt provenance also records it under self.
743        let reconciliation = reconcile_exclusive(&declared_ids, &existing_ids, &[]);
744        if !reconciliation.foreign_conflicts.is_empty() {
745            return Err(PluginError::Conflict {
746                kind: "service",
747                name: reconciliation.foreign_conflicts.join(", "),
748                plugin_id: manifest.id.clone(),
749            });
750        }
751
752        // Ownership is claimed regardless of individual start outcomes below
753        // (matches `register_mcp`'s "config write succeeded, so record
754        // ownership" contract — here there is no config write, so this is
755        // simply claimed up front).
756        rollback.service_ids_added = reconciliation.to_register.clone();
757
758        self.ensure_service_config_parent_dir(&manifest.id).await?;
759        let platform = Platform::current().unwrap_or(Platform::Linux);
760        let to_register: HashSet<&str> = reconciliation
761            .to_register
762            .iter()
763            .map(String::as_str)
764            .collect();
765
766        for entry in &manifest.provides.services {
767            if !to_register.contains(entry.id.as_str()) {
768                continue;
769            }
770            // Stop any stale running instance first — covers a leftover
771            // from a crashed install/upgrade recovery (a genuine same-id
772            // upgrade already had its old service stopped BEFORE activation by
773            // `stop_services_for_upgrade`, see the module docs' "Same-id
774            // upgrade ordering").
775            let _ = self.state.service_manager.stop_service(&entry.id).await;
776            if !entry.enabled {
777                continue;
778            }
779            let config = self.resolve_service_config(&manifest.id, entry, plugin_dir, platform);
780            match self.state.service_manager.start_service(config).await {
781                Ok(()) => rollback.service_ids_started.push(entry.id.clone()),
782                Err(error) => tracing::warn!(
783                    service_id = %entry.id,
784                    %error,
785                    "plugin-registered service failed to start; ownership kept (best-effort, matches mcp)"
786                ),
787            }
788        }
789
790        Ok(reconciliation.to_register)
791    }
792
793    /// Step 2: Prompts. Rename-on-collision (never refuses) — returns the
794    /// ACTUAL ids used (after any rename), which is what provenance must
795    /// record.
796    async fn register_prompts(&self, manifest: &PluginManifest) -> PluginResult<Vec<String>> {
797        if manifest.provides.prompts.is_empty() {
798            return Ok(Vec::new());
799        }
800
801        let path = self.prompt_presets_path();
802        let mut store = load_store(&path).await.map_err(|error| {
803            PluginError::Registration(format!("failed to load prompt-presets.json: {error}"))
804        })?;
805
806        let mut existing_ids: HashSet<String> = store
807            .prompts
808            .iter()
809            .map(|preset| preset.id.clone())
810            .collect();
811        // `general_assistant` (bamboo-server's DEFAULT_PRESET_ID) is never a
812        // row in the store, so it wouldn't otherwise appear in `existing_ids`
813        // — but manifest validation already rejects any plugin declaring it
814        // (RESERVED_PRESET_IDS), so no extra guard is needed here.
815
816        let mut actual_ids = Vec::with_capacity(manifest.provides.prompts.len());
817        for preset in &manifest.provides.prompts {
818            let actual_id = ensure_unique_preset_id(&preset.id, &existing_ids);
819            store.prompts.push(StoredPromptPreset {
820                id: actual_id.clone(),
821                name: preset.name.clone(),
822                description: preset.description.clone(),
823                content: preset.content.clone(),
824            });
825            existing_ids.insert(actual_id.clone());
826            actual_ids.push(actual_id);
827        }
828
829        save_store(&path, &store).await.map_err(|error| {
830            PluginError::Registration(format!("failed to persist prompt-presets.json: {error}"))
831        })?;
832
833        Ok(actual_ids)
834    }
835
836    /// Step 3: validate legacy plugin workflows for in-place discovery.
837    ///
838    /// Workflow markdown stays under `<plugin_dir>/workflows`; the SkillStore
839    /// discovers it as a read-only legacy adapter. Nothing is copied into the
840    /// shared global workflows directory, so a plugin filename can never
841    /// conflict with or overwrite a user's own legacy workflow.
842    async fn validate_workflows_in_place(
843        &self,
844        manifest: &PluginManifest,
845        plugin_dir: &Path,
846    ) -> PluginResult<()> {
847        if manifest.provides.workflows.is_empty() {
848            return Ok(());
849        }
850
851        let workflows_dir = plugin_dir.join("workflows");
852        let directory_metadata = fs::symlink_metadata(&workflows_dir).await?;
853        if !directory_metadata.is_dir() || directory_metadata.file_type().is_symlink() {
854            return Err(PluginError::InvalidManifest(
855                "plugin workflows must live in a real workflows directory".to_string(),
856            ));
857        }
858        let declared: HashSet<&str> = manifest
859            .provides
860            .workflows
861            .iter()
862            .map(String::as_str)
863            .collect();
864        let mut actual = HashSet::new();
865        let mut entries = fs::read_dir(&workflows_dir).await?;
866        while let Some(entry) = entries.next_entry().await? {
867            let path = entry.path();
868            if path.extension().and_then(|value| value.to_str()) != Some("md") {
869                continue;
870            }
871            let file_type = entry.file_type().await?;
872            let Some(filename) = entry.file_name().to_str().map(str::to_string) else {
873                return Err(PluginError::InvalidManifest(
874                    "plugin workflow filename must be UTF-8".to_string(),
875                ));
876            };
877            if !file_type.is_file() || file_type.is_symlink() {
878                return Err(PluginError::InvalidManifest(format!(
879                    "workflow '{filename}' must be a regular in-place markdown file"
880                )));
881            }
882            actual.insert(filename);
883        }
884        if actual.len() != declared.len()
885            || !actual.iter().all(|name| declared.contains(name.as_str()))
886        {
887            return Err(PluginError::InvalidManifest(
888                "provides.workflows must declare every workflows/*.md file exactly once"
889                    .to_string(),
890            ));
891        }
892
893        for filename in &manifest.provides.workflows {
894            let stem = filename.strip_suffix(".md").unwrap_or(filename);
895            if !bamboo_config::paths::is_safe_workflow_name(stem) {
896                return Err(PluginError::InvalidManifest(format!(
897                    "workflow filename '{filename}' is not a safe workflow name"
898                )));
899            }
900            let source_path = workflows_dir.join(filename);
901            let metadata = fs::symlink_metadata(&source_path).await?;
902            if !metadata.file_type().is_file() || metadata.file_type().is_symlink() {
903                return Err(PluginError::InvalidManifest(format!(
904                    "workflow '{filename}' must be a regular in-place markdown file"
905                )));
906            }
907        }
908        Ok(())
909    }
910
911    /// **Same-id upgrade ordering** (issue #479): after a source candidate is
912    /// prepared and its global ownership audit succeeds, the HTTP update path
913    /// calls this while retaining [`PluginOperationGuard`], then activates the
914    /// bundle and invokes [`Self::install_with_operation`]. A still-running old
915    /// process can therefore neither hold the replaced binary open nor run
916    /// stale code after the swap. Net effect: preflight → stop old binary →
917    /// swap → start new binary, all in one serialized operation boundary.
918    ///
919    /// Returns exactly the ids that were actually running and got stopped
920    /// (not e.g. an already-stopped or unknown id). If any later source
921    /// transaction step fails, those services deliberately remain stopped
922    /// for explicit operator recovery. A plugin with no prior install (or no
923    /// services) returns an empty vec and stops nothing.
924    pub(crate) async fn stop_services_for_upgrade(&self, plugin_id: &str) -> Vec<String> {
925        let store = match InstalledPlugins::load(&self.installed_json_path()).await {
926            Ok(store) => store,
927            Err(error) => {
928                tracing::warn!(
929                    %plugin_id,
930                    %error,
931                    "stop_services_for_upgrade: failed to load installed.json; skipping"
932                );
933                return Vec::new();
934            }
935        };
936        let entry = match store.get_unique(plugin_id) {
937            Ok(Some(entry)) => entry,
938            Ok(None) => return Vec::new(),
939            Err(error) => {
940                tracing::warn!(
941                    %plugin_id,
942                    %error,
943                    "stop_services_for_upgrade: ambiguous plugin provenance; skipping"
944                );
945                return Vec::new();
946            }
947        };
948        let mut stopped = Vec::with_capacity(entry.registered.service_ids.len());
949        self.state
950            .tool_event_router
951            .unregister_sinks(
952                &entry
953                    .registered
954                    .removal_order()
955                    .event_sink_ids_before_services,
956            )
957            .await;
958        for service_id in &entry.registered.service_ids {
959            match self.state.service_manager.stop_service(service_id).await {
960                Ok(()) => stopped.push(service_id.clone()),
961                Err(error) => tracing::debug!(
962                    service_id = %service_id,
963                    %error,
964                    "stop_services_for_upgrade: service was not running; nothing to stop"
965                ),
966            }
967        }
968        stopped
969    }
970
971    pub(crate) async fn install_with_operation(
972        &self,
973        manifest: &PluginManifest,
974        plugin_dir: &Path,
975        source: PluginSource,
976        disposition: InstallDisposition,
977        installed_at: DateTime<Utc>,
978        _guard: &PluginOperationGuard,
979    ) -> PluginResult<InstalledPlugin> {
980        self.install_with_operation_inner(
981            manifest,
982            plugin_dir,
983            source,
984            disposition,
985            installed_at,
986            _guard,
987            None,
988            InstallFailureInjection::default(),
989        )
990        .await
991    }
992
993    /// Prepared-source install seam carrying a preflighted, canonical host
994    /// grant target. The inner transaction validates it again while holding
995    /// the plugin operation lock and before any journal/shared-store/runtime
996    /// mutation.
997    pub(crate) async fn install_with_operation_and_event_sink_grants(
998        &self,
999        manifest: &PluginManifest,
1000        plugin_dir: &Path,
1001        source: PluginSource,
1002        disposition: InstallDisposition,
1003        installed_at: DateTime<Utc>,
1004        grants: &EventSinkPermissionGrants,
1005        guard: &PluginOperationGuard,
1006    ) -> PluginResult<InstalledPlugin> {
1007        self.install_with_operation_inner(
1008            manifest,
1009            plugin_dir,
1010            source,
1011            disposition,
1012            installed_at,
1013            guard,
1014            Some(grants),
1015            InstallFailureInjection::default(),
1016        )
1017        .await
1018    }
1019
1020    /// Deterministic test seam immediately after the Step-0 drop-diff and
1021    /// crash-safety journal, but before every prior sink is revoked for
1022    /// possible same-id service replacement.
1023    #[cfg(all(test, unix))]
1024    pub(crate) async fn install_with_operation_failing_before_service_replacement(
1025        &self,
1026        manifest: &PluginManifest,
1027        plugin_dir: &Path,
1028        source: PluginSource,
1029        disposition: InstallDisposition,
1030        installed_at: DateTime<Utc>,
1031        guard: &PluginOperationGuard,
1032    ) -> PluginResult<InstalledPlugin> {
1033        self.install_with_operation_inner(
1034            manifest,
1035            plugin_dir,
1036            source,
1037            disposition,
1038            installed_at,
1039            guard,
1040            None,
1041            InstallFailureInjection {
1042                before_service_replacement: true,
1043                ..InstallFailureInjection::default()
1044            },
1045        )
1046        .await
1047    }
1048
1049    /// Deterministic test seam for the final `Installing` -> `Installed`
1050    /// provenance commit. It exercises the complete registration and abort
1051    /// path without depending on platform-specific chmod/locking behavior.
1052    #[cfg(test)]
1053    pub(crate) async fn install_with_operation_failing_final_commit(
1054        &self,
1055        manifest: &PluginManifest,
1056        plugin_dir: &Path,
1057        source: PluginSource,
1058        disposition: InstallDisposition,
1059        installed_at: DateTime<Utc>,
1060        prepared_event_sink_grants: Option<&EventSinkPermissionGrants>,
1061        guard: &PluginOperationGuard,
1062    ) -> PluginResult<InstalledPlugin> {
1063        self.install_with_operation_inner(
1064            manifest,
1065            plugin_dir,
1066            source,
1067            disposition,
1068            installed_at,
1069            guard,
1070            prepared_event_sink_grants,
1071            InstallFailureInjection {
1072                final_provenance_commit: true,
1073                ..InstallFailureInjection::default()
1074            },
1075        )
1076        .await
1077    }
1078
1079    async fn install_with_operation_inner(
1080        &self,
1081        manifest: &PluginManifest,
1082        plugin_dir: &Path,
1083        source: PluginSource,
1084        disposition: InstallDisposition,
1085        installed_at: DateTime<Utc>,
1086        _guard: &PluginOperationGuard,
1087        prepared_event_sink_grants: Option<&EventSinkPermissionGrants>,
1088        failure_injection: InstallFailureInjection,
1089    ) -> PluginResult<InstalledPlugin> {
1090        let installed_json_path = self.installed_json_path();
1091
1092        // Disposition gate (AlreadyInstalled only for a COMPLETED prior
1093        // install; an `Installing` leftover is returned for recovery) + the
1094        // rest of the pure, AppState-free validation this crate can already
1095        // do (manifest shape, platform gate, on-disk skill/workflow
1096        // existence, `provides.skills` authoritativeness).
1097        let previous =
1098            load_previous_for_disposition(&installed_json_path, &manifest.id, disposition).await?;
1099        let resolved_mcp_servers = preflight_install(manifest, plugin_dir).await?;
1100        let event_sink_grants = match prepared_event_sink_grants {
1101            Some(grants) => canonicalize_persisted_event_sink_grants(manifest, grants)?,
1102            None => resolve_event_sink_grants(
1103                manifest,
1104                previous.as_ref().map(|entry| &entry.registered),
1105                None,
1106            )?,
1107        };
1108
1109        // Re-run under the held operation guard as defense in depth. The HTTP
1110        // prepared-source path performs the same audit before bundle swap or
1111        // old-service shutdown; direct trait callers still get this gate
1112        // before installer-owned provenance/config/runtime mutation.
1113        let event_sink_reconciliation = self.preflight_provenance_ownership(manifest).await?;
1114        let declared_event_sink_ids = event_sink_reconciliation.to_register.clone();
1115
1116        // The set this install INTENDS to own, by declaration order. Used both
1117        // for the crash-safety journal row (below) and the step-0 drop-diff.
1118        let intended = RegisteredCapabilities {
1119            mcp_server_ids: manifest
1120                .provides
1121                .mcp_servers
1122                .iter()
1123                .map(|entry| entry.id.clone())
1124                .collect(),
1125            skill_dirs: manifest.provides.skills.clone(),
1126            preset_ids: manifest
1127                .provides
1128                .prompts
1129                .iter()
1130                .map(|preset| preset.id.clone())
1131                .collect(),
1132            // New workflow publications are discovered in place. Keeping this
1133            // legacy copied-file field empty also makes an upgrade clean up
1134            // files copied by pre-#561 installs through the drop-diff below.
1135            workflow_filenames: Vec::new(),
1136            service_ids: manifest
1137                .provides
1138                .services
1139                .iter()
1140                .map(|entry| entry.id.clone())
1141                .collect(),
1142            event_sink_ids: declared_event_sink_ids,
1143            event_sink_grants: event_sink_grants.clone(),
1144        };
1145
1146        // Step 0: upgrade drop-diff. Computed from the NEW manifest's plain
1147        // declared ids (see module docs re: the preset-rename caveat) vs the
1148        // OLD install's registered set — de-register whatever the new version
1149        // no longer declares BEFORE registering anything new (BLOCKER 2). Also
1150        // fires for a recovery over an `Installing` leftover: its intended set
1151        // is diffed the same way, so a crashed attempt's extra ids get cleaned.
1152        if let Some(previous) = &previous {
1153            let dropped = intended.removed_since(&previous.registered);
1154            if !dropped.is_empty() {
1155                tracing::info!(
1156                    plugin_id = %manifest.id,
1157                    recovering = previous.status == PluginInstallStatus::Installing,
1158                    dropped_mcp = ?dropped.mcp_server_ids,
1159                    dropped_presets = ?dropped.preset_ids,
1160                    dropped_workflows = ?dropped.workflow_filenames,
1161                    dropped_services = ?dropped.service_ids,
1162                    dropped_event_sinks = ?dropped.event_sink_ids,
1163                    "install drop-diff: de-registering capabilities the new/completed version no longer declares"
1164                );
1165                self.deregister_upgrade_drop_diff(&manifest.id, &previous.registered, &dropped)
1166                    .await;
1167            }
1168        }
1169
1170        let previously_owned_mcp = previous
1171            .as_ref()
1172            .map(|p| p.registered.mcp_server_ids.clone())
1173            .unwrap_or_default();
1174        // Crash-safety journal: write an `Installing` provenance row recording
1175        // the INTENDED ownership set BEFORE mutating any shared store, so a
1176        // hard kill mid-install leaves a recoverable marker (see module docs
1177        // "Crash safety"). On a fresh install this creates the row; on an
1178        // upgrade/recovery it overwrites the prior row.
1179        self.upsert_provenance(
1180            InstalledPlugin {
1181                id: manifest.id.clone(),
1182                version: manifest.version.clone(),
1183                source: source.clone(),
1184                plugin_dir: plugin_dir.to_path_buf(),
1185                installed_at,
1186                status: PluginInstallStatus::Installing,
1187                registered: intended.clone(),
1188            },
1189            &installed_json_path,
1190        )
1191        .await?;
1192
1193        let mut rollback = InstallRollback::default();
1194
1195        // Step 1: MCP.
1196        let mcp_server_ids = match self
1197            .register_mcp(
1198                manifest,
1199                resolved_mcp_servers,
1200                &previously_owned_mcp,
1201                &mut rollback,
1202            )
1203            .await
1204        {
1205            Ok(ids) => ids,
1206            Err(error) => {
1207                self.abort_install(&rollback, &previous, &manifest.id, &installed_json_path)
1208                    .await;
1209                return Err(error);
1210            }
1211        };
1212
1213        if failure_injection.before_service_replacement {
1214            let error = PluginError::Registration(
1215                "injected failure before service replacement".to_string(),
1216            );
1217            self.abort_install(&rollback, &previous, &manifest.id, &installed_json_path)
1218                .await;
1219            return Err(error);
1220        }
1221
1222        // Direct `PluginInstaller::install(..., Upgrade, ...)` callers do not
1223        // pass through plugin_source's pre-swap stop hook. Revoke every prior
1224        // sink generation at the last common point before `register_services`
1225        // can stop/replace a same-id service. When Step 0 did not drop an old
1226        // service, keeping this after the journal and MCP step means an
1227        // earlier failure leaves that still-running service and route intact.
1228        // Step 0 revoked only sinks backed by services it actually dropped;
1229        // this full revoke is therefore always required before retained
1230        // same-id service replacement. The source path may also have revoked
1231        // the set already; unregister is intentionally idempotent.
1232        if let Some(previous) = &previous {
1233            self.state
1234                .tool_event_router
1235                .unregister_sinks(&previous.registered.event_sink_ids)
1236                .await;
1237        }
1238
1239        // Step 1b: Services (issue #479). Runs right after MCP, before the
1240        // never-refusing Prompts step, so a services conflict fails the
1241        // install as early as the other REFUSE-on-conflict kinds do. Note:
1242        // for a same-id UPGRADE, the OLD service (if any) was already stopped
1243        // after prepared-candidate ownership preflight and before bundle
1244        // activation — see `stop_services_for_upgrade`'s ordering contract.
1245        let service_ids = match self
1246            .register_services(manifest, plugin_dir, &mut rollback)
1247            .await
1248        {
1249            Ok(ids) => ids,
1250            Err(error) => {
1251                self.abort_install(&rollback, &previous, &manifest.id, &installed_json_path)
1252                    .await;
1253                return Err(error);
1254            }
1255        };
1256
1257        // Step 2: Prompts.
1258        let preset_ids = match self.register_prompts(manifest).await {
1259            Ok(ids) => {
1260                rollback.preset_ids_added = ids.clone();
1261                ids
1262            }
1263            Err(error) => {
1264                self.abort_install(&rollback, &previous, &manifest.id, &installed_json_path)
1265                    .await;
1266                return Err(error);
1267            }
1268        };
1269
1270        // Step 3: Workflows remain inside the plugin bundle and are discovered
1271        // by the shared SkillStore as read-only legacy adapters.
1272        let workflow_filenames = match self.validate_workflows_in_place(manifest, plugin_dir).await
1273        {
1274            Ok(()) => Vec::new(),
1275            Err(error) => {
1276                self.abort_install(&rollback, &previous, &manifest.id, &installed_json_path)
1277                    .await;
1278                return Err(error);
1279            }
1280        };
1281
1282        // Step 4: Skills — nothing to register, just record the
1283        // declared+validated dir names (preflight_install already confirmed
1284        // every declared dir exists and that no undeclared dir is present).
1285        let skill_dirs = manifest.provides.skills.clone();
1286
1287        // Step 5: commit provenance — flip the journal row to `Installed` with
1288        // the ACTUAL registered set (renamed preset ids, the to_register mcp/
1289        // workflow subsets). Only reached once 0-4 all succeeded.
1290        let registered = RegisteredCapabilities {
1291            mcp_server_ids,
1292            skill_dirs,
1293            preset_ids,
1294            workflow_filenames,
1295            service_ids,
1296            event_sink_ids: event_sink_reconciliation.to_register,
1297            event_sink_grants,
1298        };
1299        let runtime_sink_plan = reconcile_event_sinks(
1300            manifest,
1301            &registered,
1302            PluginInstallStatus::Installed,
1303            Platform::current(),
1304        )?;
1305        let entry = InstalledPlugin {
1306            id: manifest.id.clone(),
1307            version: manifest.version.clone(),
1308            source,
1309            plugin_dir: plugin_dir.to_path_buf(),
1310            installed_at,
1311            status: PluginInstallStatus::Installed,
1312            registered,
1313        };
1314        let final_commit = if failure_injection.final_provenance_commit {
1315            Err(PluginError::Registration(
1316                "injected final Installed provenance commit failure".to_string(),
1317            ))
1318        } else {
1319            self.upsert_provenance(entry.clone(), &installed_json_path)
1320                .await
1321        };
1322        if let Err(error) = final_commit {
1323            self.abort_install(&rollback, &previous, &manifest.id, &installed_json_path)
1324                .await;
1325            return Err(error);
1326        }
1327
1328        // Provenance is committed before runtime publication. The router
1329        // records eligible/inactive declarations now, but only creates a live
1330        // queue after ServiceManager exposes a Ready exact-generation sender.
1331        if let Err(error) = self
1332            .state
1333            .tool_event_router
1334            .apply_plugin_plan(
1335                &manifest.id,
1336                manifest,
1337                &runtime_sink_plan,
1338                &entry.registered.event_sink_grants,
1339            )
1340            .await
1341        {
1342            // The same canonical map was validated before any mutation under
1343            // the operation guard. A mismatch here therefore indicates an
1344            // internal invariant failure; keep delivery fail-closed without
1345            // misreporting the already committed plugin transaction as rolled
1346            // back.
1347            tracing::error!(
1348                plugin_id = %manifest.id,
1349                %error,
1350                "committed plugin event sinks could not be published"
1351            );
1352        }
1353
1354        Ok(entry)
1355    }
1356}
1357
1358#[async_trait]
1359impl PluginInstaller for ServerPluginInstaller {
1360    async fn install(
1361        &self,
1362        manifest: &PluginManifest,
1363        plugin_dir: &Path,
1364        source: PluginSource,
1365        disposition: InstallDisposition,
1366        installed_at: DateTime<Utc>,
1367    ) -> PluginResult<InstalledPlugin> {
1368        let guard = self.begin_operation().await;
1369        self.install_with_operation(
1370            manifest,
1371            plugin_dir,
1372            source,
1373            disposition,
1374            installed_at,
1375            &guard,
1376        )
1377        .await
1378    }
1379
1380    async fn uninstall(&self, id: &str) -> PluginResult<()> {
1381        // Serialize against every other plugin op (see module docs).
1382        let _op_guard = PLUGIN_OP_LOCK.lock().await;
1383
1384        let installed_json_path = self.installed_json_path();
1385        let mut store = InstalledPlugins::load(&installed_json_path).await?;
1386        // Works on an `Installing` (crash-leftover) row too, so a crashed
1387        // install is never un-uninstallable.
1388        let Some(entry) = store.get_unique(id)?.cloned() else {
1389            return Err(PluginError::NotFound(id.to_string()));
1390        };
1391
1392        // De-register everything this plugin's `registered` set names — by
1393        // construction (see bamboo-plugin's ownership contract) this can
1394        // only ever be entries the plugin itself created. Idempotent: a
1395        // manually-removed entry is logged and skipped, never a hard error.
1396        self.deregister_capabilities(&entry.registered).await;
1397
1398        // Remove the plugin's own files BEFORE clearing provenance: if this
1399        // fails (e.g. a permission error), provenance is left intact so a
1400        // retry is safe (the de-registration above is idempotent, so
1401        // re-running it is a harmless no-op) rather than leaving an
1402        // unregistered-but-still-on-disk `skills/` dir that discovery would
1403        // keep picking up despite `uninstall` having "succeeded".
1404        match fs::remove_dir_all(&entry.plugin_dir).await {
1405            Ok(()) => {}
1406            Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
1407            Err(error) => return Err(PluginError::Io(error)),
1408        }
1409
1410        store.remove(id);
1411        store.save(&installed_json_path).await?;
1412        Ok(())
1413    }
1414
1415    async fn list(&self) -> PluginResult<Vec<InstalledPlugin>> {
1416        let store = InstalledPlugins::load(&self.installed_json_path()).await?;
1417        Ok(store.plugins)
1418    }
1419}
1420
1421// ---------------------------------------------------------------------
1422// Free helpers shared between `ServerPluginInstaller` (instance methods
1423// above, which delegate here) and `boot_reconcile_services` (which has no
1424// `ServerPluginInstaller`/`AppState` handle to call instance methods on —
1425// see `app_state::builder`, which calls it before `AppState` finishes
1426// constructing).
1427// ---------------------------------------------------------------------
1428
1429/// See `ServerPluginInstaller::service_config_path`'s doc comment for the
1430/// full rationale (kept there since that's the reader's first encounter).
1431fn service_config_path_under(app_data_dir: &Path, plugin_id: &str) -> PathBuf {
1432    app_data_dir
1433        .join("plugin_service_config")
1434        .join(plugin_id)
1435        .join("config.json")
1436}
1437
1438fn resolve_service_config_under(
1439    app_data_dir: &Path,
1440    plugin_id: &str,
1441    entry: &ServiceManifestEntry,
1442    plugin_dir: &Path,
1443    platform: Platform,
1444) -> ServiceRuntimeConfig {
1445    let resolved = entry.resolve(plugin_dir, plugin_id, platform);
1446    ServiceRuntimeConfig {
1447        id: resolved.id,
1448        plugin_id: plugin_id.to_string(),
1449        name: resolved.name,
1450        command: resolved.command,
1451        args: resolved.args,
1452        cwd: resolved.cwd,
1453        env: resolved.env,
1454        health_check: resolved.health_check,
1455        restart_policy: resolved.restart_policy,
1456        graceful_shutdown: resolved.graceful_shutdown,
1457        input_protocol: resolved.input_protocol,
1458        user_config_path: service_config_path_under(app_data_dir, plugin_id),
1459    }
1460}
1461
1462/// Boot-time reconcile (issue #479): start every ENABLED, plugin-owned
1463/// service that `installed.json` says should be running but has no live
1464/// [`ServiceManager`] runtime — the previous `bamboo serve` process (if any)
1465/// died along with every service it supervised (child processes are spawned
1466/// `kill_on_drop`, and nothing about a running service persists
1467/// cross-process). Called from `app_state::builder` the same way
1468/// `app_state::init::init_mcp_manager` kicks off its background MCP
1469/// bootstrap — the caller is expected to `tokio::spawn` this, NOT await it
1470/// inline, so server startup is never blocked on plugin service spawns.
1471///
1472/// Deliberately reads `installed.json` + each plugin's on-disk
1473/// `plugin.json` directly rather than going through `ServerPluginInstaller`
1474/// (which needs a fully-built `web::Data<AppState>` this runs before).
1475pub async fn boot_reconcile_services(
1476    app_data_dir: &Path,
1477    service_manager: &ServiceManager,
1478    tool_event_router: &std::sync::Arc<ToolEventRouter>,
1479) {
1480    // Boot reads a provenance snapshot and then mutates both service and sink
1481    // generations from that plan. Serialize the whole pass with install,
1482    // update, and uninstall so an old boot plan can never unregister or
1483    // overwrite a route those operations just committed.
1484    let _op_guard = PLUGIN_OP_LOCK.lock().await;
1485    let installed_json_path = app_data_dir.join("plugins").join("installed.json");
1486    let store = match InstalledPlugins::load(&installed_json_path).await {
1487        Ok(store) => store,
1488        Err(error) => {
1489            tracing::warn!(
1490                %error,
1491                "service boot-reconcile: failed to load installed.json; skipping"
1492            );
1493            return;
1494        }
1495    };
1496
1497    let mut candidates = Vec::with_capacity(store.list().len());
1498    for plugin in store.list() {
1499        let manifest_path = plugin.plugin_dir.join("plugin.json");
1500        let manifest = fs::read_to_string(&manifest_path)
1501            .await
1502            .ok()
1503            .and_then(|raw| PluginManifest::parse_str(&raw).ok());
1504        candidates.push(PluginBootCandidate {
1505            installed: plugin.clone(),
1506            manifest,
1507        });
1508    }
1509
1510    let platform = Platform::current();
1511    let plans = reconcile_plugin_boot(&candidates, platform);
1512    let Some(platform) = platform else {
1513        tracing::warn!(
1514            host_os = std::env::consts::OS,
1515            "service boot-reconcile: unknown host platform; all plugin services remain stopped"
1516        );
1517        return;
1518    };
1519
1520    for (candidate, plan) in candidates.iter().zip(plans) {
1521        let plugin = &candidate.installed;
1522        tool_event_router
1523            .unregister_sinks(&plan.event_sinks.deactivate_before_services)
1524            .await;
1525        for issue in &plan.issues {
1526            tracing::warn!(
1527                plugin_id = %plugin.id,
1528                ?issue,
1529                "service boot-reconcile: provenance audit kept capabilities inactive"
1530            );
1531        }
1532        let Some(manifest) = candidate.manifest.as_ref() else {
1533            continue;
1534        };
1535
1536        // Durable grants are executable authority. Validate the complete map
1537        // before starting any plugin-owned process so a corrupt nonempty row
1538        // cannot run code while its observation policy remains unavailable.
1539        let event_sink_grants = match canonicalize_persisted_event_sink_grants(
1540            manifest,
1541            &plugin.registered.event_sink_grants,
1542        ) {
1543            Ok(grants) => grants,
1544            Err(error) => {
1545                tracing::warn!(
1546                    plugin_id = %plugin.id,
1547                    %error,
1548                    "service boot-reconcile: invalid persisted event-sink grants kept plugin capabilities unavailable"
1549                );
1550                continue;
1551            }
1552        };
1553
1554        let to_start: HashSet<&str> = plan
1555            .service_ids_to_start
1556            .iter()
1557            .map(String::as_str)
1558            .collect();
1559        for entry in &manifest.provides.services {
1560            if !to_start.contains(entry.id.as_str()) {
1561                continue;
1562            }
1563            if service_manager.is_running(&entry.id) {
1564                continue;
1565            }
1566            let config = resolve_service_config_under(
1567                app_data_dir,
1568                &plugin.id,
1569                entry,
1570                &plugin.plugin_dir,
1571                platform,
1572            );
1573            if let Some(parent) = config.user_config_path.parent() {
1574                if let Err(error) = fs::create_dir_all(parent).await {
1575                    tracing::warn!(
1576                        service_id = %entry.id,
1577                        plugin_id = %plugin.id,
1578                        %error,
1579                        "service boot-reconcile: failed to create service config parent dir"
1580                    );
1581                }
1582            }
1583            match service_manager.start_service(config).await {
1584                Ok(()) => tracing::info!(
1585                    service_id = %entry.id,
1586                    plugin_id = %plugin.id,
1587                    "service boot-reconcile: started"
1588                ),
1589                Err(error) => tracing::warn!(
1590                    service_id = %entry.id,
1591                    plugin_id = %plugin.id,
1592                    %error,
1593                    "service boot-reconcile: failed to start"
1594                ),
1595            }
1596        }
1597        if let Err(error) = tool_event_router
1598            .apply_plugin_plan(&plugin.id, manifest, &plan.event_sinks, &event_sink_grants)
1599            .await
1600        {
1601            tracing::warn!(
1602                plugin_id = %plugin.id,
1603                %error,
1604                "service boot-reconcile: event-sink grants failed router preflight"
1605            );
1606        }
1607    }
1608}
1609
1610#[cfg(test)]
1611mod tests;