Skip to main content

af_agent/
plugin.rs

1use af_agent_session::ContentBlock;
2use af_context::{ProfileRevisionId, RunId, SessionId, SubjectId, TenantId};
3use std::collections::{BTreeMap, BTreeSet};
4use std::future::Future;
5use std::pin::Pin;
6use std::sync::Arc;
7
8use async_trait::async_trait;
9use semver::{Version, VersionReq};
10use serde::{Deserialize, Serialize};
11use serde_json::Value;
12
13use crate::{
14    validate_json_schema, ContextContributor, PromptAuthority, PromptSection, Tool, ToolRegistry,
15};
16
17/// Deployment-time declaration of a trusted plugin.
18#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
19pub struct PluginManifest {
20    /// Stable identifier of this record.
21    pub id: String,
22    /// Semantic version string.
23    pub version: String,
24    /// Required plugins and the semver ranges they must satisfy.
25    #[serde(default)]
26    pub dependencies: BTreeMap<String, String>,
27    /// JSON Schema for the plugin's Profile configuration.
28    #[serde(default)]
29    pub config_schema: Value,
30    /// Permissions granted or required.
31    #[serde(default)]
32    pub permissions: BTreeSet<PluginPermission>,
33}
34
35impl PluginManifest {
36    /// Reject invalid ids and versions.
37    pub fn validate(&self) -> Result<(), PluginError> {
38        if self.id.trim().is_empty()
39            || !self.id.chars().all(|ch| {
40                ch.is_ascii_lowercase() || ch.is_ascii_digit() || matches!(ch, '.' | '-' | '_')
41            })
42        {
43            return Err(PluginError::InvalidManifest(format!(
44                "invalid plugin id {}",
45                self.id
46            )));
47        }
48        Version::parse(&self.version)
49            .map_err(|error| PluginError::InvalidManifest(format!("{}: {error}", self.id)))?;
50        Ok(())
51    }
52}
53
54/// Contribution kinds a plugin may register.
55#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
56#[serde(rename_all = "snake_case")]
57pub enum PluginPermission {
58    /// Prompt sections.
59    Prompt,
60    /// Context contributors.
61    Context,
62    /// Tools.
63    Tool,
64    /// Hooks.
65    Hook,
66}
67
68/// Redacted description of a registered plugin.
69#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
70pub struct PluginDescriptor {
71    /// Stable identifier of this record.
72    pub id: String,
73    /// Semantic version string.
74    pub version: String,
75    /// Declared dependency ranges.
76    pub dependencies: BTreeMap<String, String>,
77    /// Permissions granted or required.
78    pub permissions: BTreeSet<PluginPermission>,
79}
80
81/// Identity a plugin is mounted under for one Session.
82#[derive(Debug, Clone, PartialEq, Eq)]
83pub struct PluginMountContext {
84    /// Tenant that owns this record.
85    pub tenant_id: TenantId,
86    /// Subject (user or service principal) acting on or owning this record.
87    pub subject_id: SubjectId,
88    /// Session this record belongs to.
89    pub session_id: SessionId,
90    /// Immutable Agent Profile revision pinned by the Session.
91    pub profile_revision_id: ProfileRevisionId,
92}
93
94/// Result of a pre-tool hook.
95#[derive(Debug, Clone, PartialEq)]
96pub enum HookDecision {
97    /// Let the tool run.
98    Continue,
99    /// Refuse the call; the model sees `reason`.
100    Deny {
101        /// Why the call was refused.
102        reason: String,
103    },
104    /// Park the Run until the user answers.
105    WaitForInput {
106        /// `action` (approval) or `question`.
107        kind: String,
108        /// Interaction payload shown to the user.
109        payload: Value,
110    },
111}
112
113/// Boxed tool execution passed through `around_tool`.
114pub type ToolExecutionFuture<'a> = Pin<Box<dyn Future<Output = Result<Value, String>> + Send + 'a>>;
115
116/// Position of the loop when a step-level hook runs.
117#[derive(Debug, Clone, PartialEq, Eq)]
118pub struct StepContext {
119    /// The originating request.
120    pub request: af_context::RequestContext,
121    /// Session this record belongs to.
122    pub session_id: SessionId,
123    /// Run this record belongs to.
124    pub run_id: RunId,
125    /// 1-based Turn number inside the Run.
126    pub turn: u32,
127    /// 1-based step number inside the Turn.
128    pub step: u32,
129}
130
131/// Whether the loop may open the proposed step. A rejection closes the Run
132/// without a model request and is recorded as a `step_rejected` extension event.
133#[derive(Debug, Clone, PartialEq, Eq)]
134pub enum PreStepDecision {
135    /// Open the step.
136    Enter,
137    /// Do not open the step; the Run is cancelled with `reason`.
138    Reject {
139        /// Why the step was refused.
140        reason: String,
141    },
142}
143
144/// Mutable per-request model policy. Hooks may narrow or redirect it before the
145/// request is frozen into `ModelRequestPrepared`.
146#[derive(Debug, Clone, PartialEq)]
147pub struct ModelRequestPolicy {
148    /// Model identifier as registered in the model registry.
149    pub model: String,
150    /// Reasoning budget for this request.
151    pub reasoning_effort: Option<af_llm::ReasoningEffort>,
152    /// Sampling temperature.
153    pub temperature: f32,
154    /// Upper bound on prompt plus completion tokens.
155    pub max_tokens: Option<u32>,
156}
157
158/// One failed model attempt as seen by [`Hook::request_error`].
159#[derive(Debug, Clone, PartialEq, Eq)]
160pub struct ModelFailure {
161    /// 1-based attempt counter.
162    pub attempt: u32,
163    /// Error message when the operation failed.
164    pub error: String,
165    /// Whether the failure is safe to retry.
166    pub retryable: bool,
167    /// Whether the runtime still has attempts left.
168    pub attempts_remaining: bool,
169}
170
171/// Recovery ownership for a failed model attempt. `Default` keeps the
172/// runtime's retry policy; `Retry` forces one more attempt while attempts
173/// remain; `Terminal` stops retrying even for a retryable failure.
174#[derive(Debug, Clone, Copy, PartialEq, Eq)]
175pub enum RequestErrorAction {
176    /// Keep the runtime's retry policy.
177    Default,
178    /// Force one more attempt while attempts remain.
179    Retry,
180    /// Stop retrying now.
181    Terminal,
182}
183
184/// Whether a turn that owes no tool result may close. `Steer` appends the
185/// content as a logged user message and runs another step instead.
186#[derive(Debug, Clone, PartialEq)]
187pub enum TurnStopDecision {
188    /// Close the turn.
189    Stop,
190    /// Append `content` as a logged user message and run one more step.
191    Steer {
192        /// Steering input.
193        content: Vec<ContentBlock>,
194    },
195}
196
197/// Interception points around the loop; every method has a no-op default.
198#[async_trait]
199pub trait Hook: Send + Sync {
200    /// Runs before a step opens. The first `Reject` across hooks wins.
201    async fn pre_step(&self, _context: &StepContext) -> PreStepDecision {
202        PreStepDecision::Enter
203    }
204    /// Runs before each model request is frozen; hooks apply in mount order.
205    async fn request(&self, _context: &StepContext, _policy: &mut ModelRequestPolicy) {}
206    /// Runs after each failed model attempt. The first non-`Default` action wins.
207    async fn request_error(
208        &self,
209        _context: &StepContext,
210        _failure: &ModelFailure,
211    ) -> RequestErrorAction {
212        RequestErrorAction::Default
213    }
214    /// Runs when the model owes nothing more. The first `Steer` across hooks wins.
215    async fn turn_stopping(&self, _context: &StepContext) -> TurnStopDecision {
216        TurnStopDecision::Stop
217    }
218    /// Runs before a tool executes; may deny it or park the Run on an interaction.
219    async fn before_tool(
220        &self,
221        _context: &crate::ToolExecutionContext,
222        _tool: &str,
223        _arguments: &Value,
224    ) -> HookDecision {
225        HookDecision::Continue
226    }
227    /// Wrap the actual tool execution; call `next` to run it.
228    fn around_tool<'a>(
229        &'a self,
230        _context: &'a crate::ToolExecutionContext,
231        _tool: &'a str,
232        _arguments: &'a Value,
233        next: ToolExecutionFuture<'a>,
234    ) -> ToolExecutionFuture<'a> {
235        next
236    }
237    /// Runs after a tool result is committed; an error is recorded but cannot replace the result.
238    async fn after_tool(
239        &self,
240        _context: &crate::ToolExecutionContext,
241        _tool: &str,
242        _arguments: &Value,
243        _result: &Value,
244    ) -> Result<(), PluginError> {
245        Ok(())
246    }
247}
248
249#[derive(Default, Clone)]
250struct PluginContributions {
251    prompts: BTreeMap<String, PromptSection>,
252    contexts: Vec<Arc<dyn ContextContributor>>,
253    tools: ToolRegistry,
254    hooks: Vec<Arc<dyn Hook>>,
255}
256
257impl PluginContributions {
258    fn merge(&mut self, additions: Self) -> Result<(), PluginError> {
259        let mut merged = self.clone();
260        for (id, prompt) in additions.prompts {
261            insert_once(&mut merged.prompts, id, prompt)?;
262        }
263        merged
264            .tools
265            .extend(&additions.tools)
266            .map_err(PluginError::Conflict)?;
267        merged.contexts.extend(additions.contexts);
268        merged.hooks.extend(additions.hooks);
269        *self = merged;
270        Ok(())
271    }
272}
273
274/// Scoped registrar a plugin contributes through during activation; permissions are checked per call.
275pub struct AgentRegistrar<'a> {
276    owner: &'a PluginManifest,
277    contributions: &'a mut PluginContributions,
278}
279
280impl AgentRegistrar<'_> {
281    /// Register a prompt section under the plugin's authority.
282    pub fn prompt(
283        &mut self,
284        id: impl Into<String>,
285        prompt: impl Into<String>,
286    ) -> Result<(), PluginError> {
287        require(self.owner, PluginPermission::Prompt)?;
288        let id = format!("plugin.{}.{}", self.owner.id, id.into());
289        insert_once(
290            &mut self.contributions.prompts,
291            id.clone(),
292            PromptSection {
293                id,
294                order: 400,
295                authority: PromptAuthority::Plugin,
296                source: self.owner.id.clone(),
297                version: self.owner.version.clone(),
298                content: prompt.into(),
299            },
300        )
301    }
302    /// Register a tool.
303    pub fn tool(&mut self, tool: Arc<dyn Tool>) -> Result<(), PluginError> {
304        require(self.owner, PluginPermission::Tool)?;
305        if self.contributions.tools.contains(tool.name()) {
306            return Err(PluginError::Conflict(format!("tool {}", tool.name())));
307        }
308        self.contributions
309            .tools
310            .register(tool)
311            .map(|_| ())
312            .map_err(PluginError::Conflict)
313    }
314    /// Register a context contributor.
315    pub fn context(&mut self, contributor: Arc<dyn ContextContributor>) -> Result<(), PluginError> {
316        require(self.owner, PluginPermission::Context)?;
317        self.contributions.contexts.push(contributor);
318        Ok(())
319    }
320    /// Register a hook.
321    pub fn hook(&mut self, hook: Arc<dyn Hook>) -> Result<(), PluginError> {
322        require(self.owner, PluginPermission::Hook)?;
323        self.contributions.hooks.push(hook);
324        Ok(())
325    }
326}
327
328/// Per-Session resource a plugin holds; released on unmount.
329#[async_trait]
330pub trait PluginLease: Send + Sync {
331    /// Cleanup must be idempotent because a failed release is retried.
332    async fn unmount(&mut self) -> Result<(), PluginError>;
333}
334
335/// Trusted deployment-time extension compiled into the host.
336#[async_trait]
337pub trait AgentPlugin: Send + Sync {
338    /// Static manifest.
339    fn manifest(&self) -> &PluginManifest;
340
341    /// Creates the cleanup lease before activation performs any side effects.
342    /// The lease must be safe to unmount before, during, or after activation.
343    fn lease(&self, context: &PluginMountContext, config: &Value) -> Box<dyn PluginLease>;
344
345    /// Contribute prompts, tools, contexts and hooks for one mount.
346    async fn activate(
347        &self,
348        context: &PluginMountContext,
349        config: &Value,
350        registrar: &mut AgentRegistrar<'_>,
351    ) -> Result<(), PluginError>;
352}
353
354/// Result of an atomic mount: merged contributions plus the leases to release.
355pub struct MountedPlugins {
356    contributions: PluginContributions,
357    leases: Arc<LeaseSet>,
358}
359
360impl std::fmt::Debug for MountedPlugins {
361    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
362        formatter
363            .debug_struct("MountedPlugins")
364            .field(
365                "pending_leases",
366                &self
367                    .leases
368                    .state
369                    .lock()
370                    .unwrap_or_else(std::sync::PoisonError::into_inner)
371                    .leases
372                    .len(),
373            )
374            .finish_non_exhaustive()
375    }
376}
377
378impl MountedPlugins {
379    fn from_parts(contributions: PluginContributions, leases: Vec<Box<dyn PluginLease>>) -> Self {
380        Self {
381            contributions,
382            leases: Arc::new(LeaseSet {
383                state: std::sync::Mutex::new(LeaseState {
384                    leases,
385                    holders: 1,
386                    closing: false,
387                }),
388                notify: tokio::sync::Notify::new(),
389            }),
390        }
391    }
392
393    /// Prompt sections in order.
394    pub fn prompts(&self) -> impl Iterator<Item = &PromptSection> {
395        self.contributions.prompts.values()
396    }
397    /// Context contributors in mount order.
398    pub fn contexts(&self) -> &[Arc<dyn ContextContributor>] {
399        &self.contributions.contexts
400    }
401    /// Merged tool registry.
402    pub fn tools(&self) -> &ToolRegistry {
403        &self.contributions.tools
404    }
405    /// Hooks in mount order.
406    pub fn hooks(&self) -> &[Arc<dyn Hook>] {
407        &self.contributions.hooks
408    }
409
410    /// Release every lease in reverse order; failures are collected and retryable.
411    pub async fn unmount(&mut self) -> Result<(), PluginError> {
412        let leases = Arc::clone(&self.leases);
413        {
414            leases
415                .state
416                .lock()
417                .unwrap_or_else(std::sync::PoisonError::into_inner)
418                .closing = true;
419        }
420        loop {
421            let notified = leases.notify.notified();
422            let pending = {
423                let mut state = leases
424                    .state
425                    .lock()
426                    .unwrap_or_else(std::sync::PoisonError::into_inner);
427                (state.holders == 1).then(|| std::mem::take(&mut state.leases))
428            };
429            if let Some(mut pending) = pending {
430                let mut failed = Vec::new();
431                let mut errors = Vec::new();
432                while let Some(mut lease) = pending.pop() {
433                    if let Err(error) = lease.unmount().await {
434                        errors.push(error.to_string());
435                        failed.push(lease);
436                    }
437                }
438                leases
439                    .state
440                    .lock()
441                    .unwrap_or_else(std::sync::PoisonError::into_inner)
442                    .leases = failed;
443                return if errors.is_empty() {
444                    Ok(())
445                } else {
446                    Err(PluginError::Mount(format!(
447                        "plugin cleanup failed: {}",
448                        errors.join("; ")
449                    )))
450                };
451            }
452            notified.await;
453        }
454    }
455}
456
457impl Clone for MountedPlugins {
458    fn clone(&self) -> Self {
459        self.leases
460            .state
461            .lock()
462            .unwrap_or_else(std::sync::PoisonError::into_inner)
463            .holders += 1;
464        Self {
465            contributions: self.contributions.clone(),
466            leases: Arc::clone(&self.leases),
467        }
468    }
469}
470
471impl Drop for MountedPlugins {
472    fn drop(&mut self) {
473        let mut state = self
474            .leases
475            .state
476            .lock()
477            .unwrap_or_else(std::sync::PoisonError::into_inner);
478        state.holders -= 1;
479        if state.closing && state.holders <= 1 {
480            self.leases.notify.notify_waiters();
481        }
482    }
483}
484
485struct LeaseSet {
486    state: std::sync::Mutex<LeaseState>,
487    notify: tokio::sync::Notify,
488}
489
490struct LeaseState {
491    leases: Vec<Box<dyn PluginLease>>,
492    holders: usize,
493    closing: bool,
494}
495
496/// Registered plugins keyed by id.
497#[derive(Default)]
498pub struct PluginCatalog {
499    plugins: BTreeMap<(String, String), Arc<dyn AgentPlugin>>,
500}
501
502impl std::fmt::Debug for PluginCatalog {
503    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
504        formatter
505            .debug_struct("PluginCatalog")
506            .field("count", &self.plugins.len())
507            .finish()
508    }
509}
510
511impl PluginCatalog {
512    /// Register a plugin; duplicate ids never replace the first registration.
513    pub fn register(&mut self, plugin: Arc<dyn AgentPlugin>) -> Result<(), PluginError> {
514        plugin.manifest().validate()?;
515        let id = plugin.manifest().id.clone();
516        let version = plugin.manifest().version.clone();
517        if self.plugins.contains_key(&(id.clone(), version.clone())) {
518            return Err(PluginError::Conflict(format!("{id}@{version}")));
519        }
520        self.plugins.insert((id, version), plugin);
521        Ok(())
522    }
523
524    /// Redacted descriptors of every plugin.
525    pub fn descriptors(&self) -> impl Iterator<Item = PluginDescriptor> + '_ {
526        self.plugins.values().map(|plugin| {
527            let manifest = plugin.manifest();
528            PluginDescriptor {
529                id: manifest.id.clone(),
530                version: manifest.version.clone(),
531                dependencies: manifest.dependencies.clone(),
532                permissions: manifest.permissions.clone(),
533            }
534        })
535    }
536
537    /// Mount the referenced plugins in dependency order, rolling back on failure.
538    pub async fn mount(
539        &self,
540        context: &PluginMountContext,
541        references: &BTreeMap<String, (String, Value)>,
542    ) -> Result<MountedPlugins, PluginError> {
543        let selected = self.select(references)?;
544        let configs = references
545            .iter()
546            .map(|(id, (_, config))| (id.clone(), config.clone()))
547            .collect();
548        let borrowed = selected
549            .iter()
550            .map(|plugin| plugin.as_ref())
551            .collect::<Vec<_>>();
552        mount_plugins(context, &borrowed, &configs).await
553    }
554
555    /// Report references to unknown plugins, unsatisfied versions and invalid configs.
556    pub fn validate_references(
557        &self,
558        references: &BTreeMap<String, (String, Value)>,
559    ) -> Result<(), PluginError> {
560        self.select(references).map(|_| ())
561    }
562
563    fn select(
564        &self,
565        references: &BTreeMap<String, (String, Value)>,
566    ) -> Result<Vec<Arc<dyn AgentPlugin>>, PluginError> {
567        if let Some(issue) =
568            validate_plugin_references(&self.descriptors().collect::<Vec<_>>(), references)
569                .into_iter()
570                .next()
571        {
572            return Err(issue.error);
573        }
574        let mut selected = Vec::with_capacity(references.len());
575        let mut configs = BTreeMap::new();
576        for (id, (version, config)) in references {
577            let plugin = if let Some(plugin) = self.plugins.get(&(id.clone(), version.clone())) {
578                plugin
579            } else if let Some(((_, actual), _)) = self
580                .plugins
581                .iter()
582                .find(|((plugin_id, _), _)| plugin_id == id)
583            {
584                return Err(PluginError::DependencyVersion {
585                    plugin: id.clone(),
586                    dependency: id.clone(),
587                    required: version.clone(),
588                    actual: actual.clone(),
589                });
590            } else {
591                return Err(PluginError::MissingDependency(format!("{id}@{version}")));
592            };
593            selected.push(Arc::clone(plugin));
594            configs.insert(id.clone(), config.clone());
595        }
596        let borrowed = selected
597            .iter()
598            .map(|plugin| plugin.as_ref())
599            .collect::<Vec<_>>();
600        resolve_plugin_order(&borrowed)?;
601        for plugin in &selected {
602            let manifest = plugin.manifest();
603            validate_json_schema(&manifest.config_schema, &configs[&manifest.id])
604                .map_err(|error| PluginError::InvalidConfig(manifest.id.clone(), error))?;
605        }
606        Ok(selected)
607    }
608}
609
610/// One problem found by [`PluginCatalog::validate_references`].
611#[derive(Debug)]
612pub struct PluginReferenceIssue {
613    /// Plugin id the issue is about.
614    pub plugin: String,
615    /// Error message when the operation failed.
616    pub error: PluginError,
617}
618
619/// Side-effect-free validation shared by profile inspection and runtime mount.
620pub fn validate_plugin_references(
621    descriptors: &[PluginDescriptor],
622    references: &BTreeMap<String, (String, Value)>,
623) -> Vec<PluginReferenceIssue> {
624    let exact = descriptors
625        .iter()
626        .map(|descriptor| {
627            (
628                (descriptor.id.as_str(), descriptor.version.as_str()),
629                descriptor,
630            )
631        })
632        .collect::<BTreeMap<_, _>>();
633    let mut issues = Vec::new();
634    for (id, (version, _)) in references {
635        if exact.contains_key(&(id.as_str(), version.as_str())) {
636            continue;
637        }
638        let error = descriptors
639            .iter()
640            .find(|descriptor| descriptor.id == *id)
641            .map_or_else(
642                || PluginError::MissingDependency(format!("{id}@{version}")),
643                |actual| PluginError::DependencyVersion {
644                    plugin: id.clone(),
645                    dependency: id.clone(),
646                    required: version.clone(),
647                    actual: actual.version.clone(),
648                },
649            );
650        issues.push(PluginReferenceIssue {
651            plugin: id.clone(),
652            error,
653        });
654    }
655    for (id, (version, _)) in references {
656        let Some(descriptor) = exact.get(&(id.as_str(), version.as_str())) else {
657            continue;
658        };
659        for (dependency, requirement) in &descriptor.dependencies {
660            let error = match references.get(dependency).map(|(version, _)| version) {
661                None => Some(PluginError::MissingDependency(format!(
662                    "{id} -> {dependency}"
663                ))),
664                Some(actual) => match (VersionReq::parse(requirement), Version::parse(actual)) {
665                    (Err(error), _) => Some(PluginError::InvalidManifest(format!(
666                        "{id} dependency {dependency}: {error}"
667                    ))),
668                    (_, Err(error)) => Some(PluginError::InvalidManifest(error.to_string())),
669                    (Ok(required), Ok(actual_version)) if !required.matches(&actual_version) => {
670                        Some(PluginError::DependencyVersion {
671                            plugin: id.clone(),
672                            dependency: dependency.clone(),
673                            required: requirement.clone(),
674                            actual: actual.clone(),
675                        })
676                    }
677                    _ => None,
678                },
679            };
680            if let Some(error) = error {
681                issues.push(PluginReferenceIssue {
682                    plugin: id.clone(),
683                    error,
684                });
685            }
686        }
687    }
688    issues
689}
690
691/// Mount a complete Session plugin set atomically. Any validation or activation
692/// failure tears down current and already-mounted plugins in reverse order.
693pub async fn mount_plugins(
694    context: &PluginMountContext,
695    plugins: &[&dyn AgentPlugin],
696    configs: &BTreeMap<String, Value>,
697) -> Result<MountedPlugins, PluginError> {
698    let ordered = resolve_plugin_order(plugins)?;
699    let mut contributions = PluginContributions::default();
700    let mut leases = Vec::with_capacity(ordered.len());
701    for plugin in ordered {
702        let manifest = plugin.manifest();
703        let config = configs
704            .get(&manifest.id)
705            .cloned()
706            .unwrap_or_else(|| Value::Object(Default::default()));
707        if let Err(error) = validate_json_schema(&manifest.config_schema, &config) {
708            return Err(rollback_error(
709                PluginError::InvalidConfig(manifest.id.clone(), error),
710                leases,
711            )
712            .await);
713        }
714        let mut additions = PluginContributions::default();
715        let mut registrar = AgentRegistrar {
716            owner: manifest,
717            contributions: &mut additions,
718        };
719        leases.push(plugin.lease(context, &config));
720        if let Err(error) = plugin.activate(context, &config, &mut registrar).await {
721            return Err(rollback_error(error, leases).await);
722        }
723        if let Err(error) = contributions.merge(additions) {
724            return Err(rollback_error(error, leases).await);
725        }
726    }
727    Ok(MountedPlugins::from_parts(contributions, leases))
728}
729
730async fn rollback_error(cause: PluginError, leases: Vec<Box<dyn PluginLease>>) -> PluginError {
731    let mut cleanup = MountedPlugins::from_parts(PluginContributions::default(), leases);
732    match cleanup.unmount().await {
733        Ok(()) => cause,
734        Err(error) => PluginError::Rollback {
735            cause: Box::new(cause),
736            cleanup_error: error.to_string(),
737            cleanup: Box::new(cleanup),
738        },
739    }
740}
741
742/// Topologically order the referenced plugins by their dependencies.
743pub fn resolve_plugin_order<'a>(
744    plugins: &'a [&'a dyn AgentPlugin],
745) -> Result<Vec<&'a dyn AgentPlugin>, PluginError> {
746    let by_id = plugins
747        .iter()
748        .map(|plugin| (plugin.manifest().id.as_str(), *plugin))
749        .collect::<BTreeMap<_, _>>();
750    if by_id.len() != plugins.len() {
751        return Err(PluginError::Conflict("duplicate plugin id".into()));
752    }
753    let mut visiting = BTreeSet::new();
754    let mut visited = BTreeSet::new();
755    let mut ordered = Vec::with_capacity(plugins.len());
756    fn visit<'a>(
757        id: &'a str,
758        by_id: &BTreeMap<&'a str, &'a dyn AgentPlugin>,
759        visiting: &mut BTreeSet<&'a str>,
760        visited: &mut BTreeSet<&'a str>,
761        ordered: &mut Vec<&'a dyn AgentPlugin>,
762    ) -> Result<(), PluginError> {
763        if visited.contains(id) {
764            return Ok(());
765        }
766        if !visiting.insert(id) {
767            return Err(PluginError::DependencyCycle(id.into()));
768        }
769        let plugin = by_id
770            .get(id)
771            .ok_or_else(|| PluginError::MissingDependency(id.into()))?;
772        plugin.manifest().validate()?;
773        for (dependency, requirement) in &plugin.manifest().dependencies {
774            if !by_id.contains_key(dependency.as_str()) {
775                return Err(PluginError::MissingDependency(format!(
776                    "{id} -> {dependency}"
777                )));
778            }
779            let required = VersionReq::parse(requirement).map_err(|error| {
780                PluginError::InvalidManifest(format!("{id} dependency {dependency}: {error}"))
781            })?;
782            let actual = Version::parse(&by_id[dependency.as_str()].manifest().version)
783                .map_err(|error| PluginError::InvalidManifest(error.to_string()))?;
784            if !required.matches(&actual) {
785                return Err(PluginError::DependencyVersion {
786                    plugin: id.into(),
787                    dependency: dependency.clone(),
788                    required: requirement.clone(),
789                    actual: actual.to_string(),
790                });
791            }
792            visit(dependency, by_id, visiting, visited, ordered)?;
793        }
794        visiting.remove(id);
795        visited.insert(id);
796        ordered.push(*plugin);
797        Ok(())
798    }
799    for id in by_id.keys() {
800        visit(id, &by_id, &mut visiting, &mut visited, &mut ordered)?;
801    }
802    Ok(ordered)
803}
804
805fn require(manifest: &PluginManifest, permission: PluginPermission) -> Result<(), PluginError> {
806    manifest
807        .permissions
808        .contains(&permission)
809        .then_some(())
810        .ok_or_else(|| PluginError::Permission(format!("{} lacks {permission:?}", manifest.id)))
811}
812
813fn insert_once<T>(map: &mut BTreeMap<String, T>, key: String, value: T) -> Result<(), PluginError> {
814    if map.contains_key(&key) {
815        return Err(PluginError::Conflict(key));
816    }
817    map.insert(key, value);
818    Ok(())
819}
820
821/// Plugin registration, validation or mount failure.
822#[derive(Debug, thiserror::Error)]
823pub enum PluginError {
824    /// Invalid plugin manifest.
825    #[error("invalid plugin manifest: {0}")]
826    InvalidManifest(String),
827    /// Missing plugin dependency.
828    #[error("missing plugin dependency: {0}")]
829    MissingDependency(String),
830    /// Plugin dependency cycle at.
831    #[error("plugin dependency cycle at {0}")]
832    DependencyCycle(String),
833    /// Plugin permission denied.
834    #[error("plugin permission denied: {0}")]
835    Permission(String),
836    /// Plugin registration conflict.
837    #[error("plugin registration conflict: {0}")]
838    Conflict(String),
839    /// Plugin mount failed.
840    #[error("plugin mount failed: {0}")]
841    Mount(String),
842    /// `cause`; plugin rollback failed: `cleanup_error`.
843    #[error("{cause}; plugin rollback failed: {cleanup_error}")]
844    Rollback {
845        /// The mount failure that triggered rollback.
846        cause: Box<PluginError>,
847        /// Why rollback failed.
848        cleanup_error: String,
849        /// Partially mounted state; call [`PluginError::retry_cleanup`].
850        cleanup: Box<MountedPlugins>,
851    },
852    /// Plugin configuration is invalid.
853    #[error("plugin {0} configuration is invalid: {1}")]
854    InvalidConfig(String, String),
855    /// Plugin `plugin` requires `dependency` `required`, found `actual`.
856    #[error("plugin {plugin} requires {dependency} {required}, found {actual}")]
857    DependencyVersion {
858        /// Plugin declaring the dependency.
859        plugin: String,
860        /// Dependency id.
861        dependency: String,
862        /// Required semver range.
863        required: String,
864        /// Registered version.
865        actual: String,
866    },
867}
868
869impl PluginError {
870    /// Retry a failed rollback; succeeds once every lease is released.
871    pub async fn retry_cleanup(&mut self) -> Result<(), PluginError> {
872        let Self::Rollback {
873            cleanup,
874            cleanup_error,
875            ..
876        } = self
877        else {
878            return Ok(());
879        };
880        match cleanup.unmount().await {
881            Ok(()) => Ok(()),
882            Err(error) => {
883                *cleanup_error = error.to_string();
884                Err(error)
885            }
886        }
887    }
888}
889
890#[cfg(test)]
891mod tests {
892    use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
893
894    use super::*;
895    use crate::ToolExecutionContext;
896
897    struct TestTool;
898    #[async_trait]
899    impl Tool for TestTool {
900        fn name(&self) -> &str {
901            "test.tool"
902        }
903        fn description(&self) -> &str {
904            "test"
905        }
906        fn parameters(&self) -> Value {
907            serde_json::json!({"type":"object"})
908        }
909        fn output_schema(&self) -> Value {
910            serde_json::json!({"type":"object"})
911        }
912        async fn call(&self, arguments: Value) -> Result<Value, String> {
913            Ok(arguments)
914        }
915    }
916
917    struct TestHook;
918    #[async_trait]
919    impl Hook for TestHook {}
920
921    struct Lease(Arc<AtomicUsize>);
922    #[async_trait]
923    impl PluginLease for Lease {
924        async fn unmount(&mut self) -> Result<(), PluginError> {
925            self.0.fetch_add(1, Ordering::SeqCst);
926            Ok(())
927        }
928    }
929
930    struct TestPlugin {
931        manifest: PluginManifest,
932        fail: bool,
933        unmounted: Arc<AtomicUsize>,
934    }
935
936    struct RegisteringPlugin {
937        manifest: PluginManifest,
938        unmounted: Arc<AtomicUsize>,
939    }
940
941    #[async_trait]
942    impl AgentPlugin for RegisteringPlugin {
943        fn manifest(&self) -> &PluginManifest {
944            &self.manifest
945        }
946        fn lease(&self, _: &PluginMountContext, _: &Value) -> Box<dyn PluginLease> {
947            Box::new(Lease(Arc::clone(&self.unmounted)))
948        }
949        async fn activate(
950            &self,
951            _: &PluginMountContext,
952            _: &Value,
953            registrar: &mut AgentRegistrar<'_>,
954        ) -> Result<(), PluginError> {
955            registrar.tool(Arc::new(TestTool))?;
956            Ok(())
957        }
958    }
959
960    struct RetryLease {
961        id: &'static str,
962        fail_once: Arc<AtomicBool>,
963        calls: Arc<std::sync::Mutex<Vec<&'static str>>>,
964    }
965
966    #[async_trait]
967    impl PluginLease for RetryLease {
968        async fn unmount(&mut self) -> Result<(), PluginError> {
969            self.calls.lock().unwrap().push(self.id);
970            if self.fail_once.swap(false, Ordering::SeqCst) {
971                Err(PluginError::Mount(format!("{} failed", self.id)))
972            } else {
973                Ok(())
974            }
975        }
976    }
977
978    struct CleanupPlugin {
979        manifest: PluginManifest,
980        lease_id: &'static str,
981        fail_activation: bool,
982        fail_once: Arc<AtomicBool>,
983        calls: Arc<std::sync::Mutex<Vec<&'static str>>>,
984    }
985
986    #[async_trait]
987    impl AgentPlugin for CleanupPlugin {
988        fn manifest(&self) -> &PluginManifest {
989            &self.manifest
990        }
991        fn lease(&self, _: &PluginMountContext, _: &Value) -> Box<dyn PluginLease> {
992            Box::new(RetryLease {
993                id: self.lease_id,
994                fail_once: Arc::clone(&self.fail_once),
995                calls: Arc::clone(&self.calls),
996            })
997        }
998        async fn activate(
999            &self,
1000            _: &PluginMountContext,
1001            _: &Value,
1002            _: &mut AgentRegistrar<'_>,
1003        ) -> Result<(), PluginError> {
1004            if self.fail_activation {
1005                Err(PluginError::Mount(self.manifest.id.clone()))
1006            } else {
1007                Ok(())
1008            }
1009        }
1010    }
1011    #[async_trait]
1012    impl AgentPlugin for TestPlugin {
1013        fn manifest(&self) -> &PluginManifest {
1014            &self.manifest
1015        }
1016        fn lease(&self, _: &PluginMountContext, _: &Value) -> Box<dyn PluginLease> {
1017            Box::new(Lease(Arc::clone(&self.unmounted)))
1018        }
1019        async fn activate(
1020            &self,
1021            _context: &PluginMountContext,
1022            _config: &Value,
1023            _registrar: &mut AgentRegistrar<'_>,
1024        ) -> Result<(), PluginError> {
1025            if self.fail {
1026                Err(PluginError::Mount(self.manifest.id.clone()))
1027            } else {
1028                Ok(())
1029            }
1030        }
1031    }
1032
1033    fn plugin(
1034        id: &str,
1035        dependencies: BTreeMap<String, String>,
1036        fail: bool,
1037        unmounted: Arc<AtomicUsize>,
1038    ) -> TestPlugin {
1039        TestPlugin {
1040            manifest: PluginManifest {
1041                id: id.into(),
1042                version: "1.0.0".into(),
1043                dependencies,
1044                config_schema: serde_json::json!({"type":"object"}),
1045                permissions: BTreeSet::new(),
1046            },
1047            fail,
1048            unmounted,
1049        }
1050    }
1051
1052    #[tokio::test]
1053    async fn activation_failure_rolls_back_current_and_prior_leases() {
1054        let unmounted = Arc::new(AtomicUsize::new(0));
1055        let first = plugin("first", BTreeMap::new(), false, Arc::clone(&unmounted));
1056        let second = plugin(
1057            "second",
1058            BTreeMap::from([("first".into(), "^1".into())]),
1059            true,
1060            Arc::clone(&unmounted),
1061        );
1062        let context = PluginMountContext {
1063            tenant_id: "t".parse().unwrap(),
1064            subject_id: "s".parse().unwrap(),
1065            session_id: "session".parse().unwrap(),
1066            profile_revision_id: "profile".parse().unwrap(),
1067        };
1068        assert!(
1069            mount_plugins(&context, &[&second, &first], &BTreeMap::new())
1070                .await
1071                .is_err()
1072        );
1073        assert_eq!(unmounted.load(Ordering::SeqCst), 2);
1074    }
1075
1076    #[tokio::test]
1077    async fn activation_failure_cleans_current_plugin_and_retries_failed_cleanup() {
1078        let calls = Arc::new(std::sync::Mutex::new(Vec::new()));
1079        let first = CleanupPlugin {
1080            manifest: plugin(
1081                "first",
1082                BTreeMap::new(),
1083                false,
1084                Arc::new(AtomicUsize::new(0)),
1085            )
1086            .manifest,
1087            lease_id: "first",
1088            fail_activation: false,
1089            fail_once: Arc::new(AtomicBool::new(false)),
1090            calls: Arc::clone(&calls),
1091        };
1092        let second = CleanupPlugin {
1093            manifest: plugin(
1094                "second",
1095                BTreeMap::from([("first".into(), "^1".into())]),
1096                false,
1097                Arc::new(AtomicUsize::new(0)),
1098            )
1099            .manifest,
1100            lease_id: "second",
1101            fail_activation: true,
1102            fail_once: Arc::new(AtomicBool::new(true)),
1103            calls: Arc::clone(&calls),
1104        };
1105        let context = PluginMountContext {
1106            tenant_id: "t".parse().unwrap(),
1107            subject_id: "s".parse().unwrap(),
1108            session_id: "rollback".parse().unwrap(),
1109            profile_revision_id: "profile".parse().unwrap(),
1110        };
1111
1112        let mut error = mount_plugins(&context, &[&second, &first], &BTreeMap::new())
1113            .await
1114            .unwrap_err();
1115        assert!(matches!(error, PluginError::Rollback { .. }));
1116        assert_eq!(*calls.lock().unwrap(), vec!["second", "first"]);
1117        error.retry_cleanup().await.unwrap();
1118        assert_eq!(*calls.lock().unwrap(), vec!["second", "first", "second"]);
1119    }
1120
1121    #[tokio::test]
1122    async fn registration_conflict_rolls_back_current_and_prior_leases() {
1123        let unmounted = Arc::new(AtomicUsize::new(0));
1124        let manifest = |id: &str, dependencies| PluginManifest {
1125            id: id.into(),
1126            version: "1.0.0".into(),
1127            dependencies,
1128            config_schema: serde_json::json!({"type":"object"}),
1129            permissions: BTreeSet::from([PluginPermission::Tool]),
1130        };
1131        let first = RegisteringPlugin {
1132            manifest: manifest("first", BTreeMap::new()),
1133            unmounted: Arc::clone(&unmounted),
1134        };
1135        let second = RegisteringPlugin {
1136            manifest: manifest("second", BTreeMap::from([("first".into(), "^1".into())])),
1137            unmounted: Arc::clone(&unmounted),
1138        };
1139        let context = PluginMountContext {
1140            tenant_id: "t".parse().unwrap(),
1141            subject_id: "s".parse().unwrap(),
1142            session_id: "session".parse().unwrap(),
1143            profile_revision_id: "profile".parse().unwrap(),
1144        };
1145
1146        assert!(matches!(
1147            mount_plugins(&context, &[&second, &first], &BTreeMap::new()).await,
1148            Err(PluginError::Conflict(_))
1149        ));
1150        assert_eq!(unmounted.load(Ordering::SeqCst), 2);
1151    }
1152
1153    #[tokio::test]
1154    async fn session_lease_unmounts_after_last_runtime_clone() {
1155        let unmounted = Arc::new(AtomicUsize::new(0));
1156        let plugin = plugin("session", BTreeMap::new(), false, Arc::clone(&unmounted));
1157        let context = PluginMountContext {
1158            tenant_id: "t".parse().unwrap(),
1159            subject_id: "s".parse().unwrap(),
1160            session_id: "session".parse().unwrap(),
1161            profile_revision_id: "profile".parse().unwrap(),
1162        };
1163        let mounted = mount_plugins(&context, &[&plugin], &BTreeMap::new())
1164            .await
1165            .unwrap();
1166        let runtime_clone = mounted.clone();
1167        let teardown = tokio::spawn(async move {
1168            let mut mounted = mounted;
1169            mounted.unmount().await
1170        });
1171        tokio::task::yield_now().await;
1172        assert_eq!(unmounted.load(Ordering::SeqCst), 0);
1173        drop(runtime_clone);
1174        teardown.await.unwrap().unwrap();
1175        assert_eq!(unmounted.load(Ordering::SeqCst), 1);
1176    }
1177
1178    #[tokio::test]
1179    async fn cleanup_attempts_every_lease_and_retries_only_failures() {
1180        let calls = Arc::new(std::sync::Mutex::new(Vec::new()));
1181        let first = CleanupPlugin {
1182            manifest: plugin(
1183                "first-cleanup",
1184                BTreeMap::new(),
1185                false,
1186                Arc::new(AtomicUsize::new(0)),
1187            )
1188            .manifest,
1189            lease_id: "first",
1190            fail_activation: false,
1191            fail_once: Arc::new(AtomicBool::new(false)),
1192            calls: Arc::clone(&calls),
1193        };
1194        let second = CleanupPlugin {
1195            manifest: plugin(
1196                "second-cleanup",
1197                BTreeMap::from([("first-cleanup".into(), "^1".into())]),
1198                false,
1199                Arc::new(AtomicUsize::new(0)),
1200            )
1201            .manifest,
1202            lease_id: "second",
1203            fail_activation: false,
1204            fail_once: Arc::new(AtomicBool::new(true)),
1205            calls: Arc::clone(&calls),
1206        };
1207        let context = PluginMountContext {
1208            tenant_id: "t".parse().unwrap(),
1209            subject_id: "s".parse().unwrap(),
1210            session_id: "cleanup".parse().unwrap(),
1211            profile_revision_id: "profile".parse().unwrap(),
1212        };
1213        let mut mounted = mount_plugins(&context, &[&second, &first], &BTreeMap::new())
1214            .await
1215            .unwrap();
1216        assert!(mounted.unmount().await.is_err());
1217        assert_eq!(*calls.lock().unwrap(), vec!["second", "first"]);
1218        mounted.unmount().await.unwrap();
1219        assert_eq!(*calls.lock().unwrap(), vec!["second", "first", "second"]);
1220    }
1221
1222    #[test]
1223    fn dependency_versions_and_cycles_fail_before_mount() {
1224        let counter = Arc::new(AtomicUsize::new(0));
1225        let base = plugin("base", BTreeMap::new(), false, Arc::clone(&counter));
1226        let incompatible = plugin(
1227            "consumer",
1228            BTreeMap::from([("base".into(), "^2".into())]),
1229            false,
1230            Arc::clone(&counter),
1231        );
1232        assert!(matches!(
1233            resolve_plugin_order(&[&base, &incompatible]),
1234            Err(PluginError::DependencyVersion { .. })
1235        ));
1236        let left = plugin(
1237            "left",
1238            BTreeMap::from([("right".into(), "^1".into())]),
1239            false,
1240            Arc::clone(&counter),
1241        );
1242        let right = plugin(
1243            "right",
1244            BTreeMap::from([("left".into(), "^1".into())]),
1245            false,
1246            counter,
1247        );
1248        assert!(matches!(
1249            resolve_plugin_order(&[&left, &right]),
1250            Err(PluginError::DependencyCycle(_))
1251        ));
1252    }
1253
1254    #[test]
1255    fn descriptor_preflight_rejects_missing_and_incompatible_dependencies() {
1256        let descriptors = vec![
1257            PluginDescriptor {
1258                id: "base".into(),
1259                version: "1.0.0".into(),
1260                dependencies: BTreeMap::new(),
1261                permissions: BTreeSet::new(),
1262            },
1263            PluginDescriptor {
1264                id: "child".into(),
1265                version: "1.0.0".into(),
1266                dependencies: BTreeMap::from([("base".into(), "^2".into())]),
1267                permissions: BTreeSet::new(),
1268            },
1269        ];
1270        let references = BTreeMap::from([
1271            ("base".into(), ("1.0.0".into(), Value::Null)),
1272            ("child".into(), ("1.0.0".into(), Value::Null)),
1273        ]);
1274
1275        let issues = validate_plugin_references(&descriptors, &references);
1276        assert_eq!(issues.len(), 1);
1277        assert!(matches!(
1278            issues[0].error,
1279            PluginError::DependencyVersion { .. }
1280        ));
1281    }
1282
1283    #[test]
1284    fn descriptor_preflight_reports_every_missing_dependency() {
1285        let descriptors = vec![PluginDescriptor {
1286            id: "child".into(),
1287            version: "1.0.0".into(),
1288            dependencies: BTreeMap::from([
1289                ("first".into(), "^1".into()),
1290                ("second".into(), "^1".into()),
1291            ]),
1292            permissions: BTreeSet::new(),
1293        }];
1294        let references = BTreeMap::from([("child".into(), ("1.0.0".into(), Value::Null))]);
1295
1296        let issues = validate_plugin_references(&descriptors, &references);
1297
1298        assert_eq!(issues.len(), 2);
1299        assert!(issues.iter().all(|issue| issue.plugin == "child"
1300            && matches!(issue.error, PluginError::MissingDependency(_))));
1301    }
1302
1303    #[tokio::test]
1304    async fn registrar_and_catalog_enforce_manifest_permissions_atomically() {
1305        let manifest = PluginManifest {
1306            id: "test.plugin".into(),
1307            version: "1.0.0".into(),
1308            dependencies: BTreeMap::new(),
1309            config_schema: serde_json::json!({"type":"object","required":["enabled"]}),
1310            permissions: BTreeSet::from([
1311                PluginPermission::Prompt,
1312                PluginPermission::Tool,
1313                PluginPermission::Hook,
1314            ]),
1315        };
1316        manifest.validate().unwrap();
1317        for invalid in [
1318            PluginManifest {
1319                id: "Bad Plugin".into(),
1320                ..manifest.clone()
1321            },
1322            PluginManifest {
1323                version: "latest".into(),
1324                ..manifest.clone()
1325            },
1326        ] {
1327            assert!(matches!(
1328                invalid.validate(),
1329                Err(PluginError::InvalidManifest(_))
1330            ));
1331        }
1332
1333        let mut contributions = PluginContributions::default();
1334        {
1335            let mut registrar = AgentRegistrar {
1336                owner: &manifest,
1337                contributions: &mut contributions,
1338            };
1339            registrar.prompt("system", "prompt").unwrap();
1340            assert!(matches!(
1341                registrar.prompt("system", "again"),
1342                Err(PluginError::Conflict(_))
1343            ));
1344            registrar.tool(Arc::new(TestTool)).unwrap();
1345            assert!(matches!(
1346                registrar.tool(Arc::new(TestTool)),
1347                Err(PluginError::Conflict(_))
1348            ));
1349            registrar.hook(Arc::new(TestHook)).unwrap();
1350        }
1351        let prompt = contributions.prompts.values().next().unwrap();
1352        assert_eq!(prompt.id, "plugin.test.plugin.system");
1353        assert_eq!(prompt.source, "test.plugin");
1354        assert_eq!(prompt.version, "1.0.0");
1355
1356        let denied = PluginManifest {
1357            permissions: BTreeSet::new(),
1358            ..manifest.clone()
1359        };
1360        let mut denied_contributions = PluginContributions::default();
1361        let mut denied_registrar = AgentRegistrar {
1362            owner: &denied,
1363            contributions: &mut denied_contributions,
1364        };
1365        assert!(matches!(
1366            denied_registrar.prompt("x", "x"),
1367            Err(PluginError::Permission(_))
1368        ));
1369        let context = ToolExecutionContext {
1370            request: crate::RequestContext {
1371                tenant_id: "tenant".parse().unwrap(),
1372                subject_id: "subject".parse().unwrap(),
1373                roles: Default::default(),
1374                locale: "en".into(),
1375                request_id: "request".parse().unwrap(),
1376                entitlements: Default::default(),
1377            },
1378            session_id: "session".parse().unwrap(),
1379            run_id: "run".parse().unwrap(),
1380            step: 1,
1381            call_id: "call".parse().unwrap(),
1382            source_event_seq: 1,
1383            interaction_resolution: None,
1384            cancellation: crate::CancellationToken::default(),
1385            deadline: std::time::Instant::now() + std::time::Duration::from_secs(15),
1386        };
1387        assert_eq!(
1388            TestHook
1389                .before_tool(&context, "test.tool", &serde_json::json!({}))
1390                .await,
1391            HookDecision::Continue
1392        );
1393        TestHook
1394            .after_tool(
1395                &context,
1396                "test.tool",
1397                &serde_json::json!({}),
1398                &serde_json::json!({}),
1399            )
1400            .await
1401            .unwrap();
1402        let unmounted = Arc::new(AtomicUsize::new(0));
1403        let plugin = Arc::new(TestPlugin {
1404            manifest: manifest.clone(),
1405            fail: false,
1406            unmounted: Arc::clone(&unmounted),
1407        });
1408        let mut catalog = PluginCatalog::default();
1409        catalog.register(plugin.clone()).unwrap();
1410        assert!(matches!(
1411            catalog.register(plugin),
1412            Err(PluginError::Conflict(_))
1413        ));
1414        let mount_context = PluginMountContext {
1415            tenant_id: "t".parse().unwrap(),
1416            subject_id: "s".parse().unwrap(),
1417            session_id: "session".parse().unwrap(),
1418            profile_revision_id: "profile".parse().unwrap(),
1419        };
1420        assert!(matches!(
1421            catalog
1422                .mount(
1423                    &mount_context,
1424                    &BTreeMap::from([("missing".into(), ("1.0.0".into(), serde_json::json!({})))])
1425                )
1426                .await,
1427            Err(PluginError::MissingDependency(_))
1428        ));
1429        assert!(matches!(
1430            catalog
1431                .mount(
1432                    &mount_context,
1433                    &BTreeMap::from([(
1434                        "test.plugin".into(),
1435                        ("2.0.0".into(), serde_json::json!({}))
1436                    )])
1437                )
1438                .await,
1439            Err(PluginError::DependencyVersion { .. })
1440        ));
1441        assert!(matches!(
1442            catalog
1443                .mount(
1444                    &mount_context,
1445                    &BTreeMap::from([(
1446                        "test.plugin".into(),
1447                        ("1.0.0".into(), serde_json::json!({}))
1448                    )])
1449                )
1450                .await,
1451            Err(PluginError::InvalidConfig(_, _))
1452        ));
1453        let mut mounted = catalog
1454            .mount(
1455                &mount_context,
1456                &BTreeMap::from([(
1457                    "test.plugin".into(),
1458                    ("1.0.0".into(), serde_json::json!({"enabled":true})),
1459                )]),
1460            )
1461            .await
1462            .unwrap();
1463        mounted.unmount().await.unwrap();
1464        assert_eq!(unmounted.load(Ordering::SeqCst), 1);
1465    }
1466}