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 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(
104 manifest: &AddonManifest,
105 source: &str,
106 force: bool,
107 artifact: Option<ArtifactReceipt>,
108) -> Result<InstallOutcome, String> {
109 let cfg = Config::load();
110 let server = preflight(manifest, &cfg.addons, force)?;
111
112 let name = manifest.addon.name.clone();
113 let server_name = server.name.clone();
114 let mut enabled_gateway = false;
115
116 Config::update_global(|cfg| {
117 if !cfg.gateway.enabled {
118 cfg.gateway.enabled = true;
119 enabled_gateway = true;
120 }
121 cfg.gateway.servers.retain(|s| s.name != server_name);
122 cfg.gateway.servers.push(server.clone());
123 })
124 .map_err(|e| e.to_string())?;
125
126 let mut store = InstalledStore::load();
127 store.upsert(InstalledAddon {
128 name: name.clone(),
129 version: manifest.addon.version.clone(),
130 source: source.to_string(),
131 gateway_server: server_name.clone(),
132 granted_capabilities: manifest.capabilities.clone(),
133 content_hash: Some(super::integrity::wiring_hash(&server)),
134 install: manifest
138 .install
139 .is_declared()
140 .then(|| manifest.install.to_receipt()),
141 artifact,
142 });
143 store.save()?;
144
145 crate::core::mcp_catalog::catalog::invalidate();
146
147 Ok(InstallOutcome {
148 name,
149 gateway_server: server_name,
150 enabled_gateway,
151 })
152}
153
154pub struct RemoveOutcome {
156 pub name: String,
157 pub gateway_server: String,
158 pub last_removed: bool,
160}
161
162pub fn remove(name: &str) -> Result<RemoveOutcome, String> {
164 let mut store = InstalledStore::load();
165 let entry = store
166 .get(name)
167 .cloned()
168 .ok_or_else(|| format!("addon `{name}` is not installed"))?;
169 let server_name = entry.gateway_server.clone();
170
171 Config::update_global(|cfg| {
172 cfg.gateway.servers.retain(|s| s.name != server_name);
173 })
174 .map_err(|e| e.to_string())?;
175
176 store.remove(name);
177 let last_removed = store.addons.is_empty();
178 store.save()?;
179
180 crate::core::mcp_catalog::catalog::invalidate();
181
182 Ok(RemoveOutcome {
183 name: name.to_string(),
184 gateway_server: server_name,
185 last_removed,
186 })
187}
188
189#[cfg(test)]
190mod tests {
191 use super::*;
192 use crate::core::data_dir::isolated_data_dir;
193
194 fn manifest(name: &str) -> AddonManifest {
195 AddonManifest::from_toml(&format!(
196 "[addon]\nname = \"{name}\"\nversion = \"0.1.0\"\n\
197 [mcp]\ntransport = \"stdio\"\ncommand = \"{name}-mcp\"\n"
198 ))
199 .expect("parse")
200 }
201
202 #[test]
203 fn install_then_remove_round_trip() {
204 let _iso = isolated_data_dir();
205
206 let out = install(&manifest("demo"), "registry", false, None).expect("install");
207 assert_eq!(out.gateway_server, "demo");
208 assert!(out.enabled_gateway, "gateway was off, install enables it");
209
210 let cfg = Config::load();
212 assert!(cfg.gateway.enabled);
213 assert!(cfg.gateway.servers.iter().any(|s| s.name == "demo"));
214
215 assert!(InstalledStore::load().get("demo").is_some());
217
218 let out2 = install(&manifest("demo"), "registry", false, None).expect("reinstall");
220 assert!(!out2.enabled_gateway, "already enabled");
221 let cfg = Config::load();
222 assert_eq!(
223 cfg.gateway
224 .servers
225 .iter()
226 .filter(|s| s.name == "demo")
227 .count(),
228 1
229 );
230
231 let rm = remove("demo").expect("remove");
233 assert!(rm.last_removed);
234 let cfg = Config::load();
235 assert!(!cfg.gateway.servers.iter().any(|s| s.name == "demo"));
236 assert!(InstalledStore::load().get("demo").is_none());
237 }
238
239 #[test]
240 fn remove_unknown_is_error() {
241 let _iso = isolated_data_dir();
242 assert!(remove("nope").is_err());
243 }
244
245 #[test]
249 fn artifact_receipt_is_persisted() {
250 let _iso = isolated_data_dir();
251 let receipt = ArtifactReceipt {
252 platform: "aarch64-apple-darwin".into(),
253 url: "https://example.com/demo-bin".into(),
254 sha256: "c".repeat(64),
255 path: "/managed/bin/demo/1.0.0/demo-bin".into(),
256 };
257 install(&manifest("demo"), "registry", false, Some(receipt.clone())).expect("install");
258 let stored = InstalledStore::load();
259 let entry = stored.get("demo").expect("stored");
260 assert_eq!(entry.artifact.as_ref(), Some(&receipt));
261
262 install(&manifest("demo"), "registry", false, None).expect("reinstall");
264 assert!(
265 InstalledStore::load()
266 .get("demo")
267 .unwrap()
268 .artifact
269 .is_none()
270 );
271 }
272
273 #[test]
274 fn under_declared_capabilities_block_install_unless_forced() {
275 let _iso = isolated_data_dir();
279 let incoherent = AddonManifest::from_toml(
280 "[addon]\nname = \"liar\"\nversion = \"0.1.0\"\n\
281 [mcp]\ntransport = \"stdio\"\ncommand = \"npx\"\nargs = [\"-y\", \"pkg@1.2.3\"]\n\
282 [capabilities]\nnetwork = \"none\"\n",
283 )
284 .expect("parse");
285
286 let Err(err) = install(&incoherent, "local", false, None) else {
287 panic!("under-declared capabilities must block the install");
288 };
289 assert!(err.contains("under-state"), "got: {err}");
290 assert!(
291 !Config::load()
292 .gateway
293 .servers
294 .iter()
295 .any(|s| s.name == "liar"),
296 "nothing is wired when the gate rejects"
297 );
298
299 assert!(
301 install(&incoherent, "local", true, None).is_ok(),
302 "force bypasses the coherence gate"
303 );
304 }
305
306 #[test]
307 fn listed_only_manifest_refuses_install() {
308 let _iso = isolated_data_dir();
309 let listed = AddonManifest::from_toml("[addon]\nname = \"listed\"\n").expect("parse");
310 assert!(install(&listed, "registry", false, None).is_err());
311 }
312
313 #[test]
314 fn revoked_addon_refuses_install() {
315 let _iso = isolated_data_dir();
316 let mut list = super::super::revocation::RevocationList::load();
317 list.revoke("demo", "kill-switch test", None);
318 list.save().expect("save");
319 let Err(err) = install(&manifest("demo"), "registry", false, None) else {
320 panic!("revoked addon must refuse to install");
321 };
322 assert!(err.contains("revoked"), "got: {err}");
323 assert!(
325 !Config::load()
326 .gateway
327 .servers
328 .iter()
329 .any(|s| s.name == "demo")
330 );
331 assert!(InstalledStore::load().get("demo").is_none());
332 }
333}