Skip to main content

vtcode_agent_plugins/
discovery.rs

1use std::path::{Path, PathBuf};
2
3use crate::errors::PluginError;
4use crate::manifest::PluginManifest;
5use crate::mcp::McpConfig;
6use vtcode_skills::types::SkillManifest;
7
8#[derive(Debug, Clone)]
9pub struct DiscoveredSkill {
10    pub name: String,
11    pub dir_name: String,
12    pub skill_md_path: PathBuf,
13    pub manifest: SkillManifest,
14}
15
16#[derive(Debug, Clone)]
17pub struct LoadedPlugin {
18    pub manifest: PluginManifest,
19    pub root: PathBuf,
20    pub skills: Vec<DiscoveredSkill>,
21    pub mcp: Option<McpConfig>,
22}
23
24impl LoadedPlugin {
25    /// Load a plugin from a directory. This is the orchestrator: it calls the
26    /// individual phases (manifest, skills, MCP) and wires them together.
27    pub fn load_from_dir(plugin_root: &Path) -> Result<Self, PluginError> {
28        // Filesystem-resolved root per §4.1: resolve symlinks so later
29        // containment checks compare canonical paths.
30        let absolute = std::path::absolute(plugin_root).map_err(PluginError::Io)?;
31        let root = vtcode_commons::canonicalize(&absolute).unwrap_or(absolute);
32        let manifest = Self::parse_manifest_dir(&root)?;
33        let skills = Self::discover_skills_dir(&root)?;
34        let mcp = Self::discover_mcp_dir(&root)?;
35
36        // If MCP is present but its schema version disagrees with the manifest,
37        // disable MCP for this plugin rather than failing the whole load.
38        let mcp = match mcp {
39            Some(ref mcp_config)
40                if !mcp_config.schema.is_empty()
41                    && !manifest.schema.is_empty()
42                    && extract_schema_version(&manifest.schema) != extract_schema_version(&mcp_config.schema) =>
43            {
44                tracing::warn!(
45                    manifest_version = ?extract_schema_version(&manifest.schema),
46                    mcp_version = ?extract_schema_version(&mcp_config.schema),
47                    "mcp.json schema version does not match plugin.json; disabling MCP for plugin"
48                );
49                None
50            }
51            other => other,
52        };
53
54        Ok(LoadedPlugin { manifest, root, skills, mcp })
55    }
56
57    /// Parse the `plugin.json` manifest from a plugin directory.
58    pub fn parse_manifest_dir(plugin_root: &Path) -> Result<PluginManifest, PluginError> {
59        let manifest_path = plugin_root.join("plugin.json");
60        // §4.1 boundary 1: plugin.json escaping the root rejects the plugin.
61        let _ = ensure_package_path_within(plugin_root, &manifest_path)?;
62        let content = std::fs::read_to_string(&manifest_path)?;
63        let (manifest, unknown_fields) = PluginManifest::parse(&content)?;
64        for field in &unknown_fields {
65            tracing::warn!(field = field, "unknown top-level field in plugin.json");
66        }
67        Ok(manifest)
68    }
69
70    /// Discover bundled skills under `skills/*/SKILL.md`.
71    pub fn discover_skills_dir(plugin_root: &Path) -> Result<Vec<DiscoveredSkill>, PluginError> {
72        discover_skills(plugin_root)
73    }
74
75    /// Discover the optional `mcp.json` MCP server configuration.
76    pub fn discover_mcp_dir(plugin_root: &Path) -> Result<Option<McpConfig>, PluginError> {
77        discover_mcp(plugin_root)
78    }
79}
80
81fn extract_schema_version(schema: &str) -> Option<&str> {
82    schema
83        .strip_prefix("https://agent-plugins.org/schemas/")
84        .and_then(|rest| rest.split('/').next())
85}
86
87fn discover_skills(plugin_root: &Path) -> Result<Vec<DiscoveredSkill>, PluginError> {
88    let skills_dir = plugin_root.join("skills");
89    // Missing location is valid absence (§6.2). Use symlink_metadata so a
90    // missing path does not follow links.
91    match std::fs::symlink_metadata(&skills_dir) {
92        Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()),
93        Err(e) => return Err(PluginError::Io(e)),
94        Ok(_) => {}
95    }
96    // §4.1 boundary 2 + §6.2: wrong kind or escape invalidates only this
97    // component type; other types continue loading.
98    if ensure_package_path_within(plugin_root, &skills_dir).is_err() {
99        tracing::warn!(
100            plugin_root = %plugin_root.display(),
101            "skills location escapes plugin root; disabling skills for plugin"
102        );
103        return Ok(Vec::new());
104    }
105    if !skills_dir.is_dir() {
106        tracing::warn!(
107            plugin_root = %plugin_root.display(),
108            "skills location is not a directory; disabling skills for plugin"
109        );
110        return Ok(Vec::new());
111    }
112
113    let mut skills = Vec::new();
114    for entry in std::fs::read_dir(&skills_dir)? {
115        let entry = entry?;
116        let path = entry.path();
117        if !path.is_dir() {
118            continue;
119        }
120        // Immediate child escaping via symlink is skipped, not fatal.
121        if ensure_package_path_within(plugin_root, &path).is_err() {
122            tracing::warn!(
123                plugin_root = %plugin_root.display(),
124                skill_dir = %path.display(),
125                "skill directory escapes plugin root; skipping"
126            );
127            continue;
128        }
129        let skill_md = path.join("SKILL.md");
130        // §4.1 boundary 3: SKILL.md escaping the root skips only that skill.
131        if ensure_package_path_within(plugin_root, &skill_md).is_err() {
132            tracing::warn!(
133                plugin_root = %plugin_root.display(),
134                skill_md = %skill_md.display(),
135                "SKILL.md escapes plugin root; skipping skill"
136            );
137            continue;
138        }
139        if !skill_md.is_file() {
140            continue;
141        }
142
143        let dir_name = path
144            .file_name()
145            .and_then(|s| s.to_string_lossy().into_owned().into())
146            .unwrap_or_default();
147
148        match load_one_skill(&skill_md, &dir_name) {
149            Ok(skill) => {
150                // Post mismatch-downgrade two dirs can share one manifest name;
151                // first wins so the catalog stays deterministic.
152                if skills.iter().any(|s: &DiscoveredSkill| s.name == skill.name) {
153                    tracing::warn!(
154                        plugin_root = %plugin_root.display(),
155                        skill_name = %skill.name,
156                        skill_dir = %dir_name,
157                        "duplicate skill name in plugin; keeping first occurrence"
158                    );
159                    continue;
160                }
161                skills.push(skill);
162            }
163            // A broken skill must not take down the whole plugin: skip it and
164            // continue with the remaining skills.
165            Err(e) => {
166                tracing::warn!(
167                    plugin_root = %plugin_root.display(),
168                    skill_dir = %dir_name,
169                    error = %e,
170                    "skipping invalid plugin skill"
171                );
172            }
173        }
174    }
175
176    Ok(skills)
177}
178
179fn load_one_skill(skill_md: &Path, dir_name: &str) -> Result<DiscoveredSkill, PluginError> {
180    let content = std::fs::read_to_string(skill_md)?;
181    let (skill_manifest, _instructions) =
182        vtcode_skills::manifest::parse_skill_content(&content).map_err(|e| PluginError::InvalidSkill(e.to_string()))?;
183
184    // Agent Skills client guide + vtcode-skills::parse_skill_file: directory
185    // mismatch warns but loads so cross-client renames still work. Discovery
186    // keys by manifest name; dir_name is retained for diagnostics.
187    if skill_manifest.name != dir_name {
188        tracing::warn!(
189            skill_name = %skill_manifest.name,
190            dir_name = %dir_name,
191            "skill name does not match directory; loading by manifest name"
192        );
193    }
194
195    skill_manifest
196        .validate()
197        .map_err(|e| PluginError::InvalidSkill(e.to_string()))?;
198
199    Ok(DiscoveredSkill {
200        name: skill_manifest.name.clone(),
201        dir_name: dir_name.to_string(),
202        skill_md_path: skill_md.to_path_buf(),
203        manifest: skill_manifest,
204    })
205}
206
207fn ensure_package_path_within(plugin_root: &Path, candidate: &Path) -> Result<PathBuf, PluginError> {
208    let canonical_root = vtcode_commons::canonicalize(plugin_root)
209        .map_err(|e| PluginError::PathEscape(format!("plugin root could not be resolved: {e}")))?;
210    // Resolve the candidate (following symlinks) or its deepest existing
211    // ancestor when the leaf does not exist yet. Shared helper lives in
212    // `expansion.rs` so the two call sites cannot drift.
213    let resolved = crate::expansion::canonicalize_allow_missing(candidate)
214        .map_err(|e| PluginError::PathEscape(format!("path could not be resolved: {} ({e})", candidate.display())))?;
215    if resolved.components().any(|c| matches!(c, std::path::Component::ParentDir)) {
216        return Err(PluginError::PathEscape(format!("path escapes plugin root: {}", candidate.display())));
217    }
218    if !resolved.starts_with(&canonical_root) {
219        return Err(PluginError::PathEscape(format!("path escapes plugin root: {}", candidate.display())));
220    }
221    Ok(resolved)
222}
223
224fn discover_mcp(plugin_root: &Path) -> Result<Option<McpConfig>, PluginError> {
225    let mcp_path = plugin_root.join("mcp.json");
226    match std::fs::symlink_metadata(&mcp_path) {
227        Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None),
228        Err(e) => return Err(PluginError::Io(e)),
229        Ok(_) => {}
230    }
231    // §4.1 boundary 4 + §6.2: escape or wrong kind disables MCP only.
232    if ensure_package_path_within(plugin_root, &mcp_path).is_err() {
233        tracing::warn!(
234            plugin_root = %plugin_root.display(),
235            "mcp.json escapes plugin root; disabling MCP for plugin"
236        );
237        return Ok(None);
238    }
239    if !mcp_path.is_file() {
240        tracing::warn!(
241            plugin_root = %plugin_root.display(),
242            "mcp.json is not a regular file; disabling MCP for plugin"
243        );
244        return Ok(None);
245    }
246
247    let content = std::fs::read_to_string(&mcp_path)?;
248    let config = McpConfig::parse(&content)?;
249    if !config.unknown_fields.is_empty() {
250        tracing::warn!(
251            fields = ?config.unknown_fields,
252            "unknown top-level fields in plugin mcp.json; disabling MCP for this plugin"
253        );
254        return Ok(None);
255    }
256    Ok(Some(config))
257}