Skip to main content

vtcode_core/tools/plugins/
mod.rs

1use hashbrown::HashMap;
2use std::path::{Path, PathBuf};
3use std::time::SystemTime;
4
5use anyhow::{Context, Result, bail, ensure};
6use async_trait::async_trait;
7use serde::{Deserialize, Serialize};
8use tokio::sync::RwLock;
9
10use crate::config::{PluginRuntimeConfig, PluginTrustLevel};
11use crate::tools::ToolRegistry;
12use crate::tools::registry::ToolRegistration;
13use crate::utils::error_messages::{ERR_DESERIALIZE, ERR_READ_FILE};
14use crate::utils::file_utils::read_file_with_context;
15
16pub type PluginId = String;
17
18/// Declarative metadata for a plugin manifest.
19#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
20#[derive(Debug, Clone, Deserialize, Serialize)]
21pub struct PluginManifest {
22    /// Unique identifier. Falls back to the `name` when omitted.
23    #[serde(default)]
24    pub id: PluginId,
25
26    /// Human-readable plugin name.
27    pub name: String,
28
29    /// Semantic version string for compatibility checks.
30    #[serde(default)]
31    pub version: String,
32
33    /// Short description of the plugin.
34    #[serde(default)]
35    pub description: String,
36
37    /// Entrypoint script or binary to launch.
38    #[serde(default)]
39    pub entrypoint: PathBuf,
40
41    /// Capability descriptors advertised by the plugin.
42    #[serde(default)]
43    pub capabilities: Vec<String>,
44
45    /// Optional explicit trust level.
46    #[serde(default)]
47    pub trust_level: Option<PluginTrustLevel>,
48
49    /// Arbitrary metadata for adapters (environment, labels, etc.).
50    #[serde(default)]
51    pub metadata: serde_json::Value,
52}
53
54impl PluginManifest {
55    fn normalized(mut self, default_trust: PluginTrustLevel) -> Self {
56        if self.id.is_empty() {
57            self.id = std::mem::take(&mut self.name);
58        }
59
60        if self.trust_level.is_none() {
61            self.trust_level = Some(default_trust);
62        }
63
64        self
65    }
66}
67
68#[derive(Debug, Clone)]
69pub struct PluginHandle {
70    pub manifest: PluginManifest,
71    pub manifest_path: PathBuf,
72    pub loaded_at: SystemTime,
73}
74
75#[async_trait]
76pub trait PluginInstaller: Send + Sync {
77    /// Convert a manifest into a runtime tool registration.
78    async fn materialize(&self, manifest: &PluginManifest) -> Result<ToolRegistration>;
79}
80
81/// Runtime for loading, tracking, and hot-swapping plugin manifests.
82#[derive(Debug)]
83pub struct PluginRuntime {
84    workspace_root: PathBuf,
85    config: PluginRuntimeConfig,
86    plugins: RwLock<HashMap<PluginId, PluginHandle>>,
87}
88
89impl PluginRuntime {
90    pub fn new(workspace_root: PathBuf, config: PluginRuntimeConfig) -> Self {
91        Self {
92            workspace_root,
93            config,
94            plugins: RwLock::new(HashMap::new()),
95        }
96    }
97
98    pub fn config(&self) -> &PluginRuntimeConfig {
99        &self.config
100    }
101
102    pub async fn load_manifest(&self, manifest_path: impl AsRef<Path>) -> Result<PluginManifest> {
103        let path = manifest_path.as_ref();
104        let data = read_file_with_context(path, "plugin manifest")
105            .await
106            .with_context(|| format!("{ERR_READ_FILE}: {}", path.display()))?;
107
108        let manifest: PluginManifest = toml::from_str(&data)
109            .with_context(|| format!("{ERR_DESERIALIZE}: {}", path.display()))?;
110
111        Ok(manifest.normalized(self.config.default_trust))
112    }
113
114    pub async fn register_manifest(&self, manifest_path: impl AsRef<Path>) -> Result<PluginHandle> {
115        let path = manifest_path.as_ref();
116        ensure!(
117            self.config.enabled,
118            "plugin runtime disabled by configuration"
119        );
120
121        let manifest = self.load_manifest(path).await?;
122        self.validate_trust(&manifest)?;
123
124        let handle = PluginHandle {
125            manifest: manifest.clone(),
126            manifest_path: path.to_path_buf(),
127            loaded_at: SystemTime::now(),
128        };
129
130        let mut plugins = self.plugins.write().await;
131        plugins.insert(manifest.id.clone(), handle.clone());
132        Ok(handle)
133    }
134
135    pub async fn hot_swap(&self, manifest_path: impl AsRef<Path>) -> Result<PluginHandle> {
136        let handle = self.register_manifest(manifest_path).await?;
137        Ok(handle)
138    }
139
140    pub async fn attach_to_registry(
141        &self,
142        registry: &ToolRegistry,
143        manifest: &PluginManifest,
144        installer: &dyn PluginInstaller,
145    ) -> Result<()> {
146        ensure!(
147            self.config.enabled,
148            "plugin runtime disabled by configuration"
149        );
150        self.validate_trust(manifest)?;
151
152        let registration = installer.materialize(manifest).await?;
153        registry.register_tool(registration).await?;
154        Ok(())
155    }
156
157    pub async fn list_registered(&self) -> Vec<PluginHandle> {
158        let plugins = self.plugins.read().await;
159        plugins.values().cloned().collect()
160    }
161
162    pub async fn unload_plugin(&self, plugin_id: &str) -> Result<()> {
163        let mut plugins = self.plugins.write().await;
164        if plugins.remove(plugin_id).is_some() {
165            Ok(())
166        } else {
167            bail!("plugin {} not found in runtime", plugin_id)
168        }
169    }
170
171    fn validate_trust(&self, manifest: &PluginManifest) -> Result<()> {
172        if self
173            .config
174            .deny
175            .iter()
176            .any(|blocked| blocked == &manifest.id)
177        {
178            bail!("plugin {} is blocked by deny list", manifest.id);
179        }
180
181        if !self.config.allow.is_empty()
182            && !self
183                .config
184                .allow
185                .iter()
186                .any(|allowed| allowed == &manifest.id)
187        {
188            bail!("plugin {} not present in allow list", manifest.id);
189        }
190
191        let trust = manifest.trust_level.unwrap_or(self.config.default_trust);
192        ensure!(
193            matches!(
194                trust,
195                PluginTrustLevel::Sandbox | PluginTrustLevel::Trusted | PluginTrustLevel::Untrusted
196            ),
197            "invalid trust level for plugin {}",
198            manifest.id
199        );
200        Ok(())
201    }
202
203    pub fn workspace_root(&self) -> &Path {
204        &self.workspace_root
205    }
206}
207
208#[cfg(test)]
209mod tests {
210    use super::*;
211
212    fn manifest_toml(id: &str) -> String {
213        format!(
214            r#"
215name = "{id}"
216version = "0.1.0"
217description = "test plugin"
218entrypoint = "bin/plugin"
219"#
220        )
221    }
222
223    #[tokio::test]
224    async fn deny_list_blocks_manifest() {
225        let tmp_dir = std::env::temp_dir();
226        let manifest_path = tmp_dir.join("vtcode_plugin_deny.toml");
227        tokio::fs::write(&manifest_path, manifest_toml("blocked"))
228            .await
229            .expect("write manifest");
230
231        let runtime = PluginRuntime::new(
232            tmp_dir.clone(),
233            PluginRuntimeConfig {
234                deny: vec!["blocked".into()],
235                ..PluginRuntimeConfig::default()
236            },
237        );
238
239        let err = runtime.register_manifest(&manifest_path).await.unwrap_err();
240        assert!(
241            err.to_string().contains("blocked"),
242            "expected deny list rejection"
243        );
244    }
245
246    #[tokio::test]
247    async fn allow_list_enforced() {
248        let tmp_dir = std::env::temp_dir();
249        let manifest_path = tmp_dir.join("vtcode_plugin_allow.toml");
250        tokio::fs::write(&manifest_path, manifest_toml("allowed"))
251            .await
252            .expect("write manifest");
253
254        let runtime = PluginRuntime::new(
255            tmp_dir.clone(),
256            PluginRuntimeConfig {
257                allow: vec!["allowed".into()],
258                ..PluginRuntimeConfig::default()
259            },
260        );
261
262        let handle = runtime
263            .register_manifest(&manifest_path)
264            .await
265            .expect("allowed manifest to register");
266
267        assert_eq!(handle.manifest.id, "allowed");
268    }
269}