- 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(); - 2575
package(linked.path()); - 2576
fs::hard_link( - 2577
linked.path().join("commands/brief.md"), - 2578
linked.path().join("commands/brief-copy.md"), - 2579
) - 2580
.unwrap(); - 2581
assert!(matches!( - 2582
inspect_package(linked.path()), - 2583
Err(PluginError::UnsafePackage(_)) - 2584
)); - 2585
} - 2586
- 2587
#[test] - 2588
fn enforces_file_and_size_limits() { - 2589
let temp = tempfile::tempdir().unwrap(); - 2590
package(temp.path()); - 2591
let error = inspect_package_with_limits( - 2592
temp.path(), - 2593
InspectLimits { - 2594
max_files: 1, - 2595
..InspectLimits::default() - 2596
}, - 2597
) - 2598
.unwrap_err(); - 2599
assert!(matches!(error, PluginError::LimitExceeded(_))); - 2600
} - 2601
- 2602
#[test] - 2603
fn installs_disabled_verifies_copy_and_removes() { - 2604
let source = tempfile::tempdir().unwrap(); - 2605
let home = tempfile::tempdir().unwrap(); - 2606
package(source.path()); - 2607
let store = PluginStore::new(home.path()); - 2608
let installed = store - 2609
.install_local(source.path(), InstallOptions::default()) - 2610
.unwrap(); - 2611
assert!(!installed.enabled); - 2612
assert!(installed.package_path.is_dir()); - 2613
assert_eq!( - 2614
inspect_package(&installed.package_path).unwrap().digest, - 2615
installed.digest - 2616
); - 2617
assert_eq!(store.list().unwrap().len(), 1); - 2618
let idempotent = store - 2619
.install_local(source.path(), InstallOptions::default()) - 2620
.unwrap(); - 2621
assert_eq!(installed.digest, idempotent.digest); - 2622
let removed = store.remove("daily-brief").unwrap(); - 2623
assert_eq!(removed.digest, installed.digest); - 2624
assert!(!installed.package_path.exists()); - 2625
assert!(store.list().unwrap().is_empty()); - 2626
let registry = store.load().unwrap(); - 2627
assert_eq!(registry.generation, 2); - 2628
assert_eq!(registry.audit.len(), 2); - 2629
assert_eq!(registry.audit[0].trace_id, installed.trace_id); - 2630
assert_eq!(registry.audit[1].trace_id, installed.trace_id); - 2631
} - 2632
- 2633
#[test] - 2634
fn refuses_unlicensed_install_without_explicit_override() { - 2635
let source = tempfile::tempdir().unwrap(); - 2636
let home = tempfile::tempdir().unwrap(); - 2637
write( - 2638
&source.path().join("SKILL.md"), - 2639
"---\nname: local-skill\ndescription: Local.\n---\nLocal.\n", - 2640
); - 2641
let store = PluginStore::new(home.path()); - 2642
assert!(matches!( - 2643
store.install_local(source.path(), InstallOptions::default()), - 2644
Err(PluginError::Unlicensed(_)) - 2645
)); - 2646
let installed = store - 2647
.install_local( - 2648
source.path(), - 2649
InstallOptions { - 2650
allow_unlicensed: true, - 2651
..InstallOptions::default() - 2652
}, - 2653
) - 2654
.unwrap(); - 2655
assert_eq!(installed.format, ManifestFormat::AgentSkill); - 2656
} - 2657
- 2658
#[test] - 2659
fn refuses_replacement_without_remove() { - 2660
let source = tempfile::tempdir().unwrap(); - 2661
let home = tempfile::tempdir().unwrap(); - 2662
package(source.path()); - 2663
let store = PluginStore::new(home.path()); - 2664
store - 2665
.install_local(source.path(), InstallOptions::default()) - 2666
.unwrap(); - 2667
write(&source.path().join("commands/brief.md"), "New content.\n"); - 2668
assert!(matches!( - 2669
store.install_local(source.path(), InstallOptions::default()), - 2670
Err(PluginError::AlreadyInstalled(_)) - 2671
)); - 2672
} - 2673
- 2674
#[test] - 2675
fn lifecycle_keeps_generations_and_audits_every_transition() { - 2676
let source = tempfile::tempdir().unwrap(); - 2677
let home = tempfile::tempdir().unwrap(); - 2678
package(source.path()); - 2679
let store = PluginStore::new(home.path()); - 2680
let first = store - 2681
.install_local(source.path(), InstallOptions::default()) - 2682
.unwrap(); - 2683
let enabled = store.enable("daily-brief").unwrap(); - 2684
assert!(enabled.enabled); - 2685
let disabled = store.disable("daily-brief").unwrap(); - 2686
assert!(!disabled.enabled); - 2687
write(&source.path().join("commands/brief.md"), "New content.\n"); - 2688
let second = store - 2689
.update_local(source.path(), InstallOptions::default()) - 2690
.unwrap(); - 2691
assert_ne!(first.digest, second.digest); - 2692
assert!(!second.enabled); - 2693
assert_eq!(store.versions("daily-brief").unwrap().len(), 2); - 2694
let rolled_back = store.rollback("daily-brief").unwrap(); - 2695
assert_eq!(rolled_back.digest, first.digest); - 2696
assert!(!rolled_back.enabled); - 2697
let actions: Vec<_> = store - 2698
.load() - 2699
.unwrap() - 2700
.audit - 2701
.into_iter() - 2702
.map(|event| event.action) - 2703
.collect(); - 2704
assert_eq!( - 2705
actions, - 2706
vec![ - 2707
PluginAuditAction::Installed, - 2708
PluginAuditAction::Enabled, - 2709
PluginAuditAction::Disabled, - 2710
PluginAuditAction::Updated, - 2711
PluginAuditAction::RolledBack, - 2712
] - 2713
); - 2714
} - 2715
- 2716
#[test] - 2717
fn catalog_sources_are_registered_disabled_with_snapshot_provenance() { - 2718
let catalog = tempfile::tempdir().unwrap(); - 2719
write( - 2720
&catalog.path().join("marketplace.json"), - 2721
r#"{"name":"team","plugins":[{"name":"tool","source":"./tool"}]}"#, - 2722
); - 2723
let home = tempfile::tempdir().unwrap(); - 2724
let store = PluginStore::new(home.path()); - 2725
let source = store - 2726
.register_catalog_source( - 2727
catalog.path(), - 2728
"Team catalog", - 2729
MarketplaceTrust::ManualReview, - 2730
) - 2731
.unwrap(); - 2732
assert!(!source.enabled); - 2733
assert_eq!(source.format, MarketplaceFormat::Copilot); - 2734
assert!(source.trace_id.starts_with("catalog:team:")); - 2735
assert_eq!(store.list_sources().unwrap(), vec![source.clone()]); - 2736
assert!(store.set_source_enabled(&source.id, true).unwrap().enabled); - 2737
assert!(store.load_sources().unwrap().sources[&source.id].enabled); - 2738
write( - 2739
&catalog.path().join("marketplace.json"), - 2740
r#"{"name":"team","plugins":[]}"#, - 2741
); - 2742
assert!(matches!(
Indexing the workspace…
Vakyartha documentation is discovering safe artifacts, anchors, and source references.