1use super::manifest::AddonManifest;
11use super::policy::AddonsConfig;
12use super::store::{ArtifactReceipt, InstalledAddon, InstalledStore};
13use crate::core::config::Config;
14use crate::core::mcp_catalog::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
36 let min_version = manifest.addon.min_lean_ctx.trim();
43 if !min_version.is_empty() {
44 let current = env!("CARGO_PKG_VERSION");
45 if crate::core::context_package::loader::version_lt(current, min_version) {
46 return Err(format!(
47 "addon `{}` requires lean-ctx >= {min_version}, but this binary is {current} — \
48 upgrade lean-ctx and retry.",
49 manifest.addon.name
50 ));
51 }
52 }
53
54 let server = manifest.to_gateway_server();
55 server.resolve().map_err(|e| {
56 format!(
57 "addon `{}` has no runnable MCP endpoint: {e}",
58 manifest.addon.name
59 )
60 })?;
61
62 if let Some(reason) =
64 super::revocation::install_block(&manifest.addon.name, &manifest.addon.version)
65 {
66 return Err(format!(
67 "addon `{}` is revoked and cannot be installed: {reason}",
68 manifest.addon.name
69 ));
70 }
71
72 let findings = super::trust::assess(manifest);
75 super::policy::gate(manifest, addons, &findings)?;
76
77 enforce_capability_coherence(manifest, force)?;
82
83 Ok(server)
84}
85
86fn enforce_capability_coherence(manifest: &AddonManifest, force: bool) -> Result<(), String> {
89 if force {
90 return Ok(());
91 }
92 let report = super::audit::audit(manifest);
93 if report.capability_coherent {
94 return Ok(());
95 }
96 let detail = report
97 .findings
98 .iter()
99 .find(|f| f.code == "cap_net_underdeclared" || f.code == "cap_exec_underdeclared")
100 .map_or_else(
101 || "declared capabilities under-state what the wiring does".to_string(),
102 |f| f.message.clone(),
103 );
104 Err(format!(
105 "addon `{}` declares capabilities that under-state its wiring, so the OS sandbox would \
106 block it at runtime:\n {detail}\n Fix the [capabilities] block (e.g. network = \"full\", \
107 filesystem = \"read_write\" for an npx/npm server) or omit it to use `addons.sandbox`; \
108 re-run with --force to install anyway.",
109 manifest.addon.name
110 ))
111}
112
113pub fn install(
123 manifest: &AddonManifest,
124 source: &str,
125 force: bool,
126 artifact: Option<ArtifactReceipt>,
127) -> Result<InstallOutcome, String> {
128 let cfg = Config::load();
129 let server = preflight(manifest, &cfg.addons, force)?;
130
131 let name = manifest.addon.name.clone();
132 let server_name = server.name.clone();
133 let mut enabled_gateway = false;
134
135 Config::update_global(|cfg| {
136 if !cfg.gateway.enabled {
137 cfg.gateway.enabled = true;
138 enabled_gateway = true;
139 }
140 cfg.gateway.servers.retain(|s| s.name != server_name);
141 cfg.gateway.servers.push(server.clone());
142 })
143 .map_err(|e| e.to_string())?;
144
145 let mut store = InstalledStore::load();
146 store.upsert(InstalledAddon {
147 name: name.clone(),
148 version: manifest.addon.version.clone(),
149 source: source.to_string(),
150 gateway_server: server_name.clone(),
151 granted_capabilities: manifest.capabilities.clone(),
152 content_hash: Some(super::integrity::wiring_hash(&server)),
153 install: manifest
157 .install
158 .is_declared()
159 .then(|| manifest.install.to_receipt()),
160 artifact,
161 });
162 store.save()?;
163
164 crate::core::mcp_catalog::catalog::invalidate();
165
166 Ok(InstallOutcome {
167 name,
168 gateway_server: server_name,
169 enabled_gateway,
170 })
171}
172
173pub struct RemoveOutcome {
175 pub name: String,
176 pub gateway_server: String,
177 pub last_removed: bool,
179}
180
181pub fn remove(name: &str) -> Result<RemoveOutcome, String> {
183 let mut store = InstalledStore::load();
184 let entry = store
185 .get(name)
186 .cloned()
187 .ok_or_else(|| format!("addon `{name}` is not installed"))?;
188 let server_name = entry.gateway_server.clone();
189
190 Config::update_global(|cfg| {
191 cfg.gateway.servers.retain(|s| s.name != server_name);
192 })
193 .map_err(|e| e.to_string())?;
194
195 store.remove(name);
196 let last_removed = store.addons.is_empty();
197 store.save()?;
198
199 crate::core::mcp_catalog::catalog::invalidate();
200
201 Ok(RemoveOutcome {
202 name: name.to_string(),
203 gateway_server: server_name,
204 last_removed,
205 })
206}
207
208#[cfg(test)]
209mod tests {
210 use super::*;
211 use crate::core::data_dir::isolated_data_dir;
212
213 fn manifest(name: &str) -> AddonManifest {
214 AddonManifest::from_toml(&format!(
215 "[addon]\nname = \"{name}\"\nversion = \"0.1.0\"\n\
216 [mcp]\ntransport = \"stdio\"\ncommand = \"{name}-mcp\"\n"
217 ))
218 .expect("parse")
219 }
220
221 #[test]
222 fn install_then_remove_round_trip() {
223 let _iso = isolated_data_dir();
224
225 let out = install(&manifest("demo"), "registry", false, None).expect("install");
226 assert_eq!(out.gateway_server, "demo");
227 assert!(out.enabled_gateway, "gateway was off, install enables it");
228
229 let cfg = Config::load();
231 assert!(cfg.gateway.enabled);
232 assert!(cfg.gateway.servers.iter().any(|s| s.name == "demo"));
233
234 assert!(InstalledStore::load().get("demo").is_some());
236
237 let out2 = install(&manifest("demo"), "registry", false, None).expect("reinstall");
239 assert!(!out2.enabled_gateway, "already enabled");
240 let cfg = Config::load();
241 assert_eq!(
242 cfg.gateway
243 .servers
244 .iter()
245 .filter(|s| s.name == "demo")
246 .count(),
247 1
248 );
249
250 let rm = remove("demo").expect("remove");
252 assert!(rm.last_removed);
253 let cfg = Config::load();
254 assert!(!cfg.gateway.servers.iter().any(|s| s.name == "demo"));
255 assert!(InstalledStore::load().get("demo").is_none());
256 }
257
258 #[test]
259 fn remove_unknown_is_error() {
260 let _iso = isolated_data_dir();
261 assert!(remove("nope").is_err());
262 }
263
264 #[test]
268 fn artifact_receipt_is_persisted() {
269 let _iso = isolated_data_dir();
270 let receipt = ArtifactReceipt {
271 platform: "aarch64-apple-darwin".into(),
272 url: "https://example.com/demo-bin".into(),
273 sha256: "c".repeat(64),
274 path: "/managed/bin/demo/1.0.0/demo-bin".into(),
275 };
276 install(&manifest("demo"), "registry", false, Some(receipt.clone())).expect("install");
277 let stored = InstalledStore::load();
278 let entry = stored.get("demo").expect("stored");
279 assert_eq!(entry.artifact.as_ref(), Some(&receipt));
280
281 install(&manifest("demo"), "registry", false, None).expect("reinstall");
283 assert!(
284 InstalledStore::load()
285 .get("demo")
286 .unwrap()
287 .artifact
288 .is_none()
289 );
290 }
291
292 #[test]
293 fn under_declared_capabilities_block_install_unless_forced() {
294 let _iso = isolated_data_dir();
298 let incoherent = AddonManifest::from_toml(
299 "[addon]\nname = \"liar\"\nversion = \"0.1.0\"\n\
300 [mcp]\ntransport = \"stdio\"\ncommand = \"npx\"\nargs = [\"-y\", \"pkg@1.2.3\"]\n\
301 [capabilities]\nnetwork = \"none\"\n",
302 )
303 .expect("parse");
304
305 let Err(err) = install(&incoherent, "local", false, None) else {
306 panic!("under-declared capabilities must block the install");
307 };
308 assert!(err.contains("under-state"), "got: {err}");
309 assert!(
310 !Config::load()
311 .gateway
312 .servers
313 .iter()
314 .any(|s| s.name == "liar"),
315 "nothing is wired when the gate rejects"
316 );
317
318 assert!(
320 install(&incoherent, "local", true, None).is_ok(),
321 "force bypasses the coherence gate"
322 );
323 }
324
325 #[test]
326 fn listed_only_manifest_refuses_install() {
327 let _iso = isolated_data_dir();
328 let listed = AddonManifest::from_toml("[addon]\nname = \"listed\"\n").expect("parse");
329 assert!(install(&listed, "registry", false, None).is_err());
330 }
331
332 #[test]
334 fn preflight_accepts_an_equal_min_lean_ctx() {
335 let _iso = isolated_data_dir();
336 let toml = format!(
337 "[addon]\nname = \"demo\"\nversion = \"1.0.0\"\nmin_lean_ctx = \"{}\"\n\
338 [mcp]\ncommand = \"demo-bin\"\n",
339 env!("CARGO_PKG_VERSION")
340 );
341 let m = AddonManifest::from_toml(&toml).expect("parses");
342 preflight(&m, &AddonsConfig::default(), false).expect("equal version passes");
343 }
344
345 #[test]
347 fn preflight_aborts_when_the_binary_is_too_old() {
348 let _iso = isolated_data_dir();
349 let m = AddonManifest::from_toml(
350 "[addon]\nname = \"demo\"\nversion = \"1.0.0\"\nmin_lean_ctx = \"999.0.0\"\n\
351 [mcp]\ncommand = \"demo-bin\"\n",
352 )
353 .expect("parses");
354 let err = preflight(&m, &AddonsConfig::default(), false).expect_err("too old");
355 assert!(err.contains("requires lean-ctx >= 999.0.0"), "{err}");
356 assert!(err.contains(env!("CARGO_PKG_VERSION")), "{err}");
357 }
358
359 #[test]
360 fn revoked_addon_refuses_install() {
361 let _iso = isolated_data_dir();
362 let mut list = super::super::revocation::RevocationList::load();
363 list.revoke("demo", "kill-switch test", None);
364 list.save().expect("save");
365 let Err(err) = install(&manifest("demo"), "registry", false, None) else {
366 panic!("revoked addon must refuse to install");
367 };
368 assert!(err.contains("revoked"), "got: {err}");
369 assert!(
371 !Config::load()
372 .gateway
373 .servers
374 .iter()
375 .any(|s| s.name == "demo")
376 );
377 assert!(InstalledStore::load().get("demo").is_none());
378 }
379}