use super::manifest::AddonManifest;
use super::policy::AddonsConfig;
use super::store::{InstalledAddon, InstalledStore};
use crate::core::config::Config;
use crate::core::gateway::GatewayServer;
pub struct InstallOutcome {
pub name: String,
pub gateway_server: String,
pub enabled_gateway: bool,
}
pub fn preflight(
manifest: &AddonManifest,
addons: &AddonsConfig,
force: bool,
) -> Result<GatewayServer, String> {
manifest.validate()?;
let server = manifest.to_gateway_server();
server.resolve().map_err(|e| {
format!(
"addon `{}` has no runnable MCP endpoint: {e}",
manifest.addon.name
)
})?;
if let Some(reason) =
super::revocation::install_block(&manifest.addon.name, &manifest.addon.version)
{
return Err(format!(
"addon `{}` is revoked and cannot be installed: {reason}",
manifest.addon.name
));
}
let findings = super::trust::assess(manifest);
super::policy::gate(manifest, addons, &findings)?;
enforce_capability_coherence(manifest, force)?;
Ok(server)
}
fn enforce_capability_coherence(manifest: &AddonManifest, force: bool) -> Result<(), String> {
if force {
return Ok(());
}
let report = super::audit::audit(manifest);
if report.capability_coherent {
return Ok(());
}
let detail = report
.findings
.iter()
.find(|f| f.code == "cap_net_underdeclared" || f.code == "cap_exec_underdeclared")
.map_or_else(
|| "declared capabilities under-state what the wiring does".to_string(),
|f| f.message.clone(),
);
Err(format!(
"addon `{}` declares capabilities that under-state its wiring, so the OS sandbox would \
block it at runtime:\n {detail}\n Fix the [capabilities] block (e.g. network = \"full\", \
filesystem = \"read_write\" for an npx/npm server) or omit it to use `addons.sandbox`; \
re-run with --force to install anyway.",
manifest.addon.name
))
}
pub fn install(
manifest: &AddonManifest,
source: &str,
force: bool,
) -> Result<InstallOutcome, String> {
let cfg = Config::load();
let server = preflight(manifest, &cfg.addons, force)?;
let name = manifest.addon.name.clone();
let server_name = server.name.clone();
let mut enabled_gateway = false;
Config::update_global(|cfg| {
if !cfg.gateway.enabled {
cfg.gateway.enabled = true;
enabled_gateway = true;
}
cfg.gateway.servers.retain(|s| s.name != server_name);
cfg.gateway.servers.push(server.clone());
})
.map_err(|e| e.to_string())?;
let mut store = InstalledStore::load();
store.upsert(InstalledAddon {
name: name.clone(),
version: manifest.addon.version.clone(),
source: source.to_string(),
gateway_server: server_name.clone(),
granted_capabilities: manifest.capabilities.clone(),
content_hash: Some(super::integrity::wiring_hash(&server)),
install: manifest
.install
.is_declared()
.then(|| manifest.install.to_receipt()),
});
store.save()?;
crate::core::gateway::catalog::invalidate();
Ok(InstallOutcome {
name,
gateway_server: server_name,
enabled_gateway,
})
}
pub struct RemoveOutcome {
pub name: String,
pub gateway_server: String,
pub last_removed: bool,
}
pub fn remove(name: &str) -> Result<RemoveOutcome, String> {
let mut store = InstalledStore::load();
let entry = store
.get(name)
.cloned()
.ok_or_else(|| format!("addon `{name}` is not installed"))?;
let server_name = entry.gateway_server.clone();
Config::update_global(|cfg| {
cfg.gateway.servers.retain(|s| s.name != server_name);
})
.map_err(|e| e.to_string())?;
store.remove(name);
let last_removed = store.addons.is_empty();
store.save()?;
crate::core::gateway::catalog::invalidate();
Ok(RemoveOutcome {
name: name.to_string(),
gateway_server: server_name,
last_removed,
})
}
#[cfg(test)]
mod tests {
use super::*;
use crate::core::data_dir::isolated_data_dir;
fn manifest(name: &str) -> AddonManifest {
AddonManifest::from_toml(&format!(
"[addon]\nname = \"{name}\"\nversion = \"0.1.0\"\n\
[mcp]\ntransport = \"stdio\"\ncommand = \"{name}-mcp\"\n"
))
.expect("parse")
}
#[test]
fn install_then_remove_round_trip() {
let _iso = isolated_data_dir();
let out = install(&manifest("demo"), "registry", false).expect("install");
assert_eq!(out.gateway_server, "demo");
assert!(out.enabled_gateway, "gateway was off, install enables it");
let cfg = Config::load();
assert!(cfg.gateway.enabled);
assert!(cfg.gateway.servers.iter().any(|s| s.name == "demo"));
assert!(InstalledStore::load().get("demo").is_some());
let out2 = install(&manifest("demo"), "registry", false).expect("reinstall");
assert!(!out2.enabled_gateway, "already enabled");
let cfg = Config::load();
assert_eq!(
cfg.gateway
.servers
.iter()
.filter(|s| s.name == "demo")
.count(),
1
);
let rm = remove("demo").expect("remove");
assert!(rm.last_removed);
let cfg = Config::load();
assert!(!cfg.gateway.servers.iter().any(|s| s.name == "demo"));
assert!(InstalledStore::load().get("demo").is_none());
}
#[test]
fn remove_unknown_is_error() {
let _iso = isolated_data_dir();
assert!(remove("nope").is_err());
}
#[test]
fn under_declared_capabilities_block_install_unless_forced() {
let _iso = isolated_data_dir();
let incoherent = AddonManifest::from_toml(
"[addon]\nname = \"liar\"\nversion = \"0.1.0\"\n\
[mcp]\ntransport = \"stdio\"\ncommand = \"npx\"\nargs = [\"-y\", \"pkg@1.2.3\"]\n\
[capabilities]\nnetwork = \"none\"\n",
)
.expect("parse");
let Err(err) = install(&incoherent, "local", false) else {
panic!("under-declared capabilities must block the install");
};
assert!(err.contains("under-state"), "got: {err}");
assert!(
!Config::load()
.gateway
.servers
.iter()
.any(|s| s.name == "liar"),
"nothing is wired when the gate rejects"
);
assert!(
install(&incoherent, "local", true).is_ok(),
"force bypasses the coherence gate"
);
}
#[test]
fn listed_only_manifest_refuses_install() {
let _iso = isolated_data_dir();
let listed = AddonManifest::from_toml("[addon]\nname = \"listed\"\n").expect("parse");
assert!(install(&listed, "registry", false).is_err());
}
#[test]
fn revoked_addon_refuses_install() {
let _iso = isolated_data_dir();
let mut list = super::super::revocation::RevocationList::load();
list.revoke("demo", "kill-switch test", None);
list.save().expect("save");
let Err(err) = install(&manifest("demo"), "registry", false) else {
panic!("revoked addon must refuse to install");
};
assert!(err.contains("revoked"), "got: {err}");
assert!(
!Config::load()
.gateway
.servers
.iter()
.any(|s| s.name == "demo")
);
assert!(InstalledStore::load().get("demo").is_none());
}
}