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::policy::AddonsConfig;
12use super::store::{InstalledAddon, InstalledStore};
13use crate::core::config::Config;
14use crate::core::gateway::GatewayServer;
15
16/// Result of a successful [`install`].
17pub struct InstallOutcome {
18    pub name: String,
19    pub gateway_server: String,
20    /// `true` when installation flipped `gateway.enabled` from off to on.
21    pub enabled_gateway: bool,
22}
23
24/// Pure pre-persist gate shared by [`install`] and the CLI. Runs every check
25/// that can reject an addon *before* anything is wired or any health probe
26/// spawns a process — validation, runnable endpoint, kill-switch, the org
27/// install policy, and the capability-coherence gate (#1080) — and returns the
28/// resolved [`GatewayServer`] so the caller can reuse it. Pure + deterministic.
29pub 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    // Kill-switch (P2): a revoked addon never installs.
44    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    // Security floor (#865): enforce the global-only install policy before any
54    // gateway mutation, so a blocked addon never touches config.
55    let findings = super::trust::assess(manifest);
56    super::policy::gate(manifest, addons, &findings)?;
57
58    // Capability-coherence gate (#1080): an addon whose declared `[capabilities]`
59    // under-state its wiring (e.g. `network = none` while launching `npx`) would
60    // be silently sandbox-blocked at runtime. Refuse the install with an
61    // actionable message instead of letting it fail opaquely at first use.
62    enforce_capability_coherence(manifest, force)?;
63
64    Ok(server)
65}
66
67/// Block an install whose declared capabilities under-state what the wiring
68/// does (the audit's incoherence verdict), unless `force` overrides it.
69fn 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
94/// Wire `manifest` into the global gateway and record it in the store.
95///
96/// `source` is recorded for `addon list` (`"registry"` or `"local"`). `force`
97/// bypasses the capability-coherence gate (#1080). Replaces any existing gateway
98/// server / store entry with the same name (idempotent re-install). Returns an
99/// error if any [`preflight`] check rejects the addon.
100pub 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        // Record what a `[install]` block provisions so `remove` can uninstall
131        // it (#1105). The bootstrap itself runs in the CLI layer before this
132        // call; here we only persist the receipt, keeping `install` pure.
133        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
149/// Result of a successful [`remove`].
150pub struct RemoveOutcome {
151    pub name: String,
152    pub gateway_server: String,
153    /// `true` when no addons remain installed afterwards.
154    pub last_removed: bool,
155}
156
157/// Unwire an installed addon: drop its gateway server and store entry.
158pub 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        // Config now carries the server + gateway enabled.
206        let cfg = Config::load();
207        assert!(cfg.gateway.enabled);
208        assert!(cfg.gateway.servers.iter().any(|s| s.name == "demo"));
209
210        // Store records it.
211        assert!(InstalledStore::load().get("demo").is_some());
212
213        // Re-install is idempotent (no duplicate server).
214        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        // Remove unwinds both config + store.
227        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        // #1080: a manifest that launches `npx` (needs network) but declares
243        // `network = none` would be sandbox-blocked at runtime. The install gate
244        // must refuse it with an actionable message — and `--force` must override.
245        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        // --force overrides the coherence gate.
267        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        // Nothing was wired.
291        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}