Skip to main content

lean_ctx/core/addons/
install.rs

1//! Install / remove logic: wire an addon's MCP server into the global gateway
2//! and record it in the installed store.
3//!
4//! Pure state mutation — any interactive confirmation belongs in the CLI layer
5//! (so this stays unit-testable). Installation goes through
6//! [`Config::update_global`], the canonical safe-persistence entry point: it
7//! reads only the global config (no project-local merge) and refuses to clobber
8//! an unparseable file.
9
10use super::manifest::AddonManifest;
11use super::store::{InstalledAddon, InstalledStore};
12use crate::core::config::Config;
13
14/// Result of a successful [`install`].
15pub struct InstallOutcome {
16    pub name: String,
17    pub gateway_server: String,
18    /// `true` when installation flipped `gateway.enabled` from off to on.
19    pub enabled_gateway: bool,
20}
21
22/// Wire `manifest` into the global gateway and record it in the store.
23///
24/// `source` is recorded for `addon list` (`"registry"` or `"local"`). Replaces
25/// any existing gateway server / store entry with the same name (idempotent
26/// re-install). Returns an error if the addon has no runnable MCP endpoint.
27pub 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    // Kill-switch (P2): a revoked addon never installs.
38    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    // Security floor (#865): enforce the global-only install policy before any
48    // gateway mutation, so a blocked addon never touches config.
49    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
87/// Result of a successful [`remove`].
88pub struct RemoveOutcome {
89    pub name: String,
90    pub gateway_server: String,
91    /// `true` when no addons remain installed afterwards.
92    pub last_removed: bool,
93}
94
95/// Unwire an installed addon: drop its gateway server and store entry.
96pub 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        // Config now carries the server + gateway enabled.
144        let cfg = Config::load();
145        assert!(cfg.gateway.enabled);
146        assert!(cfg.gateway.servers.iter().any(|s| s.name == "demo"));
147
148        // Store records it.
149        assert!(InstalledStore::load().get("demo").is_some());
150
151        // Re-install is idempotent (no duplicate server).
152        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        // Remove unwinds both config + store.
165        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        // Nothing was wired.
196        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}