lean_ctx/core/addons/
install.rs1use super::manifest::AddonManifest;
11use super::policy::AddonsConfig;
12use super::store::{InstalledAddon, InstalledStore};
13use crate::core::config::Config;
14use crate::core::gateway::GatewayServer;
15
16pub struct InstallOutcome {
18 pub name: String,
19 pub gateway_server: String,
20 pub enabled_gateway: bool,
22}
23
24pub fn preflight(
30 manifest: &AddonManifest,
31 addons: &AddonsConfig,
32 force: bool,
33) -> Result<GatewayServer, String> {
34 manifest.validate()?;
35 let server = manifest.to_gateway_server();
36 server.resolve().map_err(|e| {
37 format!(
38 "addon `{}` has no runnable MCP endpoint: {e}",
39 manifest.addon.name
40 )
41 })?;
42
43 if let Some(reason) =
45 super::revocation::install_block(&manifest.addon.name, &manifest.addon.version)
46 {
47 return Err(format!(
48 "addon `{}` is revoked and cannot be installed: {reason}",
49 manifest.addon.name
50 ));
51 }
52
53 let findings = super::trust::assess(manifest);
56 super::policy::gate(manifest, addons, &findings)?;
57
58 enforce_capability_coherence(manifest, force)?;
63
64 Ok(server)
65}
66
67fn enforce_capability_coherence(manifest: &AddonManifest, force: bool) -> Result<(), String> {
70 if force {
71 return Ok(());
72 }
73 let report = super::audit::audit(manifest);
74 if report.capability_coherent {
75 return Ok(());
76 }
77 let detail = report
78 .findings
79 .iter()
80 .find(|f| f.code == "cap_net_underdeclared" || f.code == "cap_exec_underdeclared")
81 .map_or_else(
82 || "declared capabilities under-state what the wiring does".to_string(),
83 |f| f.message.clone(),
84 );
85 Err(format!(
86 "addon `{}` declares capabilities that under-state its wiring, so the OS sandbox would \
87 block it at runtime:\n {detail}\n Fix the [capabilities] block (e.g. network = \"full\", \
88 filesystem = \"read_write\" for an npx/npm server) or omit it to use `addons.sandbox`; \
89 re-run with --force to install anyway.",
90 manifest.addon.name
91 ))
92}
93
94pub fn install(
101 manifest: &AddonManifest,
102 source: &str,
103 force: bool,
104) -> Result<InstallOutcome, String> {
105 let cfg = Config::load();
106 let server = preflight(manifest, &cfg.addons, force)?;
107
108 let name = manifest.addon.name.clone();
109 let server_name = server.name.clone();
110 let mut enabled_gateway = false;
111
112 Config::update_global(|cfg| {
113 if !cfg.gateway.enabled {
114 cfg.gateway.enabled = true;
115 enabled_gateway = true;
116 }
117 cfg.gateway.servers.retain(|s| s.name != server_name);
118 cfg.gateway.servers.push(server.clone());
119 })
120 .map_err(|e| e.to_string())?;
121
122 let mut store = InstalledStore::load();
123 store.upsert(InstalledAddon {
124 name: name.clone(),
125 version: manifest.addon.version.clone(),
126 source: source.to_string(),
127 gateway_server: server_name.clone(),
128 granted_capabilities: manifest.capabilities.clone(),
129 content_hash: Some(super::integrity::wiring_hash(&server)),
130 install: manifest
134 .install
135 .is_declared()
136 .then(|| manifest.install.to_receipt()),
137 });
138 store.save()?;
139
140 crate::core::gateway::catalog::invalidate();
141
142 Ok(InstallOutcome {
143 name,
144 gateway_server: server_name,
145 enabled_gateway,
146 })
147}
148
149pub struct RemoveOutcome {
151 pub name: String,
152 pub gateway_server: String,
153 pub last_removed: bool,
155}
156
157pub fn remove(name: &str) -> Result<RemoveOutcome, String> {
159 let mut store = InstalledStore::load();
160 let entry = store
161 .get(name)
162 .cloned()
163 .ok_or_else(|| format!("addon `{name}` is not installed"))?;
164 let server_name = entry.gateway_server.clone();
165
166 Config::update_global(|cfg| {
167 cfg.gateway.servers.retain(|s| s.name != server_name);
168 })
169 .map_err(|e| e.to_string())?;
170
171 store.remove(name);
172 let last_removed = store.addons.is_empty();
173 store.save()?;
174
175 crate::core::gateway::catalog::invalidate();
176
177 Ok(RemoveOutcome {
178 name: name.to_string(),
179 gateway_server: server_name,
180 last_removed,
181 })
182}
183
184#[cfg(test)]
185mod tests {
186 use super::*;
187 use crate::core::data_dir::isolated_data_dir;
188
189 fn manifest(name: &str) -> AddonManifest {
190 AddonManifest::from_toml(&format!(
191 "[addon]\nname = \"{name}\"\nversion = \"0.1.0\"\n\
192 [mcp]\ntransport = \"stdio\"\ncommand = \"{name}-mcp\"\n"
193 ))
194 .expect("parse")
195 }
196
197 #[test]
198 fn install_then_remove_round_trip() {
199 let _iso = isolated_data_dir();
200
201 let out = install(&manifest("demo"), "registry", false).expect("install");
202 assert_eq!(out.gateway_server, "demo");
203 assert!(out.enabled_gateway, "gateway was off, install enables it");
204
205 let cfg = Config::load();
207 assert!(cfg.gateway.enabled);
208 assert!(cfg.gateway.servers.iter().any(|s| s.name == "demo"));
209
210 assert!(InstalledStore::load().get("demo").is_some());
212
213 let out2 = install(&manifest("demo"), "registry", false).expect("reinstall");
215 assert!(!out2.enabled_gateway, "already enabled");
216 let cfg = Config::load();
217 assert_eq!(
218 cfg.gateway
219 .servers
220 .iter()
221 .filter(|s| s.name == "demo")
222 .count(),
223 1
224 );
225
226 let rm = remove("demo").expect("remove");
228 assert!(rm.last_removed);
229 let cfg = Config::load();
230 assert!(!cfg.gateway.servers.iter().any(|s| s.name == "demo"));
231 assert!(InstalledStore::load().get("demo").is_none());
232 }
233
234 #[test]
235 fn remove_unknown_is_error() {
236 let _iso = isolated_data_dir();
237 assert!(remove("nope").is_err());
238 }
239
240 #[test]
241 fn under_declared_capabilities_block_install_unless_forced() {
242 let _iso = isolated_data_dir();
246 let incoherent = AddonManifest::from_toml(
247 "[addon]\nname = \"liar\"\nversion = \"0.1.0\"\n\
248 [mcp]\ntransport = \"stdio\"\ncommand = \"npx\"\nargs = [\"-y\", \"pkg@1.2.3\"]\n\
249 [capabilities]\nnetwork = \"none\"\n",
250 )
251 .expect("parse");
252
253 let Err(err) = install(&incoherent, "local", false) else {
254 panic!("under-declared capabilities must block the install");
255 };
256 assert!(err.contains("under-state"), "got: {err}");
257 assert!(
258 !Config::load()
259 .gateway
260 .servers
261 .iter()
262 .any(|s| s.name == "liar"),
263 "nothing is wired when the gate rejects"
264 );
265
266 assert!(
268 install(&incoherent, "local", true).is_ok(),
269 "force bypasses the coherence gate"
270 );
271 }
272
273 #[test]
274 fn listed_only_manifest_refuses_install() {
275 let _iso = isolated_data_dir();
276 let listed = AddonManifest::from_toml("[addon]\nname = \"listed\"\n").expect("parse");
277 assert!(install(&listed, "registry", false).is_err());
278 }
279
280 #[test]
281 fn revoked_addon_refuses_install() {
282 let _iso = isolated_data_dir();
283 let mut list = super::super::revocation::RevocationList::load();
284 list.revoke("demo", "kill-switch test", None);
285 list.save().expect("save");
286 let Err(err) = install(&manifest("demo"), "registry", false) else {
287 panic!("revoked addon must refuse to install");
288 };
289 assert!(err.contains("revoked"), "got: {err}");
290 assert!(
292 !Config::load()
293 .gateway
294 .servers
295 .iter()
296 .any(|s| s.name == "demo")
297 );
298 assert!(InstalledStore::load().get("demo").is_none());
299 }
300}