lean_ctx/core/addons/
install.rs1use super::manifest::AddonManifest;
11use super::store::{InstalledAddon, InstalledStore};
12use crate::core::config::Config;
13
14pub struct InstallOutcome {
16 pub name: String,
17 pub gateway_server: String,
18 pub enabled_gateway: bool,
20}
21
22pub fn install(manifest: &AddonManifest, source: &str) -> Result<InstallOutcome, String> {
28 manifest.validate()?;
29 let server = manifest.to_gateway_server();
30 server.resolve().map_err(|e| {
31 format!(
32 "addon `{}` has no runnable MCP endpoint: {e}",
33 manifest.addon.name
34 )
35 })?;
36
37 if let Some(reason) =
39 super::revocation::install_block(&manifest.addon.name, &manifest.addon.version)
40 {
41 return Err(format!(
42 "addon `{}` is revoked and cannot be installed: {reason}",
43 manifest.addon.name
44 ));
45 }
46
47 let cfg = Config::load();
50 let findings = super::trust::assess(manifest);
51 super::policy::gate(manifest, &cfg.addons, &findings)?;
52
53 let name = manifest.addon.name.clone();
54 let server_name = server.name.clone();
55 let mut enabled_gateway = false;
56
57 Config::update_global(|cfg| {
58 if !cfg.gateway.enabled {
59 cfg.gateway.enabled = true;
60 enabled_gateway = true;
61 }
62 cfg.gateway.servers.retain(|s| s.name != server_name);
63 cfg.gateway.servers.push(server.clone());
64 })
65 .map_err(|e| e.to_string())?;
66
67 let mut store = InstalledStore::load();
68 store.upsert(InstalledAddon {
69 name: name.clone(),
70 version: manifest.addon.version.clone(),
71 source: source.to_string(),
72 gateway_server: server_name.clone(),
73 granted_capabilities: manifest.capabilities.clone(),
74 content_hash: Some(super::integrity::wiring_hash(&server)),
75 });
76 store.save()?;
77
78 crate::core::gateway::catalog::invalidate();
79
80 Ok(InstallOutcome {
81 name,
82 gateway_server: server_name,
83 enabled_gateway,
84 })
85}
86
87pub struct RemoveOutcome {
89 pub name: String,
90 pub gateway_server: String,
91 pub last_removed: bool,
93}
94
95pub fn remove(name: &str) -> Result<RemoveOutcome, String> {
97 let mut store = InstalledStore::load();
98 let entry = store
99 .get(name)
100 .cloned()
101 .ok_or_else(|| format!("addon `{name}` is not installed"))?;
102 let server_name = entry.gateway_server.clone();
103
104 Config::update_global(|cfg| {
105 cfg.gateway.servers.retain(|s| s.name != server_name);
106 })
107 .map_err(|e| e.to_string())?;
108
109 store.remove(name);
110 let last_removed = store.addons.is_empty();
111 store.save()?;
112
113 crate::core::gateway::catalog::invalidate();
114
115 Ok(RemoveOutcome {
116 name: name.to_string(),
117 gateway_server: server_name,
118 last_removed,
119 })
120}
121
122#[cfg(test)]
123mod tests {
124 use super::*;
125 use crate::core::data_dir::isolated_data_dir;
126
127 fn manifest(name: &str) -> AddonManifest {
128 AddonManifest::from_toml(&format!(
129 "[addon]\nname = \"{name}\"\nversion = \"0.1.0\"\n\
130 [mcp]\ntransport = \"stdio\"\ncommand = \"{name}-mcp\"\n"
131 ))
132 .expect("parse")
133 }
134
135 #[test]
136 fn install_then_remove_round_trip() {
137 let _iso = isolated_data_dir();
138
139 let out = install(&manifest("demo"), "registry").expect("install");
140 assert_eq!(out.gateway_server, "demo");
141 assert!(out.enabled_gateway, "gateway was off, install enables it");
142
143 let cfg = Config::load();
145 assert!(cfg.gateway.enabled);
146 assert!(cfg.gateway.servers.iter().any(|s| s.name == "demo"));
147
148 assert!(InstalledStore::load().get("demo").is_some());
150
151 let out2 = install(&manifest("demo"), "registry").expect("reinstall");
153 assert!(!out2.enabled_gateway, "already enabled");
154 let cfg = Config::load();
155 assert_eq!(
156 cfg.gateway
157 .servers
158 .iter()
159 .filter(|s| s.name == "demo")
160 .count(),
161 1
162 );
163
164 let rm = remove("demo").expect("remove");
166 assert!(rm.last_removed);
167 let cfg = Config::load();
168 assert!(!cfg.gateway.servers.iter().any(|s| s.name == "demo"));
169 assert!(InstalledStore::load().get("demo").is_none());
170 }
171
172 #[test]
173 fn remove_unknown_is_error() {
174 let _iso = isolated_data_dir();
175 assert!(remove("nope").is_err());
176 }
177
178 #[test]
179 fn listed_only_manifest_refuses_install() {
180 let _iso = isolated_data_dir();
181 let listed = AddonManifest::from_toml("[addon]\nname = \"listed\"\n").expect("parse");
182 assert!(install(&listed, "registry").is_err());
183 }
184
185 #[test]
186 fn revoked_addon_refuses_install() {
187 let _iso = isolated_data_dir();
188 let mut list = super::super::revocation::RevocationList::load();
189 list.revoke("demo", "kill-switch test", None);
190 list.save().expect("save");
191 let Err(err) = install(&manifest("demo"), "registry") else {
192 panic!("revoked addon must refuse to install");
193 };
194 assert!(err.contains("revoked"), "got: {err}");
195 assert!(
197 !Config::load()
198 .gateway
199 .servers
200 .iter()
201 .any(|s| s.name == "demo")
202 );
203 assert!(InstalledStore::load().get("demo").is_none());
204 }
205}