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(®istered.removal_order().event_sink_ids_before_services)
518 .await;
519 for mcp_id in ®istered.mcp_server_ids {
520 self.remove_mcp_server(mcp_id).await;
521 }
522 for preset_id in ®istered.preset_ids {
523 self.remove_prompt_preset(preset_id).await;
524 }
525 for workflow_filename in ®istered.workflow_filenames {
526 self.remove_workflow_file(workflow_filename).await;
527 }
528 for service_id in ®istered.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 ®istered,
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;