Skip to main content

edgecrab_plugins/
manifest.rs

1use std::collections::HashMap;
2use std::path::Path;
3
4use regex::Regex;
5use serde::{Deserialize, Serialize};
6use toml::Value;
7
8use crate::error::PluginError;
9use crate::hermes::{
10    looks_like_hermes_plugin, parse_hermes_manifest,
11    synthesize_manifest as synthesize_hermes_manifest,
12};
13use crate::skill::manifest::{SkillManifest, parse_skill_manifest};
14use crate::types::{PluginKind, TrustLevel};
15
16#[derive(Debug, Clone, Serialize, Deserialize)]
17pub struct PluginManifest {
18    pub plugin: PluginMetadata,
19    #[serde(default)]
20    pub exec: Option<PluginExecConfig>,
21    #[serde(default)]
22    pub script: Option<PluginScriptConfig>,
23    #[serde(default)]
24    pub tools: Vec<PluginToolDefinition>,
25    #[serde(default)]
26    pub capabilities: PluginCapabilities,
27    #[serde(default)]
28    pub trust: Option<PluginTrustConfig>,
29    #[serde(default)]
30    pub integrity: Option<PluginIntegrityConfig>,
31}
32
33#[derive(Debug, Clone, Serialize, Deserialize)]
34pub struct PluginMetadata {
35    pub name: String,
36    pub version: String,
37    pub description: String,
38    pub kind: PluginKind,
39    #[serde(default)]
40    pub author: String,
41    #[serde(default)]
42    pub license: String,
43    #[serde(default)]
44    pub homepage: Option<String>,
45    #[serde(default)]
46    pub min_edgecrab_version: Option<String>,
47}
48
49#[derive(Debug, Clone, Serialize, Deserialize)]
50pub struct PluginExecConfig {
51    pub command: String,
52    #[serde(default)]
53    pub args: Vec<String>,
54    #[serde(default)]
55    pub cwd: Option<String>,
56    #[serde(default)]
57    pub env: HashMap<String, String>,
58    #[serde(default = "default_startup_timeout_secs")]
59    pub startup_timeout_secs: u64,
60    #[serde(default = "default_call_timeout_secs")]
61    pub call_timeout_secs: u64,
62    #[serde(default)]
63    pub restart_policy: PluginRestartPolicy,
64    #[serde(default = "default_restart_max_attempts")]
65    pub restart_max_attempts: u32,
66    #[serde(default = "default_idle_timeout_secs")]
67    pub idle_timeout_secs: u64,
68}
69
70#[derive(Debug, Clone, Serialize, Deserialize)]
71pub struct PluginScriptConfig {
72    pub file: String,
73    #[serde(default = "default_max_operations")]
74    pub max_operations: u64,
75    #[serde(default = "default_max_call_depth")]
76    pub max_call_depth: usize,
77}
78
79#[derive(Debug, Clone, Serialize, Deserialize)]
80pub struct PluginToolDefinition {
81    pub name: String,
82    pub description: String,
83}
84
85#[derive(Debug, Clone, Serialize, Deserialize, Default)]
86pub struct PluginCapabilities {
87    #[serde(default)]
88    pub host: Vec<String>,
89    #[serde(default)]
90    pub secrets: Vec<String>,
91    #[serde(default)]
92    pub allowed_hosts: Vec<String>,
93    #[serde(default)]
94    pub allowed_paths: Vec<String>,
95    #[serde(default)]
96    pub required_host_toolsets: Vec<String>,
97}
98
99#[derive(Debug, Clone, Serialize, Deserialize)]
100pub struct PluginTrustConfig {
101    #[serde(default)]
102    pub level: TrustLevel,
103    #[serde(default)]
104    pub source: Option<String>,
105}
106
107#[derive(Debug, Clone, Serialize, Deserialize)]
108pub struct PluginIntegrityConfig {
109    pub checksum: String,
110}
111
112pub const INSTALL_METADATA_FILE: &str = ".edgecrab-install.json";
113
114#[derive(Debug, Clone, Serialize, Deserialize)]
115pub struct InstallMetadata {
116    pub trust_level: TrustLevel,
117    pub source: String,
118    pub checksum: String,
119}
120
121#[derive(Debug, Clone, Copy, Serialize, Deserialize, Default)]
122#[serde(rename_all = "kebab-case")]
123pub enum PluginRestartPolicy {
124    Never,
125    #[default]
126    Once,
127    Always,
128}
129
130fn default_startup_timeout_secs() -> u64 {
131    10
132}
133
134fn default_call_timeout_secs() -> u64 {
135    60
136}
137
138fn default_idle_timeout_secs() -> u64 {
139    300
140}
141
142fn default_restart_max_attempts() -> u32 {
143    3
144}
145
146fn default_max_operations() -> u64 {
147    100_000
148}
149
150fn default_max_call_depth() -> usize {
151    50
152}
153
154pub fn parse_plugin_manifest(path: &Path) -> Result<PluginManifest, PluginError> {
155    let content = std::fs::read_to_string(path)?;
156    parse_plugin_manifest_str(path, &content)
157}
158
159pub fn parse_plugin_manifest_str(
160    path: &Path,
161    content: &str,
162) -> Result<PluginManifest, PluginError> {
163    let manifest: PluginManifest = toml::from_str(content)?;
164    validate_plugin_manifest(path, &manifest)?;
165    Ok(manifest)
166}
167
168pub fn ensure_installable_manifest(dir: &Path) -> Result<std::path::PathBuf, PluginError> {
169    let manifest_path = dir.join("plugin.toml");
170    if manifest_path.is_file() {
171        return Ok(manifest_path);
172    }
173
174    let manifest = if looks_like_hermes_plugin(dir) {
175        let hermes_manifest = parse_hermes_manifest(dir)?;
176        synthesize_hermes_manifest(dir, &hermes_manifest)
177    } else {
178        let skill_path = dir.join("SKILL.md");
179        if !skill_path.is_file() {
180            return Err(PluginError::MissingManifest {
181                path: manifest_path,
182            });
183        }
184        let skill_manifest = parse_skill_manifest(&skill_path)?;
185        synthesize_skill_manifest(&skill_manifest)
186    };
187
188    write_plugin_manifest(&manifest_path, &manifest)?;
189    Ok(manifest_path)
190}
191
192fn validate_plugin_manifest(path: &Path, manifest: &PluginManifest) -> Result<(), PluginError> {
193    let name_re = Regex::new(r"^[a-z0-9][a-z0-9._-]*$").expect("valid regex");
194    if manifest.plugin.name.trim().is_empty() {
195        return invalid_manifest(path, "plugin.name is required");
196    }
197    if manifest.plugin.name.len() > 64 || !name_re.is_match(&manifest.plugin.name) {
198        return invalid_manifest(
199            path,
200            "plugin.name must match [a-z0-9][a-z0-9._-]* and be <= 64 chars",
201        );
202    }
203
204    if manifest.plugin.version.trim().is_empty() {
205        return invalid_manifest(path, "plugin.version is required");
206    }
207    if manifest.plugin.description.trim().is_empty() || manifest.plugin.description.len() > 256 {
208        return invalid_manifest(path, "plugin.description must be 1..=256 characters");
209    }
210    if let Some(homepage) = manifest.plugin.homepage.as_deref() {
211        if !homepage.starts_with("https://") {
212            return invalid_manifest(path, "plugin.homepage must use https");
213        }
214    }
215
216    if let Some(trust) = manifest.trust.as_ref() {
217        if matches!(trust.level, TrustLevel::Official | TrustLevel::Trusted) {
218            let installer_stamped = trust
219                .source
220                .as_deref()
221                .is_some_and(|source| !source.trim().is_empty())
222                && manifest
223                    .integrity
224                    .as_ref()
225                    .is_some_and(|integrity| !integrity.checksum.trim().is_empty());
226            if !installer_stamped {
227                return invalid_manifest(
228                    path,
229                    "plugin.toml cannot self-assign official or trusted trust level",
230                );
231            }
232        }
233    }
234
235    for tool in &manifest.tools {
236        if tool.name.trim().is_empty() {
237            return invalid_manifest(path, "tool names must be non-empty");
238        }
239        if !name_re.is_match(&tool.name) {
240            return invalid_manifest(path, "tool names must match [a-z0-9][a-z0-9._-]*");
241        }
242    }
243
244    match manifest.plugin.kind {
245        PluginKind::Skill => {}
246        PluginKind::ToolServer => {
247            let Some(exec) = manifest.exec.as_ref() else {
248                return invalid_manifest(path, "tool-server plugins require [exec]");
249            };
250            if exec.command.trim().is_empty() {
251                return invalid_manifest(path, "exec.command is required");
252            }
253            if !(1..=60).contains(&exec.startup_timeout_secs) {
254                return invalid_manifest(path, "exec.startup_timeout_secs must be 1..=60");
255            }
256            if !(1..=300).contains(&exec.call_timeout_secs) {
257                return invalid_manifest(path, "exec.call_timeout_secs must be 1..=300");
258            }
259        }
260        PluginKind::Script => {
261            let Some(script) = manifest.script.as_ref() else {
262                return invalid_manifest(path, "script plugins require [script]");
263            };
264            if script.file.trim().is_empty() {
265                return invalid_manifest(path, "script.file is required");
266            }
267        }
268        PluginKind::Hermes => {
269            let Some(exec) = manifest.exec.as_ref() else {
270                return invalid_manifest(path, "Hermes compatibility plugins require [exec]");
271            };
272            if exec.command.trim().is_empty() {
273                return invalid_manifest(path, "exec.command is required");
274            }
275        }
276    }
277
278    if !matches!(manifest.plugin.kind, PluginKind::Skill | PluginKind::Hermes)
279        && manifest.tools.is_empty()
280    {
281        return invalid_manifest(
282            path,
283            "runtime plugins must declare at least one [[tools]] entry",
284        );
285    }
286
287    Ok(())
288}
289
290pub fn write_install_metadata(
291    path: &Path,
292    trust_level: TrustLevel,
293    source: &str,
294    checksum: &str,
295) -> Result<(), PluginError> {
296    let content = std::fs::read_to_string(path)?;
297    let mut value: Value = toml::from_str(&content)?;
298    let root = value
299        .as_table_mut()
300        .ok_or_else(|| PluginError::InvalidManifest {
301            path: path.to_path_buf(),
302            message: "plugin manifest must be a TOML table".into(),
303        })?;
304
305    let trust = root
306        .entry("trust")
307        .or_insert_with(|| Value::Table(Default::default()))
308        .as_table_mut()
309        .ok_or_else(|| PluginError::InvalidManifest {
310            path: path.to_path_buf(),
311            message: "[trust] must be a TOML table".into(),
312        })?;
313    trust.insert(
314        "level".into(),
315        Value::String(
316            match trust_level {
317                TrustLevel::Official => "official",
318                TrustLevel::Trusted => "trusted",
319                TrustLevel::Community => "community",
320                TrustLevel::AgentCreated => "agent-created",
321                TrustLevel::Unverified => "unverified",
322            }
323            .into(),
324        ),
325    );
326    trust.insert("source".into(), Value::String(source.to_string()));
327
328    let integrity = root
329        .entry("integrity")
330        .or_insert_with(|| Value::Table(Default::default()))
331        .as_table_mut()
332        .ok_or_else(|| PluginError::InvalidManifest {
333            path: path.to_path_buf(),
334            message: "[integrity] must be a TOML table".into(),
335        })?;
336    integrity.insert("checksum".into(), Value::String(checksum.to_string()));
337
338    let rendered =
339        toml::to_string_pretty(&value).map_err(|error| PluginError::InvalidManifest {
340            path: path.to_path_buf(),
341            message: error.to_string(),
342        })?;
343    std::fs::write(path, rendered)?;
344    Ok(())
345}
346
347pub fn write_bundle_install_metadata(
348    dir: &Path,
349    trust_level: TrustLevel,
350    source: &str,
351    checksum: &str,
352) -> Result<(), PluginError> {
353    let metadata = InstallMetadata {
354        trust_level,
355        source: source.to_string(),
356        checksum: checksum.to_string(),
357    };
358    let path = dir.join(INSTALL_METADATA_FILE);
359    std::fs::write(
360        path,
361        serde_json::to_vec_pretty(&metadata).map_err(PluginError::Json)?,
362    )?;
363    Ok(())
364}
365
366pub fn read_bundle_install_metadata(dir: &Path) -> Option<InstallMetadata> {
367    let path = dir.join(INSTALL_METADATA_FILE);
368    let content = std::fs::read(path).ok()?;
369    serde_json::from_slice(&content).ok()
370}
371
372fn synthesize_skill_manifest(skill: &SkillManifest) -> PluginManifest {
373    PluginManifest {
374        plugin: PluginMetadata {
375            name: skill.name.clone(),
376            version: skill.version.clone().unwrap_or_else(|| "0.1.0".into()),
377            description: synthesize_skill_description(skill),
378            kind: PluginKind::Skill,
379            author: skill.author.clone().unwrap_or_default(),
380            license: skill.license.clone().unwrap_or_default(),
381            homepage: None,
382            min_edgecrab_version: None,
383        },
384        exec: None,
385        script: None,
386        tools: Vec::new(),
387        capabilities: PluginCapabilities::default(),
388        trust: None,
389        integrity: None,
390    }
391}
392
393fn synthesize_skill_description(skill: &SkillManifest) -> String {
394    let fallback = format!("Skill plugin '{}'", skill.name);
395    let description = if skill.description.trim().is_empty() {
396        fallback.as_str()
397    } else {
398        skill.description.trim()
399    };
400    description.chars().take(256).collect()
401}
402
403fn write_plugin_manifest(path: &Path, manifest: &PluginManifest) -> Result<(), PluginError> {
404    let rendered =
405        toml::to_string_pretty(manifest).map_err(|error| PluginError::InvalidManifest {
406            path: path.to_path_buf(),
407            message: error.to_string(),
408        })?;
409    std::fs::write(path, rendered)?;
410    Ok(())
411}
412
413fn invalid_manifest<T>(path: &Path, message: &str) -> Result<T, PluginError> {
414    Err(PluginError::InvalidManifest {
415        path: path.to_path_buf(),
416        message: message.into(),
417    })
418}
419
420#[cfg(test)]
421mod tests {
422    use super::*;
423    use tempfile::TempDir;
424
425    #[test]
426    fn rejects_invalid_name() {
427        let err = parse_plugin_manifest_str(
428            Path::new("/tmp/plugin.toml"),
429            r#"
430[plugin]
431name = "Bad Name"
432version = "1.0.0"
433description = "Demo"
434kind = "skill"
435"#,
436        )
437        .expect_err("invalid name rejected");
438
439        assert!(err.to_string().contains("plugin.name"));
440    }
441
442    #[test]
443    fn rejects_self_assigned_trusted_level() {
444        let err = parse_plugin_manifest_str(
445            Path::new("/tmp/plugin.toml"),
446            r#"
447[plugin]
448name = "demo"
449version = "1.0.0"
450description = "Demo"
451kind = "skill"
452
453[trust]
454level = "trusted"
455"#,
456        )
457        .expect_err("trusted self-assignment rejected");
458
459        assert!(err.to_string().contains("trust level"));
460    }
461
462    #[test]
463    fn accepts_installer_stamped_trusted_level_with_source_and_checksum() {
464        let manifest = parse_plugin_manifest_str(
465            Path::new("/tmp/plugin.toml"),
466            r#"
467[plugin]
468name = "demo"
469version = "1.0.0"
470description = "Demo"
471kind = "skill"
472
473[trust]
474level = "trusted"
475source = "hub:official/demo"
476
477[integrity]
478checksum = "sha256:abc123"
479"#,
480        )
481        .expect("installer-stamped trust should parse");
482
483        assert_eq!(
484            manifest.trust.as_ref().map(|trust| trust.level),
485            Some(TrustLevel::Trusted)
486        );
487    }
488
489    #[test]
490    fn synthesizes_install_manifest_for_local_hermes_bundle() {
491        let temp = TempDir::new().expect("tempdir");
492        std::fs::write(
493            temp.path().join("plugin.yaml"),
494            r#"
495name: calculator
496version: "1.0.0"
497description: Calculator plugin
498provides_tools:
499  - calculate
500"#,
501        )
502        .expect("write manifest");
503        std::fs::write(
504            temp.path().join("__init__.py"),
505            "def register(ctx):\n    pass\n",
506        )
507        .expect("write init");
508
509        let manifest_path = ensure_installable_manifest(temp.path()).expect("manifest path");
510        let manifest = parse_plugin_manifest(&manifest_path).expect("parsed manifest");
511
512        assert_eq!(manifest.plugin.kind, PluginKind::Hermes);
513        assert_eq!(manifest.plugin.name, "calculator");
514    }
515
516    #[test]
517    fn synthesizes_install_manifest_for_local_skill_bundle() {
518        let temp = TempDir::new().expect("tempdir");
519        std::fs::write(
520            temp.path().join("SKILL.md"),
521            r#"---
522name: github-issues
523description: Manage GitHub issues with a long but valid description.
524version: 1.1.0
525---
526
527# GitHub Issues
528
529Body.
530"#,
531        )
532        .expect("write skill");
533
534        let manifest_path = ensure_installable_manifest(temp.path()).expect("manifest path");
535        let manifest = parse_plugin_manifest(&manifest_path).expect("parsed manifest");
536
537        assert_eq!(manifest.plugin.kind, PluginKind::Skill);
538        assert_eq!(manifest.plugin.name, "github-issues");
539        assert_eq!(manifest.plugin.version, "1.1.0");
540    }
541}