1use std::path::{Component, Path, PathBuf};
4
5use crate::tools::plugins::PluginRuntime;
6use crate::utils::validation::{validate_all_non_empty, validate_non_empty};
7use anyhow::{Context, Result, bail};
8use tokio::fs;
9use vtcode_commons::VtCodePaths;
10use vtcode_commons::fs::{read_private_file_no_follow, write_private_file_atomic, write_private_json_file};
11
12use super::PluginManifest;
13
14pub struct PluginInstaller {
16 pub plugins_dir: PathBuf,
18
19 core_plugin_runtime: Option<PluginRuntime>,
21}
22
23impl PluginInstaller {
24 pub fn new(plugins_dir: PathBuf, core_plugin_runtime: Option<PluginRuntime>) -> Self {
26 Self { plugins_dir, core_plugin_runtime }
27 }
28
29 pub async fn install_plugin(&self, manifest: &PluginManifest) -> Result<()> {
31 self.validate_manifest(manifest)?;
32
33 VtCodePaths::ensure_user_dir(&self.plugins_dir)
35 .with_context(|| format!("Failed to create plugin directory: {}", self.plugins_dir.display()))?;
36
37 let plugin_dir = self.plugins_dir.join(&manifest.id);
39 VtCodePaths::ensure_user_dir(&plugin_dir)
40 .with_context(|| format!("Failed to create plugin directory: {}", plugin_dir.display()))?;
41
42 self.download_plugin(manifest, &plugin_dir).await?;
44
45 let manifest_dir = plugin_dir.join(".vtcode-plugin");
47 let manifest_path = manifest_dir.join("plugin.json");
48 write_private_json_file(&manifest_path, manifest).await?;
49
50 self.integrate_with_core_plugin_system(&manifest_path).await?;
52
53 Ok(())
54 }
55
56 async fn integrate_with_core_plugin_system(&self, manifest_path: &Path) -> Result<()> {
58 if let Some(runtime) = &self.core_plugin_runtime {
60 let handle = runtime.register_manifest(manifest_path).await?;
62 tracing::info!(plugin_id = %handle.manifest.id, "registered plugin with core runtime");
63 } else {
64 tracing::info!(path = %manifest_path.display(), "no core plugin runtime, skipping integration");
65 }
66
67 Ok(())
68 }
69
70 async fn download_plugin(&self, manifest: &PluginManifest, plugin_dir: &Path) -> Result<()> {
72 self.validate_manifest(manifest)?;
74
75 tracing::info!(plugin_id = %manifest.id, source = %manifest.source, "downloading plugin");
76
77 if manifest.source.starts_with("http") {
79 self.download_from_http(manifest, plugin_dir).await?;
80 } else if manifest.source.starts_with("file://") {
81 self.download_from_file(manifest, plugin_dir).await?;
82 } else if fs::try_exists(&manifest.source)
83 .await
84 .with_context(|| format!("Failed to check plugin source {}", manifest.source))?
85 {
86 self.download_from_local(manifest, plugin_dir).await?;
88 } else {
89 self.download_from_git(manifest, plugin_dir).await?;
91 }
92
93 Ok(())
94 }
95
96 async fn download_from_http(&self, manifest: &PluginManifest, plugin_dir: &Path) -> Result<()> {
98 let placeholder_path = self.entrypoint_path(plugin_dir, manifest)?;
100
101 write_private_file_atomic(&placeholder_path, format!("# HTTP Downloaded plugin: {}\n", manifest.id)).await?;
102
103 tracing::info!(plugin_id = %manifest.id, "http download completed");
104 Ok(())
105 }
106
107 async fn download_from_file(&self, manifest: &PluginManifest, plugin_dir: &Path) -> Result<()> {
109 let source_path = PathBuf::from(&manifest.source.replace("file://", ""));
110 let content = read_private_file_no_follow(&source_path)
111 .await
112 .with_context(|| format!("Failed to read local source file {}", source_path.display()))?;
113 let dest_path = self.entrypoint_path(plugin_dir, manifest)?;
114
115 write_private_file_atomic(&dest_path, content).await.with_context(|| {
117 format!("Failed to copy plugin from {} to {}", source_path.display(), dest_path.display())
118 })?;
119
120 tracing::info!(plugin_id = %manifest.id, "local file copy completed");
121 Ok(())
122 }
123
124 async fn download_from_local(&self, manifest: &PluginManifest, plugin_dir: &Path) -> Result<()> {
126 let source_path = PathBuf::from(&manifest.source);
127 let content = read_private_file_no_follow(&source_path)
128 .await
129 .with_context(|| format!("Failed to read local source path {}", source_path.display()))?;
130 let dest_path = self.entrypoint_path(plugin_dir, manifest)?;
131
132 write_private_file_atomic(&dest_path, content).await.with_context(|| {
134 format!("Failed to copy plugin from {} to {}", source_path.display(), dest_path.display())
135 })?;
136
137 tracing::info!(plugin_id = %manifest.id, "local path copy completed");
138 Ok(())
139 }
140
141 async fn download_from_git(&self, manifest: &PluginManifest, plugin_dir: &Path) -> Result<()> {
143 let placeholder_path = self.entrypoint_path(plugin_dir, manifest)?;
145
146 write_private_file_atomic(&placeholder_path, format!("# Git downloaded plugin: {}\n", manifest.id)).await?;
147
148 tracing::info!(plugin_id = %manifest.id, "git download completed");
149 Ok(())
150 }
151
152 pub fn validate_manifest(&self, manifest: &PluginManifest) -> Result<()> {
154 validate_non_empty(&manifest.id, "Plugin ID")?;
156 validate_non_empty(&manifest.name, "Plugin name")?;
157 validate_non_empty(&manifest.source, "Plugin source URL")?;
158
159 validate_plugin_component(&manifest.id, "Plugin ID")?;
161 validate_relative_path(&manifest.entrypoint, "Plugin entrypoint")?;
162
163 if let Some(trust_level) = &manifest.trust_level {
165 match trust_level {
166 crate::config::PluginTrustLevel::Sandbox
167 | crate::config::PluginTrustLevel::Trusted
168 | crate::config::PluginTrustLevel::Untrusted => {
169 }
171 }
172 }
173
174 validate_all_non_empty(&manifest.dependencies, "Plugin dependencies")?;
176
177 Ok(())
178 }
179
180 pub async fn uninstall_plugin(&self, plugin_id: &str) -> Result<()> {
182 validate_plugin_component(plugin_id, "Plugin ID")?;
183 VtCodePaths::ensure_user_dir(&self.plugins_dir)
184 .with_context(|| format!("Failed to validate plugin directory: {}", self.plugins_dir.display()))?;
185 let plugin_dir = self.plugins_dir.join(plugin_id);
186 let metadata = match fs::symlink_metadata(&plugin_dir).await {
187 Ok(metadata) => metadata,
188 Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
189 bail!("Installed plugin path does not exist: {}", plugin_dir.display());
190 }
191 Err(error) => {
192 return Err(error)
193 .with_context(|| format!("Failed to inspect installed plugin {}", plugin_dir.display()));
194 }
195 };
196 if metadata.file_type().is_symlink() || !metadata.is_dir() {
197 bail!("Refusing to remove non-directory plugin path: {}", plugin_dir.display());
198 }
199
200 self.remove_from_core_plugin_system(plugin_id).await?;
202
203 fs::remove_dir_all(&plugin_dir)
204 .await
205 .with_context(|| format!("Failed to remove plugin directory: {}", plugin_dir.display()))?;
206
207 Ok(())
208 }
209
210 async fn remove_from_core_plugin_system(&self, plugin_id: &str) -> Result<()> {
212 if let Some(runtime) = &self.core_plugin_runtime {
214 runtime
216 .unload_plugin(plugin_id)
217 .await
218 .with_context(|| format!("Failed to unload plugin from runtime: {plugin_id}"))?;
219 tracing::info!(plugin_id = %plugin_id, "unloaded plugin from core runtime");
220 } else {
221 tracing::info!(plugin_id = %plugin_id, "no core plugin runtime, skipping removal");
222 }
223
224 Ok(())
225 }
226
227 pub async fn is_installed(&self, plugin_id: &str) -> bool {
229 if validate_plugin_component(plugin_id, "Plugin ID").is_err() {
230 return false;
231 }
232 let plugin_dir = self.plugins_dir.join(plugin_id);
233 fs::symlink_metadata(plugin_dir)
234 .await
235 .is_ok_and(|metadata| metadata.is_dir() && !metadata.file_type().is_symlink())
236 }
237
238 fn entrypoint_path(&self, plugin_dir: &Path, manifest: &PluginManifest) -> Result<PathBuf> {
239 validate_relative_path(&manifest.entrypoint, "Plugin entrypoint")?;
240 let path = plugin_dir.join(&manifest.entrypoint);
241 if let Some(parent) = path.parent() {
242 VtCodePaths::ensure_user_dir(parent)
243 .with_context(|| format!("Failed to create plugin entrypoint directory: {}", parent.display()))?;
244 }
245 Ok(path)
246 }
247}
248
249fn validate_plugin_component(value: &str, label: &str) -> Result<()> {
250 let mut components = Path::new(value).components();
251 if value.trim().is_empty()
252 || !matches!(components.next(), Some(Component::Normal(_)))
253 || components.next().is_some()
254 {
255 bail!("{label} must be one normal path component: {value}");
256 }
257 Ok(())
258}
259
260fn validate_relative_path(path: &Path, label: &str) -> Result<()> {
261 if path.as_os_str().is_empty() || !path.components().all(|component| matches!(component, Component::Normal(_))) {
262 bail!("{label} must be a non-empty relative path without traversal: {}", path.display());
263 }
264 Ok(())
265}