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 ®istered.mcp_server_ids {
342 self.remove_mcp_server(mcp_id).await;
343 }
344 for preset_id in ®istered.preset_ids {
345 self.remove_prompt_preset(preset_id).await;
346 }
347 for workflow_filename in ®istered.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;