Skip to main content

bamboo_plugin/
installer.rs

1//! The `PluginInstaller` trait — the method surface later agents implement.
2//!
3//! This crate lives at the `infra` layer and has no access to `AppState`
4//! (`bamboo-server`, an `app`-layer crate). Actually registering capabilities
5//! — merging into `config.json`, calling `mcp_manager.start_server`, appending
6//! to `prompt-presets.json`, validating in-place workflow publication — all need `AppState` (or
7//! equivalent handles), so a REAL implementation of this trait has to live in
8//! `bamboo-server` (or a sibling crate that depends on it), implemented by a
9//! later agent (see `PLUGIN_PLAN.md` § Installer-core agent). Because the
10//! trait is foreign there and the implementing type is local, that's a
11//! perfectly ordinary downstream `impl` — no orphan-rule issue.
12//!
13//! [`LocalPluginInstaller`] below is a reference skeleton that implements
14//! everything this crate CAN implement without `AppState` (manifest
15//! validation, platform gating, MCP-entry token resolution, the
16//! disposition/upgrade decision, the `provides.skills`-authoritative check,
17//! provenance listing) and returns [`PluginError::NotImplemented`] at the
18//! exact points that need capability-registration wiring, with a comment
19//! enumerating what goes there and citing the exact files. It is a
20//! reference/example, not a requirement to reuse verbatim — Wave-2's
21//! installer-core agent may replace it entirely with a type that holds an
22//! `AppState` handle.
23//!
24//! # Ownership + upgrade contract (why uninstall is provably safe)
25//!
26//! Two invariants make uninstall/upgrade never touch a user's own entries:
27//!
28//! 1. **Only plugin-created entries are ever recorded as removable.** For MCP
29//!    servers, the
30//!    installer MUST run [`crate::registry::reconcile_exclusive`] against the
31//!    live shared store before touching anything: a declared id/filename that
32//!    already exists and is not owned by this plugin lands in
33//!    `foreign_conflicts` and the install is REFUSED
34//!    ([`PluginError::Conflict`]) — it is never registered and never written
35//!    into [`crate::registry::RegisteredCapabilities`]. So the `registered`
36//!    set an [`InstalledPlugin`] carries contains ONLY entries this plugin
37//!    genuinely created; `uninstall` iterating that set can only ever delete
38//!    the plugin's own entries. (Prompt presets are the one exception: they
39//!    rename on collision via bamboo-server's `ensure_unique_preset_id`
40//!    instead of refusing, and the RENAMED id is what gets recorded.)
41//! 2. **Upgrade de-registers what the new version dropped.** `install` with
42//!    [`InstallDisposition::Upgrade`] loads the prior [`InstalledPlugin`],
43//!    computes [`crate::registry::RegisteredCapabilities::removed_since`] (old
44//!    minus new), de-registers those dropped capabilities, THEN registers the
45//!    new set and upserts provenance — so a capability the old version had and
46//!    the new one dropped can't leak as an orphan.
47
48use std::path::{Path, PathBuf};
49
50use async_trait::async_trait;
51use chrono::{DateTime, Utc};
52
53use crate::error::{PluginError, PluginResult};
54use crate::manifest::{Platform, PluginManifest};
55use crate::registry::{InstalledPlugin, InstalledPlugins, PluginSource};
56
57/// How [`PluginInstaller::install`] must treat a plugin id that is ALREADY
58/// installed. Maps directly to the CLI verbs: `bamboo plugin install` uses
59/// [`Self::FailIfInstalled`], `bamboo plugin update` (or `install --force`)
60/// uses [`Self::Upgrade`].
61#[derive(Debug, Clone, Copy, PartialEq, Eq)]
62pub enum InstallDisposition {
63    /// Refuse a re-install of an already-installed id with
64    /// [`PluginError::AlreadyInstalled`]. A first-time `install` should not
65    /// silently replace an existing plugin.
66    FailIfInstalled,
67    /// Upgrade in place: de-register the capabilities the new version dropped
68    /// (via [`crate::registry::RegisteredCapabilities::removed_since`]),
69    /// register the new set, then upsert provenance.
70    Upgrade,
71}
72
73/// The installer method surface. `install`/`uninstall`/`list` are the verbs
74/// the CLI (`bamboo plugin install/list/remove/update`) and the HTTP routes
75/// (`/api/v1/plugins`) both call through.
76#[async_trait]
77pub trait PluginInstaller {
78    /// Install (or, with [`InstallDisposition::Upgrade`], upgrade) a plugin
79    /// already unpacked at `plugin_dir` (source handling — copying a local
80    /// dir, unpacking a `.tar.gz`, fetching+verifying a URL + selecting the
81    /// per-platform artifact — happens BEFORE this is called; by the time
82    /// `install` runs, `plugin_dir` already contains `plugin.json` plus the
83    /// `skills/`/`prompts/`/`workflows/`/`bin/` layout the manifest declares).
84    ///
85    /// Contract (see the module docs for the full rationale):
86    /// - `disposition` decides already-installed handling (fail vs upgrade).
87    /// - MCP-server collisions with non-plugin entries must be refused through
88    ///   [`crate::registry::reconcile_exclusive`]. Workflow markdown remains
89    ///   isolated in place and therefore cannot clobber a user source.
90    /// - On upgrade, capabilities the new version dropped MUST be
91    ///   de-registered ([`crate::registry::RegisteredCapabilities::removed_since`]).
92    /// - The returned [`InstalledPlugin`] must have already been persisted
93    ///   into `installed.json`, and its `registered` set must contain ONLY
94    ///   entries this plugin genuinely created (so uninstall is safe).
95    async fn install(
96        &self,
97        manifest: &PluginManifest,
98        plugin_dir: &Path,
99        source: PluginSource,
100        disposition: InstallDisposition,
101        installed_at: DateTime<Utc>,
102    ) -> PluginResult<InstalledPlugin>;
103
104    /// Reverse everything `install` registered for `id` (stop + remove MCP
105    /// servers, remove prompt presets, and clean legacy copied workflow files), then remove
106    /// the provenance entry and delete `plugin_dir` from disk. Safe by
107    /// construction: the provenance `registered` set only ever names
108    /// plugin-created entries (invariant 1 in the module docs).
109    async fn uninstall(&self, id: &str) -> PluginResult<()>;
110
111    /// All currently-installed plugins (a thin read of `installed.json`).
112    async fn list(&self) -> PluginResult<Vec<InstalledPlugin>>;
113}
114
115/// Directory names directly under `<plugin_dir>/skills/` that contain a
116/// `SKILL.md` (i.e. what discovery would actually pick up in place). Used
117/// by [`preflight_install`] to enforce that `provides.skills` is
118/// authoritative (MAJOR 4).
119pub async fn on_disk_skill_dirs(plugin_dir: &Path) -> Vec<String> {
120    let skills_root = plugin_dir.join("skills");
121    let mut found = Vec::new();
122    let Ok(mut entries) = tokio::fs::read_dir(&skills_root).await else {
123        return found;
124    };
125    while let Ok(Some(entry)) = entries.next_entry().await {
126        let is_dir = entry
127            .file_type()
128            .await
129            .map(|file_type| file_type.is_dir())
130            .unwrap_or(false);
131        if !is_dir {
132            continue;
133        }
134        let has_skill_md = tokio::fs::try_exists(entry.path().join("SKILL.md"))
135            .await
136            .unwrap_or(false);
137        if !has_skill_md {
138            continue;
139        }
140        if let Some(name) = entry.file_name().to_str() {
141            found.push(name.to_string());
142        }
143    }
144    found
145}
146
147/// Everything an `install` can validate/resolve WITHOUT touching `AppState`:
148/// manifest validation, platform gating, per-entry MCP resolution (so a
149/// malformed entry — e.g. an empty stdio command — fails fast before any
150/// registration happens), and the `provides.skills` / `provides.workflows`
151/// on-disk existence + authoritative checks (MAJOR 4).
152///
153/// Shared by [`LocalPluginInstaller`] and any real `AppState`-backed
154/// installer (see `PLUGIN_PLAN.md` § Installer-core agent) so the two can
155/// never drift apart. Returns the resolved MCP server configs (the caller
156/// needs them anyway to register step 1) so a real installer doesn't have to
157/// re-resolve them a second time.
158pub async fn preflight_install(
159    manifest: &PluginManifest,
160    plugin_dir: &Path,
161) -> PluginResult<Vec<bamboo_domain::mcp_config::McpServerConfig>> {
162    manifest.validate()?;
163    crate::hooks::registrations(manifest, plugin_dir)?;
164
165    let current_platform = Platform::current();
166    if let Some(platforms) = &manifest.platforms {
167        // An unrecognized host OS (`current_platform == None`) fails closed
168        // rather than guessing.
169        let supported = current_platform.is_some_and(|platform| platforms.contains(&platform));
170        if !supported {
171            return Err(PluginError::UnsupportedPlatform {
172                plugin_id: manifest.id.clone(),
173                platform: current_platform
174                    .map(|platform| platform.as_str().to_string())
175                    .unwrap_or_else(|| std::env::consts::OS.to_string()),
176            });
177        }
178    }
179
180    // Resolve what each declared MCP server WOULD look like once registered.
181    // Pure — fails early on an unresolvable entry (e.g. empty stdio command)
182    // and hands the caller the resolved configs to register in step 1.
183    let platform = current_platform.unwrap_or(Platform::Linux);
184    let resolved_mcp_servers = manifest
185        .provides
186        .mcp_servers
187        .iter()
188        .map(|entry| entry.resolve(plugin_dir, &manifest.id, platform))
189        .collect::<PluginResult<Vec<_>>>()?;
190
191    // Sanity-check declared skill dirs exist on disk. Skills need no further
192    // action here — they're discovered in place by bamboo-skills' plugin
193    // discovery-dir extension, not copied.
194    for skill_dir in &manifest.provides.skills {
195        let skill_md = plugin_dir.join("skills").join(skill_dir).join("SKILL.md");
196        if !tokio::fs::try_exists(&skill_md).await.unwrap_or(false) {
197            return Err(PluginError::InvalidManifest(format!(
198                "declared skill '{skill_dir}' has no SKILL.md at {}",
199                skill_md.display()
200            )));
201        }
202    }
203    // `provides.skills` is AUTHORITATIVE: reject any on-disk skill dir the
204    // manifest does not declare. Discovery is a dumb globber that picks up
205    // every `<plugin_dir>/skills/*` with a SKILL.md, so without this a
206    // bundle could smuggle an undeclared skill live past its own manifest.
207    {
208        use std::collections::HashSet;
209        let declared: HashSet<&str> = manifest
210            .provides
211            .skills
212            .iter()
213            .map(String::as_str)
214            .collect();
215        for on_disk in on_disk_skill_dirs(plugin_dir).await {
216            if !declared.contains(on_disk.as_str()) {
217                return Err(PluginError::InvalidManifest(format!(
218                    "skill directory '{on_disk}' exists under skills/ but is not declared in \
219                     provides.skills (a plugin must declare every skill it ships)"
220                )));
221            }
222        }
223    }
224    for workflow_file in &manifest.provides.workflows {
225        let workflow_path = plugin_dir.join("workflows").join(workflow_file);
226        if !tokio::fs::try_exists(&workflow_path).await.unwrap_or(false) {
227            return Err(PluginError::InvalidManifest(format!(
228                "declared workflow '{workflow_file}' not found at {}",
229                workflow_path.display()
230            )));
231        }
232    }
233    let workflows_root = plugin_dir.join("workflows");
234    if tokio::fs::try_exists(&workflows_root)
235        .await
236        .unwrap_or(false)
237    {
238        let declared: std::collections::HashSet<&str> = manifest
239            .provides
240            .workflows
241            .iter()
242            .map(String::as_str)
243            .collect();
244        let mut actual = std::collections::HashSet::new();
245        let mut entries = tokio::fs::read_dir(&workflows_root).await?;
246        while let Some(entry) = entries.next_entry().await? {
247            let path = entry.path();
248            if path.extension().and_then(|value| value.to_str()) != Some("md") {
249                continue;
250            }
251            let Some(filename) = entry.file_name().to_str().map(str::to_string) else {
252                return Err(PluginError::InvalidManifest(
253                    "plugin workflow filename must be UTF-8".to_string(),
254                ));
255            };
256            actual.insert(filename);
257        }
258        if actual.len() != declared.len()
259            || !actual.iter().all(|name| declared.contains(name.as_str()))
260        {
261            return Err(PluginError::InvalidManifest(
262                "provides.workflows must declare every workflows/*.md file exactly once"
263                    .to_string(),
264            ));
265        }
266    }
267
268    Ok(resolved_mcp_servers)
269}
270
271/// Loads prior provenance for `plugin_id` from `installed_json_path` and
272/// applies the [`InstallDisposition`] gate: [`InstallDisposition::FailIfInstalled`]
273/// errors [`PluginError::AlreadyInstalled`] when a COMPLETED entry already
274/// exists; [`InstallDisposition::Upgrade`] passes through either way. Returns
275/// the previous entry (`None` for a fresh install), which the caller needs for
276/// the upgrade drop-diff (BLOCKER 2) and for crash recovery.
277///
278/// Crash-recovery exception: a previous row with
279/// [`crate::registry::PluginInstallStatus::Installing`] is a leftover from an
280/// interrupted install (a hard kill mid-registration), NOT a completed
281/// install — so it does NOT trip `AlreadyInstalled` even under
282/// `FailIfInstalled`. It is returned like any other `previous` so the caller
283/// treats its (intended) `registered` set as this-plugin-owned and cleans it
284/// up as an upgrade-over-incomplete, instead of the leftover's half-written
285/// entries reading as a foreign conflict on retry.
286pub async fn load_previous_for_disposition(
287    installed_json_path: &Path,
288    plugin_id: &str,
289    disposition: InstallDisposition,
290) -> PluginResult<Option<InstalledPlugin>> {
291    use crate::registry::PluginInstallStatus;
292
293    let existing = InstalledPlugins::load(installed_json_path).await?;
294    let previous = existing.get_unique(plugin_id)?.cloned();
295    let is_completed = previous
296        .as_ref()
297        .is_some_and(|plugin| plugin.status == PluginInstallStatus::Installed);
298    if is_completed && disposition == InstallDisposition::FailIfInstalled {
299        return Err(PluginError::AlreadyInstalled(plugin_id.to_string()));
300    }
301    Ok(previous)
302}
303
304/// Reference skeleton — see module docs. Holds only a `bamboo_dir` so tests
305/// can point it at a tempdir instead of the real `~/.bamboo`.
306pub struct LocalPluginInstaller {
307    bamboo_dir: PathBuf,
308}
309
310impl LocalPluginInstaller {
311    pub fn new(bamboo_dir: PathBuf) -> Self {
312        Self { bamboo_dir }
313    }
314
315    fn plugins_dir(&self) -> PathBuf {
316        self.bamboo_dir.join("plugins")
317    }
318
319    fn installed_json_path(&self) -> PathBuf {
320        self.plugins_dir().join("installed.json")
321    }
322}
323
324impl Default for LocalPluginInstaller {
325    fn default() -> Self {
326        Self::new(bamboo_config::paths::bamboo_dir())
327    }
328}
329
330#[async_trait]
331impl PluginInstaller for LocalPluginInstaller {
332    async fn install(
333        &self,
334        manifest: &PluginManifest,
335        plugin_dir: &Path,
336        _source: PluginSource,
337        disposition: InstallDisposition,
338        _installed_at: DateTime<Utc>,
339    ) -> PluginResult<InstalledPlugin> {
340        // --- Disposition / upgrade decision (fully implemented here) ---
341        //
342        // Load prior provenance FIRST so we can (a) reject a first-time
343        // install of an already-installed id, and (b) on upgrade, capture the
344        // old registered set for the drop-diff below.
345        let previous =
346            load_previous_for_disposition(&self.installed_json_path(), &manifest.id, disposition)
347                .await?;
348        // On upgrade this is the set the installer-core agent must
349        // `removed_since`-diff against the new registered set and de-register.
350        let _previous_registered = previous.as_ref().map(|plugin| plugin.registered.clone());
351
352        // --- Validation + pure path resolution (fully implemented here,
353        // shared with any real AppState-backed installer via
354        // `preflight_install`) ---
355        let _resolved_mcp_servers = preflight_install(manifest, plugin_dir).await?;
356
357        // --- Capability-registration wiring (TODO: installer-core agent) ---
358        //
359        // None of this can be implemented in this infra-layer crate — it all
360        // needs `AppState` (bamboo-server, an app-layer crate this crate must
361        // not depend on). See PLUGIN_PLAN.md § Installer-core agent for the
362        // full breakdown. In order:
363        //
364        //   0. UPGRADE DROP-DIFF (only when `previous` is Some): compute
365        //      `new_registered.removed_since(&_previous_registered.unwrap())`
366        //      and DE-register those dropped mcp ids / preset ids / workflow
367        //      files (same removal ops as `uninstall`) BEFORE registering the
368        //      new set, so a capability the old version had and the new one
369        //      dropped never leaks (BLOCKER 2).
370        //   1. MCP: run `registry::reconcile_exclusive(declared_mcp_ids,
371        //      existing_mcp_ids_in_config, previously_owned_mcp_ids)`. If
372        //      `foreign_conflicts` is non-empty → return `PluginError::Conflict`
373        //      (BLOCKER 1 — do NOT clobber). Otherwise merge only `to_register`
374        //      into `Config.mcp.servers` via `AppState::update_config`
375        //      (crates/app/bamboo-server/src/app_state/config_runtime.rs),
376        //      reusing the merge-by-id logic in
377        //      crates/app/bamboo-server/src/handlers/agent/mcp/server_handlers/import.rs
378        //      (`import_servers`), then `state.mcp_manager.start_server(..)`
379        //      for each enabled one. Record exactly `to_register` as owned.
380        //   2. Prompts: append `manifest.provides.prompts` into
381        //      `prompt-presets.json`
382        //      (crates/app/bamboo-server/src/handlers/agent/prompt_presets/storage.rs),
383        //      reusing `validate_preset_id` / `ensure_unique_preset_id` — on an
384        //      id collision RENAME (don't refuse), and record the RENAMED id as
385        //      owned (not the manifest's nominal one).
386        //   3. Workflows: validate each declared in-place
387        //      `<plugin_dir>/workflows/<name>.md` with
388        //      `bamboo_config::paths::is_safe_workflow_name`. SkillStore
389        //      discovers the files in place; do not copy them globally.
390        //   4. Skills: nothing to register (discovered in place). Record the
391        //      declared+validated dir names as owned.
392        //   5. Only once 0-4 succeed: build the `RegisteredCapabilities`
393        //      reflecting exactly what got registered (renamed preset ids, the
394        //      `to_register` MCP subset — NOT a blind copy of
395        //      `manifest.provides`), then upsert provenance via
396        //      `InstalledPlugins::load` + `.add(..)` + `.save(..)` at
397        //      `self.installed_json_path()`.
398        let _ = self.installed_json_path(); // reserved for step 5 above.
399        Err(PluginError::NotImplemented(
400            "capability registration wiring — see PLUGIN_PLAN.md \
401             \u{a7} Installer-core agent"
402                .to_string(),
403        ))
404    }
405
406    async fn uninstall(&self, id: &str) -> PluginResult<()> {
407        let installed = InstalledPlugins::load(&self.installed_json_path()).await?;
408        if installed.get_unique(id)?.is_none() {
409            return Err(PluginError::NotFound(id.to_string()));
410        }
411
412        // TODO(installer-core agent): using the found entry's `registered`
413        // capabilities (which, by construction, name ONLY plugin-created
414        // entries — see module docs), stop + remove each `mcp_server_ids`
415        // entry from `config.json` (`AppState::update_config` +
416        // `mcp_manager.stop_server`), remove each `preset_ids` entry from
417        // `prompt-presets.json`, delete each `workflow_filenames` file from
418        // `bamboo_config::paths::workflows_dir()`. THEN remove the provenance
419        // entry (`InstalledPlugins::remove` + `.save(..)`) and finally
420        // `tokio::fs::remove_dir_all(entry.plugin_dir)`.
421        Err(PluginError::NotImplemented(
422            "capability de-registration wiring — see PLUGIN_PLAN.md \
423             \u{a7} Installer-core agent"
424                .to_string(),
425        ))
426    }
427
428    async fn list(&self) -> PluginResult<Vec<InstalledPlugin>> {
429        let installed = InstalledPlugins::load(&self.installed_json_path()).await?;
430        Ok(installed.plugins)
431    }
432}
433
434#[cfg(test)]
435mod tests {
436    use super::*;
437    use crate::manifest::PluginManifest;
438    use crate::registry::RegisteredCapabilities;
439
440    fn manifest_with(skills: Vec<&str>, workflows: Vec<&str>) -> PluginManifest {
441        let json = serde_json::json!({
442            "id": "hello-plugin",
443            "name": "Hello Plugin",
444            "version": "0.1.0",
445            "provides": {
446                "skills": skills,
447                "workflows": workflows,
448            }
449        });
450        PluginManifest::parse_str(&json.to_string()).expect("parse manifest")
451    }
452
453    /// Create `<plugin_dir>/skills/<id>/SKILL.md` for each id.
454    async fn write_skill_dirs(plugin_dir: &Path, ids: &[&str]) {
455        for id in ids {
456            let skill_dir = plugin_dir.join("skills").join(id);
457            tokio::fs::create_dir_all(&skill_dir).await.unwrap();
458            tokio::fs::write(
459                skill_dir.join("SKILL.md"),
460                format!("---\nname: {id}\ndescription: demo\n---\nHi\n"),
461            )
462            .await
463            .unwrap();
464        }
465    }
466
467    #[tokio::test]
468    async fn list_on_fresh_bamboo_dir_is_empty() {
469        let dir = tempfile::tempdir().expect("tempdir");
470        let installer = LocalPluginInstaller::new(dir.path().to_path_buf());
471        let plugins = installer.list().await.expect("list");
472        assert!(plugins.is_empty());
473    }
474
475    #[tokio::test]
476    async fn uninstall_unknown_plugin_is_not_found() {
477        let dir = tempfile::tempdir().expect("tempdir");
478        let installer = LocalPluginInstaller::new(dir.path().to_path_buf());
479        let error = installer
480            .uninstall("does-not-exist")
481            .await
482            .expect_err("should be not-found");
483        assert!(matches!(error, PluginError::NotFound(_)));
484    }
485
486    #[tokio::test]
487    async fn install_validates_declared_skill_exists_on_disk() {
488        let dir = tempfile::tempdir().expect("tempdir");
489        let installer = LocalPluginInstaller::new(dir.path().join("bamboo-home"));
490
491        let plugin_dir = dir.path().join("plugin-src");
492        tokio::fs::create_dir_all(&plugin_dir).await.unwrap();
493        // Note: no `skills/hello-world/SKILL.md` created — install should
494        // reject the manifest for a missing declared skill before it ever
495        // reaches the (currently NotImplemented) registration step.
496        let manifest = manifest_with(vec!["hello-world"], vec![]);
497
498        let error = installer
499            .install(
500                &manifest,
501                &plugin_dir,
502                PluginSource::LocalDir {
503                    path: plugin_dir.clone(),
504                },
505                InstallDisposition::FailIfInstalled,
506                Utc::now(),
507            )
508            .await
509            .expect_err("missing SKILL.md should fail validation");
510        assert!(matches!(error, PluginError::InvalidManifest(_)));
511    }
512
513    #[tokio::test]
514    async fn install_rejects_undeclared_on_disk_skill_dir() {
515        let dir = tempfile::tempdir().expect("tempdir");
516        let installer = LocalPluginInstaller::new(dir.path().join("bamboo-home"));
517
518        let plugin_dir = dir.path().join("plugin-src");
519        // Bundle ships TWO skills on disk but declares only one.
520        write_skill_dirs(&plugin_dir, &["hello-world", "sneaky-extra"]).await;
521        let manifest = manifest_with(vec!["hello-world"], vec![]);
522
523        let error = installer
524            .install(
525                &manifest,
526                &plugin_dir,
527                PluginSource::LocalDir {
528                    path: plugin_dir.clone(),
529                },
530                InstallDisposition::FailIfInstalled,
531                Utc::now(),
532            )
533            .await
534            .expect_err("undeclared skill dir should be rejected");
535        assert!(matches!(error, PluginError::InvalidManifest(_)));
536        assert!(error.to_string().contains("sneaky-extra"));
537    }
538
539    #[tokio::test]
540    async fn install_reaches_not_implemented_once_declared_files_exist() {
541        let dir = tempfile::tempdir().expect("tempdir");
542        let installer = LocalPluginInstaller::new(dir.path().join("bamboo-home"));
543
544        let plugin_dir = dir.path().join("plugin-src");
545        write_skill_dirs(&plugin_dir, &["hello-world"]).await;
546        let manifest = manifest_with(vec!["hello-world"], vec![]);
547
548        let error = installer
549            .install(
550                &manifest,
551                &plugin_dir,
552                PluginSource::LocalDir {
553                    path: plugin_dir.clone(),
554                },
555                InstallDisposition::FailIfInstalled,
556                Utc::now(),
557            )
558            .await
559            .expect_err("registration wiring is a later-agent TODO");
560        assert!(matches!(error, PluginError::NotImplemented(_)));
561
562        // And it must not have been committed to provenance either, since
563        // registration never completed.
564        let plugins = installer.list().await.expect("list");
565        assert!(plugins.is_empty());
566    }
567
568    #[tokio::test]
569    async fn install_fails_if_already_installed_under_fail_disposition() {
570        let dir = tempfile::tempdir().expect("tempdir");
571        let bamboo_home = dir.path().join("bamboo-home");
572        let installer = LocalPluginInstaller::new(bamboo_home.clone());
573
574        // Seed provenance with an existing install of the same id.
575        let mut store = InstalledPlugins::default();
576        store.add(InstalledPlugin {
577            id: "hello-plugin".to_string(),
578            version: "0.0.1".to_string(),
579            source: PluginSource::LocalDir {
580                path: dir.path().to_path_buf(),
581            },
582            plugin_dir: bamboo_home.join("plugins").join("hello-plugin"),
583            installed_at: Utc::now(),
584            status: crate::registry::PluginInstallStatus::Installed,
585            registered: RegisteredCapabilities::default(),
586        });
587        store
588            .save(&bamboo_home.join("plugins").join("installed.json"))
589            .await
590            .unwrap();
591
592        let plugin_dir = dir.path().join("plugin-src");
593        write_skill_dirs(&plugin_dir, &["hello-world"]).await;
594        let manifest = manifest_with(vec!["hello-world"], vec![]);
595
596        let error = installer
597            .install(
598                &manifest,
599                &plugin_dir,
600                PluginSource::LocalDir {
601                    path: plugin_dir.clone(),
602                },
603                InstallDisposition::FailIfInstalled,
604                Utc::now(),
605            )
606            .await
607            .expect_err("already-installed under FailIfInstalled should error");
608        assert!(matches!(error, PluginError::AlreadyInstalled(_)));
609    }
610
611    #[tokio::test]
612    async fn upgrade_disposition_proceeds_past_the_already_installed_gate() {
613        let dir = tempfile::tempdir().expect("tempdir");
614        let bamboo_home = dir.path().join("bamboo-home");
615        let installer = LocalPluginInstaller::new(bamboo_home.clone());
616
617        // Same seed as the FailIfInstalled test.
618        let mut store = InstalledPlugins::default();
619        store.add(InstalledPlugin {
620            id: "hello-plugin".to_string(),
621            version: "0.0.1".to_string(),
622            source: PluginSource::LocalDir {
623                path: dir.path().to_path_buf(),
624            },
625            plugin_dir: bamboo_home.join("plugins").join("hello-plugin"),
626            installed_at: Utc::now(),
627            status: crate::registry::PluginInstallStatus::Installed,
628            registered: RegisteredCapabilities::default(),
629        });
630        store
631            .save(&bamboo_home.join("plugins").join("installed.json"))
632            .await
633            .unwrap();
634
635        let plugin_dir = dir.path().join("plugin-src");
636        write_skill_dirs(&plugin_dir, &["hello-world"]).await;
637        let manifest = manifest_with(vec!["hello-world"], vec![]);
638
639        // Upgrade must NOT hit AlreadyInstalled; it proceeds to the (still
640        // later-agent) registration TODO.
641        let error = installer
642            .install(
643                &manifest,
644                &plugin_dir,
645                PluginSource::LocalDir {
646                    path: plugin_dir.clone(),
647                },
648                InstallDisposition::Upgrade,
649                Utc::now(),
650            )
651            .await
652            .expect_err("registration wiring is a later-agent TODO");
653        assert!(matches!(error, PluginError::NotImplemented(_)));
654    }
655
656    #[tokio::test]
657    async fn disposition_preflight_rejects_duplicate_plugin_rows_for_every_status_mix() {
658        use crate::registry::PluginInstallStatus;
659
660        for statuses in [
661            [
662                PluginInstallStatus::Installed,
663                PluginInstallStatus::Installed,
664            ],
665            [
666                PluginInstallStatus::Installed,
667                PluginInstallStatus::Installing,
668            ],
669            [
670                PluginInstallStatus::Installing,
671                PluginInstallStatus::Installing,
672            ],
673        ] {
674            let dir = tempfile::tempdir().expect("tempdir");
675            let path = dir.path().join("plugins").join("installed.json");
676            let mut store = InstalledPlugins::default();
677            for (index, status) in statuses.into_iter().enumerate() {
678                store.plugins.push(InstalledPlugin {
679                    id: "hello-plugin".to_string(),
680                    version: format!("0.0.{index}"),
681                    source: PluginSource::LocalDir {
682                        path: dir.path().join(format!("source-{index}")),
683                    },
684                    plugin_dir: dir.path().join(format!("plugin-{index}")),
685                    installed_at: Utc::now(),
686                    status,
687                    registered: RegisteredCapabilities::default(),
688                });
689            }
690            store.save(&path).await.expect("save corrupt registry");
691
692            for disposition in [
693                InstallDisposition::FailIfInstalled,
694                InstallDisposition::Upgrade,
695            ] {
696                let error = load_previous_for_disposition(&path, "hello-plugin", disposition)
697                    .await
698                    .expect_err("duplicate plugin rows must fail before mutation");
699                assert!(matches!(error, PluginError::Registration(_)));
700                assert!(error.to_string().contains("duplicate rows"));
701            }
702        }
703    }
704}