- 1575
pub fn registry_path(&self) -> PathBuf { - 1576
self.home.join("plugins/registry.json") - 1577
} - 1578
- 1579
pub fn packages_root(&self) -> PathBuf { - 1580
self.home.join("plugins/packages") - 1581
} - 1582
- 1583
pub fn source_registry_path(&self) -> PathBuf { - 1584
self.home.join("plugins/sources.json") - 1585
} - 1586
- 1587
pub fn invocation_log_path(&self) -> PathBuf { - 1588
self.home.join("plugins/invocations.jsonl") - 1589
} - 1590
- 1591
pub fn record_invocation( - 1592
&self, - 1593
trace_id: &str, - 1594
plugin: &str, - 1595
capability: &str, - 1596
success: bool, - 1597
) -> Result<(), PluginError> { - 1598
let _lock = RegistryLock::acquire(&self.home.join("plugins"))?; - 1599
let path = self.invocation_log_path(); - 1600
if let Some(parent) = path.parent() { - 1601
fs::create_dir_all(parent).map_err(|error| io_error(parent, error))?; - 1602
} - 1603
let event = PluginInvocationEvent { - 1604
at_unix: now_unix(), - 1605
trace_id: trace_id.to_string(), - 1606
plugin: plugin.to_string(), - 1607
capability: capability.to_string(), - 1608
success, - 1609
}; - 1610
let bytes = serde_json::to_vec(&event).map_err(|source| PluginError::Json { - 1611
path: path.clone(), - 1612
source, - 1613
})?; - 1614
let mut file = OpenOptions::new() - 1615
.create(true) - 1616
.append(true) - 1617
.open(&path) - 1618
.map_err(|error| io_error(&path, error))?; - 1619
file.write_all(&bytes) - 1620
.map_err(|error| io_error(&path, error))?; - 1621
file.write_all(b"\n") - 1622
.map_err(|error| io_error(&path, error))?; - 1623
file.sync_data().map_err(|error| io_error(&path, error)) - 1624
} - 1625
- 1626
pub fn invocations(&self) -> Result<Vec<PluginInvocationEvent>, PluginError> { - 1627
let path = self.invocation_log_path(); - 1628
if !path.exists() { - 1629
return Ok(Vec::new()); - 1630
} - 1631
let text = fs::read_to_string(&path).map_err(|error| io_error(&path, error))?; - 1632
text.lines() - 1633
.filter(|line| !line.trim().is_empty()) - 1634
.map(|line| { - 1635
serde_json::from_str(line).map_err(|source| PluginError::Json { - 1636
path: path.clone(), - 1637
source, - 1638
}) - 1639
}) - 1640
.collect() - 1641
} - 1642
- 1643
pub fn load_sources(&self) -> Result<MarketplaceSourceRegistry, PluginError> { - 1644
let path = self.source_registry_path(); - 1645
if !path.exists() { - 1646
return Ok(MarketplaceSourceRegistry { - 1647
schema: SOURCE_REGISTRY_SCHEMA, - 1648
..MarketplaceSourceRegistry::default() - 1649
}); - 1650
} - 1651
let bytes = fs::read(&path).map_err(|error| io_error(&path, error))?; - 1652
let registry: MarketplaceSourceRegistry = - 1653
serde_json::from_slice(&bytes).map_err(|source| PluginError::Json { - 1654
path: path.clone(), - 1655
source, - 1656
})?; - 1657
if registry.schema > SOURCE_REGISTRY_SCHEMA { - 1658
return Err(PluginError::InvalidManifest(format!( - 1659
"marketplace source registry schema {} is newer than supported schema {SOURCE_REGISTRY_SCHEMA}", - 1660
registry.schema - 1661
))); - 1662
} - 1663
Ok(registry) - 1664
} - 1665
- 1666
pub fn register_catalog_source( - 1667
&self, - 1668
root: &Path, - 1669
label: &str, - 1670
trust: MarketplaceTrust, - 1671
) -> Result<MarketplaceSource, PluginError> { - 1672
self.register_catalog_source_with_signature(root, label, trust, None) - 1673
} - 1674
- 1675
pub fn register_catalog_source_with_signature( - 1676
&self, - 1677
root: &Path, - 1678
label: &str, - 1679
trust: MarketplaceTrust, - 1680
signature: Option<SignatureEvidence>, - 1681
) -> Result<MarketplaceSource, PluginError> { - 1682
let inspection = inspect_catalog(root)?; - 1683
let signature = match signature { - 1684
Some(mut evidence) => { - 1685
if evidence.revoked - 1686
|| evidence.algorithm != "ed25519" - 1687
|| verify_ed25519_signature( - 1688
inspection.digest.as_bytes(), - 1689
&evidence.public_key, - 1690
&evidence.signature, - 1691
) - 1692
.is_err() - 1693
{ - 1694
return Err(PluginError::UnsafePackage( - 1695
"catalog signature evidence is not valid or is revoked".into(), - 1696
)); - 1697
} - 1698
evidence.verified = true; - 1699
Some(evidence) - 1700
} - 1701
None => None, - 1702
}; - 1703
let canonical = fs::canonicalize(root).map_err(|error| io_error(root, error))?; - 1704
let id = format!("{}:{}", inspection.name, inspection.digest); - 1705
let source = MarketplaceSource { - 1706
id: id.clone(), - 1707
label: label.trim().to_string(), - 1708
root: canonical, - 1709
format: inspection.format, - 1710
catalog_digest: inspection.digest, - 1711
trace_id: inspection.trace_id, - 1712
trust, - 1713
enabled: false, - 1714
registered_at_unix: now_unix(), - 1715
signature, - 1716
}; - 1717
let _lock = RegistryLock::acquire(&self.home.join("plugins"))?; - 1718
let mut registry = self.load_sources()?; - 1719
registry.schema = SOURCE_REGISTRY_SCHEMA; - 1720
registry.sources.insert(id, source.clone()); - 1721
self.save_sources(®istry)?; - 1722
Ok(source) - 1723
} - 1724
- 1725
pub fn set_key_revoked(&self, key_id: &str, revoked: bool) -> Result<(), PluginError> { - 1726
if key_id.trim().is_empty() { - 1727
return Err(PluginError::InvalidManifest( - 1728
"key id must not be empty".into(), - 1729
)); - 1730
} - 1731
let _lock = RegistryLock::acquire(&self.home.join("plugins"))?; - 1732
let mut registry = self.load_sources()?; - 1733
if revoked { - 1734
registry.revoked_keys.insert(key_id.to_string()); - 1735
} else { - 1736
registry.revoked_keys.remove(key_id); - 1737
} - 1738
self.save_sources(®istry) - 1739
} - 1740
- 1741
pub fn list_sources(&self) -> Result<Vec<MarketplaceSource>, PluginError> { - 1742
Ok(self.load_sources()?.sources.into_values().collect()) - 1743
} - 1744
- 1745
pub fn set_source_enabled( - 1746
&self, - 1747
id: &str, - 1748
enabled: bool, - 1749
) -> Result<MarketplaceSource, PluginError> { - 1750
let _lock = RegistryLock::acquire(&self.home.join("plugins"))?; - 1751
let mut registry = self.load_sources()?; - 1752
let source = registry - 1753
.sources - 1754
.get_mut(id) - 1755
.ok_or_else(|| PluginError::NotInstalled(id.to_string()))?; - 1756
let current = inspect_catalog(&source.root)?; - 1757
if current.digest != source.catalog_digest { - 1758
return Err(PluginError::UnsafePackage(format!( - 1759
"marketplace catalog changed since registration: {}", - 1760
source.root.display() - 1761
))); - 1762
} - 1763
if let Some(signature) = &source.signature { - 1764
if signature.revoked || registry.revoked_keys.contains(&signature.key_id) { - 1765
return Err(PluginError::UnsafePackage(format!( - 1766
"marketplace source key is revoked: {}", - 1767
signature.key_id - 1768
))); - 1769
} - 1770
if !signature.verified { - 1771
return Err(PluginError::UnsafePackage( - 1772
"marketplace source signature is not verified".into(), - 1773
)); - 1774
} - 1775
} - 1776
source.enabled = enabled; - 1777
let source = source.clone(); - 1778
self.save_sources(®istry)?; - 1779
Ok(source) - 1780
} - 1781
- 1782
fn save_sources(&self, registry: &MarketplaceSourceRegistry) -> Result<(), PluginError> { - 1783
let path = self.source_registry_path(); - 1784
if let Some(parent) = path.parent() { - 1785
fs::create_dir_all(parent).map_err(|error| io_error(parent, error))?; - 1786
} - 1787
let bytes = serde_json::to_vec_pretty(registry).map_err(|source| PluginError::Json { - 1788
path: path.clone(), - 1789
source, - 1790
})?; - 1791
let temp = path.with_extension("json.tmp"); - 1792
let mut file = File::create(&temp).map_err(|error| io_error(&temp, error))?; - 1793
file.write_all(&bytes) - 1794
.map_err(|error| io_error(&temp, error))?; - 1795
file.sync_all().map_err(|error| io_error(&temp, error))?; - 1796
fs::rename(&temp, &path).map_err(|error| io_error(&path, error)) - 1797
} - 1798
- 1799
pub fn load(&self) -> Result<PluginRegistry, PluginError> { - 1800
let path = self.registry_path(); - 1801
if !path.exists() { - 1802
return Ok(PluginRegistry::default()); - 1803
} - 1804
let bytes = fs::read(&path).map_err(|error| io_error(&path, error))?; - 1805
let registry: PluginRegistry = - 1806
serde_json::from_slice(&bytes).map_err(|source| PluginError::Json { - 1807
path: path.clone(), - 1808
source, - 1809
})?; - 1810
if registry.schema > REGISTRY_SCHEMA { - 1811
return Err(PluginError::InvalidManifest(format!( - 1812
"registry schema {} is newer than supported schema {REGISTRY_SCHEMA}", - 1813
registry.schema - 1814
))); - 1815
} - 1816
Ok(registry) - 1817
} - 1818
- 1819
pub fn list(&self) -> Result<Vec<InstalledPlugin>, PluginError> { - 1820
Ok(self.load()?.plugins.into_values().collect()) - 1821
} - 1822
- 1823
pub fn enabled(&self) -> Result<Vec<InstalledPlugin>, PluginError> { - 1824
Ok(self - 1825
.load()? - 1826
.plugins - 1827
.into_values() - 1828
.filter(|plugin| plugin.enabled) - 1829
.collect()) - 1830
} - 1831
- 1832
/// Scan installed (enabled or disabled) plugins for references to retired - 1833
/// tool names in their skill descriptions or bodies. Returns each - 1834
/// `(plugin_name, retired_tool_name)` pair found, so callers can remove - 1835
/// the offending plugin and warn the operator. - 1836
/// - 1837
/// A plugin is flagged when any of its `SKILL.md` files — either the - 1838
/// `description` frontmatter field or the body text — contains a - 1839
/// backtick-quoted reference to a retired tool name (e.g. - 1840
/// `` `python_eval` ``). - 1841
pub fn retired_plugins(&self) -> Result<Vec<(String, Vec<String>)>, PluginError> { - 1842
let retired_names: std::collections::HashSet<&str> = - 1843
vak_tools::retired::retired_names().into_iter().collect(); - 1844
let mut flagged: Vec<(String, Vec<String>)> = Vec::new(); - 1845
for plugin in self.list()? { - 1846
let mut hits: Vec<String> = Vec::new(); - 1847
// Walk all SKILL.md files in the package directory tree. - 1848
let walker = walkdir::WalkDir::new(&plugin.package_path) - 1849
.into_iter() - 1850
.filter_map(|e| e.ok()) - 1851
.filter(|e| e.file_name() == "SKILL.md"); - 1852
for entry in walker { - 1853
let Ok(text) = std::fs::read_to_string(entry.path()) else { - 1854
continue; - 1855
}; - 1856
let lower = text.to_lowercase(); - 1857
for retired in &retired_names { - 1858
let pattern = format!("`{}`", retired.to_lowercase()); - 1859
if lower.contains(&pattern) && !hits.iter().any(|h: &String| h == *retired) { - 1860
hits.push((*retired).to_string()); - 1861
} - 1862
} - 1863
} - 1864
if !hits.is_empty() { - 1865
flagged.push((plugin.name, hits)); - 1866
} - 1867
} - 1868
Ok(flagged) - 1869
} - 1870
- 1871
pub fn enabled_hooks(&self) -> Result<Vec<(InstalledPlugin, PluginHook)>, PluginError> { - 1872
let mut hooks = Vec::new(); - 1873
for plugin in self.enabled()? { - 1874
for relative in &plugin.capabilities.hooks { - 1875
let path = plugin.package_path.join(relative); - 1876
let bytes = fs::read(&path).map_err(|error| io_error(&path, error))?; - 1877
let value: serde_json::Value = - 1878
serde_json::from_slice(&bytes).map_err(|source| PluginError::Json { - 1879
path: path.clone(), - 1880
source, - 1881
})?; - 1882
let entries = value.get("hooks").cloned().unwrap_or(value); - 1883
let entries = if let Some(array) = entries.as_array() { - 1884
array.clone() - 1885
} else { - 1886
vec![entries] - 1887
}; - 1888
for entry in entries { - 1889
let hook: PluginHook = serde_json::from_value(entry).map_err(|error| { - 1890
PluginError::InvalidManifest(format!( - 1891
"plugin '{}' hook {} is invalid: {error}", - 1892
plugin.name, relative - 1893
)) - 1894
})?; - 1895
if hook.command.trim().is_empty() { - 1896
return Err(PluginError::InvalidManifest(format!( - 1897
"plugin '{}' hook {} has an empty command", - 1898
plugin.name, relative - 1899
))); - 1900
} - 1901
hooks.push((plugin.clone(), hook)); - 1902
} - 1903
} - 1904
} - 1905
Ok(hooks) - 1906
} - 1907
- 1908
pub fn versions(&self, name: &str) -> Result<Vec<InstalledPlugin>, PluginError> { - 1909
let name = normalize_plugin_id(name) - 1910
.ok_or_else(|| PluginError::InvalidManifest("invalid plugin name".into()))?; - 1911
let registry = self.load()?; - 1912
if let Some(versions) = registry.versions.get(&name) { - 1913
return Ok(versions.clone()); - 1914
} - 1915
Ok(registry.plugins.get(&name).cloned().into_iter().collect()) - 1916
} - 1917
- 1918
pub fn install_local( - 1919
&self, - 1920
source: &Path, - 1921
options: InstallOptions, - 1922
) -> Result<InstalledPlugin, PluginError> { - 1923
let inspection = inspect_package(source)?; - 1924
if inspection.manifest.license.is_none() && !options.allow_unlicensed { - 1925
return Err(PluginError::Unlicensed(inspection.manifest.name)); - 1926
} - 1927
let _lock = RegistryLock::acquire(&self.home.join("plugins"))?; - 1928
let mut registry = self.load()?; - 1929
if let Some(existing) = registry.plugins.get(&inspection.manifest.name) { - 1930
if existing.digest == inspection.digest { - 1931
return Ok(existing.clone()); - 1932
} - 1933
return Err(PluginError::AlreadyInstalled( - 1934
inspection.manifest.name.clone(), - 1935
)); - 1936
} - 1937
let package_path = self.package_path(&inspection); - 1938
if package_path.exists() { - 1939
let existing = inspect_package(&package_path)?; - 1940
if existing.digest != inspection.digest { - 1941
return Err(PluginError::UnsafePackage(format!( - 1942
"immutable package destination has unexpected content: {}", - 1943
package_path.display() - 1944
))); - 1945
} - 1946
} else { - 1947
self.copy_verified(&inspection, &package_path)?; - 1948
} - 1949
let installed_at_unix = now_unix(); - 1950
let source = inspection.root.display().to_string(); - 1951
let trace_id = format!("install:{}:{}", inspection.manifest.name, inspection.digest); - 1952
let installed = InstalledPlugin { - 1953
name: inspection.manifest.name.clone(), - 1954
version: inspection.manifest.version.clone(), - 1955
digest: inspection.digest.clone(), - 1956
description: inspection.manifest.description, - 1957
license: inspection.manifest.license, - 1958
publisher: inspection.manifest.publisher, - 1959
format: inspection.format, - 1960
source: source.clone(), - 1961
trace_id: trace_id.clone(), - 1962
package_path, - 1963
scope: options.scope, - 1964
enabled: false, - 1965
installed_at_unix, - 1966
capabilities: inspection.capabilities, - 1967
warnings: inspection.warnings, - 1968
}; - 1969
registry - 1970
.plugins - 1971
.insert(installed.name.clone(), installed.clone()); - 1972
registry - 1973
.versions - 1974
.entry(installed.name.clone()) - 1975
.or_default() - 1976
.push(installed.clone()); - 1977
registry.generation = registry.generation.saturating_add(1); - 1978
registry.audit.push(PluginAuditEvent { - 1979
generation: registry.generation, - 1980
at_unix: installed_at_unix, - 1981
action: PluginAuditAction::Installed, - 1982
plugin: installed.name.clone(), - 1983
version: installed.version.clone(), - 1984
digest: installed.digest.clone(), - 1985
trace_id, - 1986
source, - 1987
}); - 1988
self.save(®istry)?; - 1989
Ok(installed) - 1990
} - 1991
- 1992
pub fn update_local( - 1993
&self, - 1994
source: &Path, - 1995
options: InstallOptions, - 1996
) -> Result<InstalledPlugin, PluginError> { - 1997
let inspection = inspect_package(source)?; - 1998
if inspection.manifest.license.is_none() && !options.allow_unlicensed { - 1999
return Err(PluginError::Unlicensed(inspection.manifest.name)); - 2000
} - 2001
let _lock = RegistryLock::acquire(&self.home.join("plugins"))?; - 2002
let mut registry = self.load()?; - 2003
let current = registry - 2004
.plugins - 2005
.get(&inspection.manifest.name) - 2006
.cloned() - 2007
.ok_or_else(|| PluginError::NotInstalled(inspection.manifest.name.clone()))?; - 2008
if current.digest == inspection.digest { - 2009
return Ok(current); - 2010
} - 2011
if registry - 2012
.versions - 2013
.get(&inspection.manifest.name) - 2014
.is_some_and(|items| items.iter().any(|item| item.digest == inspection.digest)) - 2015
{ - 2016
return Err(PluginError::AlreadyInstalled( - 2017
inspection.manifest.name.clone(), - 2018
)); - 2019
} - 2020
let package_path = self.package_path(&inspection); - 2021
if package_path.exists() { - 2022
let existing = inspect_package(&package_path)?; - 2023
if existing.digest != inspection.digest { - 2024
return Err(PluginError::UnsafePackage(format!( - 2025
"immutable package destination has unexpected content: {}", - 2026
package_path.display() - 2027
))); - 2028
} - 2029
} else { - 2030
self.copy_verified(&inspection, &package_path)?; - 2031
} - 2032
let installed_at_unix = now_unix(); - 2033
let source = inspection.root.display().to_string(); - 2034
let installed = InstalledPlugin { - 2035
name: inspection.manifest.name.clone(), - 2036
version: inspection.manifest.version.clone(), - 2037
digest: inspection.digest.clone(), - 2038
description: inspection.manifest.description, - 2039
license: inspection.manifest.license, - 2040
publisher: inspection.manifest.publisher, - 2041
format: inspection.format, - 2042
source: source.clone(), - 2043
trace_id: format!("update:{}:{}", inspection.manifest.name, inspection.digest), - 2044
package_path, - 2045
scope: options.scope, - 2046
enabled: false, - 2047
installed_at_unix, - 2048
capabilities: inspection.capabilities, - 2049
warnings: inspection.warnings, - 2050
}; - 2051
registry - 2052
.versions - 2053
.entry(installed.name.clone()) - 2054
.or_default() - 2055
.push(installed.clone()); - 2056
// Updating selects the new immutable generation, but it remains disabled - 2057
// until an operator explicitly enables the plugin. - 2058
registry - 2059
.plugins - 2060
.insert(installed.name.clone(), installed.clone()); - 2061
registry.generation = registry.generation.saturating_add(1); - 2062
registry.audit.push(PluginAuditEvent { - 2063
generation: registry.generation, - 2064
at_unix: installed_at_unix, - 2065
action: PluginAuditAction::Updated, - 2066
plugin: installed.name.clone(), - 2067
version: installed.version.clone(), - 2068
digest: installed.digest.clone(), - 2069
trace_id: installed.trace_id.clone(), - 2070
source, - 2071
}); - 2072
self.save(®istry)?; - 2073
Ok(installed) - 2074
} - 2075
- 2076
pub fn enable(&self, name: &str) -> Result<InstalledPlugin, PluginError> { - 2077
self.set_enabled(name, true) - 2078
} - 2079
- 2080
pub fn disable(&self, name: &str) -> Result<InstalledPlugin, PluginError> { - 2081
self.set_enabled(name, false) - 2082
} - 2083
- 2084
fn set_enabled(&self, name: &str, enabled: bool) -> Result<InstalledPlugin, PluginError> { - 2085
let name = normalize_plugin_id(name) - 2086
.ok_or_else(|| PluginError::InvalidManifest("invalid plugin name".into()))?; - 2087
let _lock = RegistryLock::acquire(&self.home.join("plugins"))?; - 2088
let mut registry = self.load()?; - 2089
let mut plugin = registry - 2090
.plugins - 2091
.get(&name) - 2092
.cloned() - 2093
.ok_or_else(|| PluginError::NotInstalled(name.clone()))?; - 2094
if plugin.enabled == enabled { - 2095
return Ok(plugin); - 2096
} - 2097
plugin.enabled = enabled; - 2098
registry.plugins.insert(name.clone(), plugin.clone()); - 2099
registry.generation = registry.generation.saturating_add(1); - 2100
registry.audit.push(PluginAuditEvent { - 2101
generation: registry.generation, - 2102
at_unix: now_unix(), - 2103
action: if enabled { - 2104
PluginAuditAction::Enabled - 2105
} else { - 2106
PluginAuditAction::Disabled - 2107
}, - 2108
plugin: plugin.name.clone(), - 2109
version: plugin.version.clone(), - 2110
digest: plugin.digest.clone(), - 2111
trace_id: plugin.trace_id.clone(), - 2112
source: plugin.source.clone(), - 2113
}); - 2114
self.save(®istry)?; - 2115
Ok(plugin) - 2116
} - 2117
- 2118
pub fn rollback(&self, name: &str) -> Result<InstalledPlugin, PluginError> { - 2119
let name = normalize_plugin_id(name) - 2120
.ok_or_else(|| PluginError::InvalidManifest("invalid plugin name".into()))?; - 2121
let _lock = RegistryLock::acquire(&self.home.join("plugins"))?; - 2122
let mut registry = self.load()?; - 2123
let current = registry - 2124
.plugins - 2125
.get(&name) - 2126
.cloned() - 2127
.ok_or_else(|| PluginError::NotInstalled(name.clone()))?; - 2128
let previous = registry - 2129
.versions - 2130
.get(&name) - 2131
.and_then(|items| { - 2132
items - 2133
.iter() - 2134
.rev() - 2135
.find(|item| item.digest != current.digest) - 2136
}) - 2137
.cloned() - 2138
.ok_or_else(|| { - 2139
PluginError::InvalidManifest(format!("plugin {name:?} has no previous generation")) - 2140
})?; - 2141
let mut previous = previous; - 2142
previous.enabled = false; - 2143
registry.plugins.insert(name.clone(), previous.clone()); - 2144
registry.generation = registry.generation.saturating_add(1); - 2145
registry.audit.push(PluginAuditEvent { - 2146
generation: registry.generation, - 2147
at_unix: now_unix(), - 2148
action: PluginAuditAction::RolledBack, - 2149
plugin: previous.name.clone(), - 2150
version: previous.version.clone(), - 2151
digest: previous.digest.clone(), - 2152
trace_id: previous.trace_id.clone(), - 2153
source: previous.source.clone(), - 2154
}); - 2155
self.save(®istry)?; - 2156
Ok(previous) - 2157
} - 2158
- 2159
pub fn remove(&self, name: &str) -> Result<InstalledPlugin, PluginError> { - 2160
let name = normalize_plugin_id(name) - 2161
.ok_or_else(|| PluginError::InvalidManifest("invalid plugin name".into()))?; - 2162
let _lock = RegistryLock::acquire(&self.home.join("plugins"))?; - 2163
let mut registry = self.load()?; - 2164
let installed = registry - 2165
.plugins - 2166
.remove(&name) - 2167
.ok_or_else(|| PluginError::NotInstalled(name.clone()))?; - 2168
let packages_root = fs::canonicalize(self.packages_root()) - 2169
.map_err(|error| io_error(self.packages_root(), error))?; - 2170
let package_paths = registry - 2171
.versions - 2172
.remove(&name) - 2173
.unwrap_or_else(|| vec![installed.clone()]) - 2174
.into_iter() - 2175
.map(|item| item.package_path) - 2176
.collect::<Vec<_>>(); - 2177
for package_path in &package_paths { - 2178
let package_path = - 2179
fs::canonicalize(package_path).map_err(|error| io_error(package_path, error))?; - 2180
if !package_path.starts_with(&packages_root) || package_path == packages_root { - 2181
return Err(PluginError::UnsafePackage(format!( - 2182
"registry package path is outside the managed store: {}", - 2183
package_path.display() - 2184
))); - 2185
} - 2186
} - 2187
registry.generation = registry.generation.saturating_add(1); - 2188
registry.audit.push(PluginAuditEvent { - 2189
generation: registry.generation, - 2190
at_unix: now_unix(), - 2191
action: PluginAuditAction::Removed, - 2192
plugin: installed.name.clone(), - 2193
version: installed.version.clone(), - 2194
digest: installed.digest.clone(), - 2195
trace_id: installed.trace_id.clone(), - 2196
source: installed.source.clone(), - 2197
}); - 2198
self.save(®istry)?; - 2199
for package_path in package_paths { - 2200
let package_path = - 2201
fs::canonicalize(&package_path).map_err(|error| io_error(&package_path, error))?; - 2202
if package_path.exists() { - 2203
fs::remove_dir_all(&package_path) - 2204
.map_err(|error| io_error(&package_path, error))?; - 2205
prune_empty_parents(&package_path, &packages_root)?; - 2206
} - 2207
} - 2208
Ok(installed) - 2209
} - 2210
- 2211
fn package_path(&self, inspection: &PackageInspection) -> PathBuf { - 2212
self.packages_root() - 2213
.join(&inspection.manifest.name) - 2214
.join(&inspection.manifest.version) - 2215
.join(&inspection.digest) - 2216
} - 2217
- 2218
fn copy_verified( - 2219
&self, - 2220
inspection: &PackageInspection, - 2221
destination: &Path, - 2222
) -> Result<(), PluginError> { - 2223
let parent = destination.parent().ok_or_else(|| { - 2224
PluginError::UnsafePackage("package destination has no parent".into()) - 2225
})?; - 2226
fs::create_dir_all(parent).map_err(|error| io_error(parent, error))?; - 2227
let staging = tempfile::Builder::new() - 2228
.prefix(".plugin-stage-") - 2229
.tempdir_in(parent) - 2230
.map_err(|error| io_error(parent, error))?; - 2231
for file in &inspection.files { - 2232
let relative = path_from_portable(&file.path); - 2233
let source = inspection.root.join(&relative); - 2234
let source_meta = - 2235
fs::symlink_metadata(&source).map_err(|error| io_error(&source, error))?; - 2236
if !source_meta.is_file() || source_meta.file_type().is_symlink() { - 2237
return Err(PluginError::UnsafePackage(format!( - 2238
"source changed during install: {}", - 2239
source.display() - 2240
))); - 2241
} - 2242
let canonical_source = - 2243
fs::canonicalize(&source).map_err(|error| io_error(&source, error))?; - 2244
if !canonical_source.starts_with(&inspection.root) { - 2245
return Err(PluginError::UnsafePackage(format!( - 2246
"source escaped package during install: {}", - 2247
source.display() - 2248
))); - 2249
} - 2250
let target = staging.path().join(&relative); - 2251
if let Some(directory) = target.parent() { - 2252
fs::create_dir_all(directory).map_err(|error| io_error(directory, error))?; - 2253
} - 2254
fs::copy(&source, &target).map_err(|error| io_error(&target, error))?; - 2255
} - 2256
let staged_inspection = inspect_package(staging.path())?; - 2257
if staged_inspection.digest != inspection.digest { - 2258
return Err(PluginError::UnsafePackage( - 2259
"package changed while it was being installed".into(), - 2260
)); - 2261
} - 2262
let staged_path = staging.keep(); - 2263
fs::rename(&staged_path, destination).map_err(|error| io_error(destination, error))?; - 2264
Ok(()) - 2265
} - 2266
- 2267
fn save(&self, registry: &PluginRegistry) -> Result<(), PluginError> { - 2268
let path = self.registry_path(); - 2269
let parent = path - 2270
.parent() - 2271
.ok_or_else(|| PluginError::UnsafePackage("registry path has no parent".into()))?; - 2272
fs::create_dir_all(parent).map_err(|error| io_error(parent, error))?; - 2273
let bytes = serde_json::to_vec_pretty(registry).map_err(|source| PluginError::Json { - 2274
path: path.clone(), - 2275
source, - 2276
})?; - 2277
let mut staged = - 2278
tempfile::NamedTempFile::new_in(parent).map_err(|error| io_error(parent, error))?; - 2279
staged - 2280
.write_all(&bytes) - 2281
.map_err(|error| io_error(staged.path(), error))?; - 2282
staged - 2283
.as_file() - 2284
.sync_all() - 2285
.map_err(|error| io_error(staged.path(), error))?; - 2286
staged - 2287
.persist(&path) - 2288
.map_err(|error| io_error(&path, error.error))?; - 2289
Ok(()) - 2290
} - 2291
} - 2292
- 2293
fn prune_empty_parents(path: &Path, stop: &Path) -> Result<(), PluginError> { - 2294
let mut current = path.parent(); - 2295
while let Some(directory) = current { - 2296
if directory == stop { - 2297
break; - 2298
} - 2299
match fs::remove_dir(directory) { - 2300
Ok(()) => current = directory.parent(), - 2301
Err(error) if error.kind() == std::io::ErrorKind::DirectoryNotEmpty => break, - 2302
Err(error) => return Err(io_error(directory, error)), - 2303
} - 2304
} - 2305
Ok(()) - 2306
} - 2307
- 2308
fn now_unix() -> u64 { - 2309
SystemTime::now() - 2310
.duration_since(UNIX_EPOCH) - 2311
.unwrap_or_default() - 2312
.as_secs() - 2313
} - 2314
- 2315
struct RegistryLock { - 2316
path: PathBuf, - 2317
} - 2318
- 2319
impl RegistryLock { - 2320
fn acquire(root: &Path) -> Result<Self, PluginError> { - 2321
fs::create_dir_all(root).map_err(|error| io_error(root, error))?; - 2322
let path = root.join(".registry.lock"); - 2323
match create_lock(&path) { - 2324
Ok(()) => Ok(Self { path }), - 2325
Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => { - 2326
let stale = fs::metadata(&path) - 2327
.and_then(|metadata| metadata.modified()) - 2328
.ok() - 2329
.and_then(|modified| SystemTime::now().duration_since(modified).ok()) - 2330
.is_some_and(|age| age > Duration::from_secs(120)); - 2331
if !stale { - 2332
return Err(PluginError::RegistryBusy); - 2333
} - 2334
fs::remove_file(&path).map_err(|remove_error| io_error(&path, remove_error))?; - 2335
create_lock(&path).map_err(|retry_error| io_error(&path, retry_error))?; - 2336
Ok(Self { path }) - 2337
} - 2338
Err(error) => Err(io_error(&path, error)), - 2339
} - 2340
} - 2341
} - 2342
- 2343
impl Drop for RegistryLock { - 2344
fn drop(&mut self) { - 2345
let _ = fs::remove_file(&self.path); - 2346
} - 2347
} - 2348
- 2349
fn create_lock(path: &Path) -> std::io::Result<()> { - 2350
let mut lock = OpenOptions::new().write(true).create_new(true).open(path)?; - 2351
writeln!(lock, "{} {}", std::process::id(), now_unix())?; - 2352
lock.sync_all() - 2353
} - 2354
- 2355
#[cfg(test)] - 2356
mod tests { - 2357
#![allow(clippy::expect_used, clippy::panic, clippy::unwrap_used)] - 2358
- 2359
use super::*; - 2360
- 2361
fn write(path: &Path, text: &str) { - 2362
if let Some(parent) = path.parent() { - 2363
fs::create_dir_all(parent).unwrap(); - 2364
} - 2365
fs::write(path, text).unwrap(); - 2366
} - 2367
- 2368
fn package(root: &Path) { - 2369
write( - 2370
&root.join("vak-plugin.json"), - 2371
r#"{ - 2372
"schema": 1, - 2373
"name": "daily-brief", - 2374
"version": "1.2.3", - 2375
"description": "Prepare a daily brief.", - 2376
"license": "MIT", - 2377
"publisher": {"id":"vak-labs","name":"Vak Labs"}, - 2378
"components": {"skills":["skills"],"commands":["commands"]} - 2379
}"#, - 2380
); - 2381
write( - 2382
&root.join("skills/daily-brief/SKILL.md"), - 2383
"---\nname: daily-brief\ndescription: Prepare the brief.\n---\n\nDo it.\n", - 2384
); - 2385
write( - 2386
&root.join("skills/daily-brief/scripts/collect.sh"), - 2387
"#!/bin/sh\nprintf brief\n", - 2388
); - 2389
write(&root.join("commands/brief.md"), "Prepare a brief.\n"); - 2390
} - 2391
- 2392
#[test] - 2393
fn inspects_native_package_and_digest_is_deterministic() { - 2394
let temp = tempfile::tempdir().unwrap(); - 2395
package(temp.path()); - 2396
let first = inspect_package(temp.path()).unwrap(); - 2397
let second = inspect_package(temp.path()).unwrap(); - 2398
assert_eq!(first.digest, second.digest); - 2399
assert_eq!(first.manifest.name, "daily-brief"); - 2400
assert_eq!(first.capabilities.skills, ["daily-brief"]); - 2401
assert_eq!(first.capabilities.commands, ["commands/brief.md"]); - 2402
assert_eq!( - 2403
first.capabilities.scripts, - 2404
["skills/daily-brief/scripts/collect.sh"] - 2405
); - 2406
} - 2407
- 2408
#[test] - 2409
fn changing_content_changes_digest() { - 2410
let temp = tempfile::tempdir().unwrap(); - 2411
package(temp.path()); - 2412
let first = inspect_package(temp.path()).unwrap(); - 2413
write(&temp.path().join("commands/brief.md"), "Changed.\n"); - 2414
let second = inspect_package(temp.path()).unwrap(); - 2415
assert_ne!(first.digest, second.digest); - 2416
} - 2417
- 2418
#[test] - 2419
fn normalizes_codex_manifest_and_conventional_components() { - 2420
let temp = tempfile::tempdir().unwrap(); - 2421
write( - 2422
&temp.path().join(".codex-plugin/plugin.json"), - 2423
r#"{"name":"codex-compatible","version":"2.0.0","description":"Compatible","license":"Apache-2.0","skills":"./skills"}"#, - 2424
); - 2425
write( - 2426
&temp.path().join("skills/hello/SKILL.md"), - 2427
"---\nname: hello\ndescription: Hello.\n---\nHello.\n", - 2428
); - 2429
write(&temp.path().join(".mcp.json"), "{}\n"); - 2430
let inspected = inspect_package(temp.path()).unwrap(); - 2431
assert_eq!(inspected.format, ManifestFormat::Codex); - 2432
assert_eq!(inspected.capabilities.skills, ["hello"]); - 2433
assert_eq!(inspected.capabilities.mcp_manifests, [".mcp.json"]); - 2434
} - 2435
- 2436
#[test] - 2437
fn imports_agent_plugins_one_with_fixed_locations_and_dotted_name() { - 2438
let temp = tempfile::tempdir().unwrap(); - 2439
write( - 2440
&temp.path().join("plugin.json"), - 2441
r#"{"$schema":"https://agent-plugins.org/schemas/1.0.0/plugin.schema.json","name":"acme.tools","version":"release-7","license":"MIT","skills":"ignored-by-fixed-discovery"}"#, - 2442
); - 2443
write( - 2444
&temp.path().join("skills/review/SKILL.md"), - 2445
"---\nname: review\ndescription: Review code.\n---\nReview.\n", - 2446
); - 2447
write( - 2448
&temp.path().join("mcp.json"), - 2449
r#"{"$schema":"https://agent-plugins.org/schemas/1.0.0/mcp.schema.json","mcpServers":{}}"#, - 2450
); - 2451
let inspected = inspect_package(temp.path()).unwrap(); - 2452
assert_eq!(inspected.format, ManifestFormat::AgentPlugin); - 2453
assert_eq!(inspected.manifest.name, "acme.tools"); - 2454
assert!(inspected.manifest.version.starts_with("0.0.0+source.")); - 2455
assert_eq!(inspected.capabilities.skills, ["review"]); - 2456
assert_eq!(inspected.capabilities.mcp_manifests, ["mcp.json"]); - 2457
} - 2458
- 2459
#[test] - 2460
fn imports_claude_and_cursor_client_components() { - 2461
for (manifest, format) in [ - 2462
(".claude-plugin/plugin.json", ManifestFormat::Claude), - 2463
(".cursor-plugin/plugin.json", ManifestFormat::Cursor), - 2464
] { - 2465
let temp = tempfile::tempdir().unwrap(); - 2466
write( - 2467
&temp.path().join(manifest), - 2468
r#"{"name":"client-pack","version":"1.0.0","license":"MIT","hooks":{"PreToolUse":[]},"mcpServers":{"tools":{"command":"tool"}}}"#, - 2469
); - 2470
write( - 2471
&temp.path().join("agents/reviewer.md"), - 2472
"Review carefully.\n", - 2473
); - 2474
let inspected = inspect_package(temp.path()).unwrap(); - 2475
assert_eq!(inspected.format, format); - 2476
assert_eq!(inspected.capabilities.hooks, [manifest]); - 2477
assert_eq!(inspected.capabilities.mcp_manifests, [manifest]); - 2478
assert_eq!(inspected.capabilities.agents, ["agents/reviewer.md"]); - 2479
} - 2480
} - 2481
- 2482
#[test] - 2483
fn imports_gemini_extension_without_granting_policy() { - 2484
let temp = tempfile::tempdir().unwrap(); - 2485
write( - 2486
&temp.path().join("gemini-extension.json"), - 2487
r#"{"name":"gemini-pack","version":"1.0.0","license":"MIT","mcpServers":{"tools":{"command":"tool"}},"themes":[{"name":"dark"}]}"#, - 2488
); - 2489
write( - 2490
&temp.path().join("policies/restrict.toml"), - 2491
"decision = \"ask_user\"\n", - 2492
); - 2493
let inspected = inspect_package(temp.path()).unwrap(); - 2494
assert_eq!(inspected.format, ManifestFormat::Gemini); - 2495
assert_eq!( - 2496
inspected.capabilities.mcp_manifests, - 2497
["gemini-extension.json"] - 2498
); - 2499
assert_eq!(inspected.capabilities.themes, ["gemini-extension.json"]); - 2500
assert_eq!(inspected.capabilities.policies, ["policies/restrict.toml"]); - 2501
} - 2502
- 2503
#[test] - 2504
fn inspects_compatible_catalog_with_snapshot_trace() { - 2505
let temp = tempfile::tempdir().unwrap(); - 2506
write( - 2507
&temp.path().join(".claude-plugin/marketplace.json"), - 2508
r#"{ - 2509
"name":"team-catalog", - 2510
"plugins":[ - 2511
{"name":"local-tools","version":"1.0.0","license":"MIT","source":"./plugins/local-tools"}, - 2512
{"name":"remote-tools","source":{"source":"github","repo":"acme/tools","sha":"0123456789abcdef0123456789abcdef01234567"}} - 2513
] - 2514
}"#, - 2515
); - 2516
let first = inspect_catalog(temp.path()).unwrap(); - 2517
let second = inspect_catalog(temp.path()).unwrap(); - 2518
assert_eq!(first.format, MarketplaceFormat::Claude); - 2519
assert_eq!(first.entries.len(), 2); - 2520
assert_eq!(first.digest, second.digest); - 2521
assert_eq!( - 2522
first.trace_id, - 2523
format!("catalog:team-catalog:{}", first.digest) - 2524
); - 2525
assert_eq!(first.warnings.len(), 1); - 2526
} - 2527
- 2528
#[test] - 2529
fn catalog_rejects_duplicates_and_bad_pins() { - 2530
let duplicate = tempfile::tempdir().unwrap(); - 2531
write( - 2532
&duplicate.path().join("marketplace.json"), - 2533
r#"{"name":"bad","plugins":[{"name":"same","source":"./a"},{"name":"same","source":"./b"}]}"#, - 2534
); - 2535
assert!(matches!( - 2536
inspect_catalog(duplicate.path()), - 2537
Err(PluginError::InvalidManifest(_)) - 2538
)); - 2539
- 2540
let bad_pin = tempfile::tempdir().unwrap(); - 2541
write( - 2542
&bad_pin.path().join(".cursor-plugin/marketplace.json"), - 2543
r#"{"name":"bad-pin","plugins":[{"name":"tool","source":{"repo":"a/b","sha":"short"}}]}"#, - 2544
); - 2545
assert!(matches!( - 2546
inspect_catalog(bad_pin.path()), - 2547
Err(PluginError::InvalidManifest(_)) - 2548
)); - 2549
- 2550
let escaping = tempfile::tempdir().unwrap(); - 2551
write( - 2552
&escaping.path().join("marketplace.json"), - 2553
r#"{"name":"escape","plugins":[{"name":"tool","source":"../outside"}]}"#, - 2554
); - 2555
assert!(matches!( - 2556
inspect_catalog(escaping.path()), - 2557
Err(PluginError::UnsafePackage(_)) - 2558
)); - 2559
} - 2560
- 2561
#[cfg(unix)] - 2562
#[test] - 2563
fn rejects_symlinks_and_hard_links() { - 2564
use std::os::unix::fs::symlink; - 2565
- 2566
let symlinked = tempfile::tempdir().unwrap(); - 2567
package(symlinked.path()); - 2568
symlink("commands/brief.md", symlinked.path().join("alias.md")).unwrap(); - 2569
assert!(matches!( - 2570
inspect_package(symlinked.path()), - 2571
Err(PluginError::UnsafePackage(_)) - 2572
)); - 2573
- 2574
let linked = tempfile::tempdir().unwrap();
Indexing the workspace…
Vakyartha documentation is discovering safe artifacts, anchors, and source references.