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::{ArtifactReceipt, InstalledAddon, InstalledStore};
13use crate::core::config::Config;
14use crate::core::mcp_catalog::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
36    // Version gate (GH #727): an older lean-ctx does not understand this addon's
37    // `[[dependencies]]` / `{pack_dir:…}` wiring — it would install the addon and
38    // silently ignore both. Abort. This deliberately diverges from
39    // `context_package::loader`, which only warns on a min-version mismatch: a
40    // warning here would leave exactly the silent-ignore path this gate exists to
41    // close.
42    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    // Kill-switch (P2): a revoked addon never installs.
63    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    // Security floor (#865): enforce the global-only install policy before any
73    // gateway mutation, so a blocked addon never touches config.
74    let findings = super::trust::assess(manifest);
75    super::policy::gate(manifest, addons, &findings)?;
76
77    // Capability-coherence gate (#1080): an addon whose declared `[capabilities]`
78    // under-state its wiring (e.g. `network = none` while launching `npx`) would
79    // be silently sandbox-blocked at runtime. Refuse the install with an
80    // actionable message instead of letting it fail opaquely at first use.
81    enforce_capability_coherence(manifest, force)?;
82
83    Ok(server)
84}
85
86/// Block an install whose declared capabilities under-state what the wiring
87/// does (the audit's incoherence verdict), unless `force` overrides it.
88fn 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
113/// Wire `manifest` into the global gateway and record it in the store.
114///
115/// `source` is recorded for `addon list` (`"registry"` or `"local"`). `force`
116/// bypasses the capability-coherence gate (#1080). `artifact` is the receipt
117/// of a managed prebuilt binary the CLI layer installed beforehand (GH #725) —
118/// like the bootstrap, the impure download runs in the CLI layer and only the
119/// receipt is persisted here. Replaces any existing gateway server / store
120/// entry with the same name (idempotent re-install). Returns an error if any
121/// [`preflight`] check rejects the addon.
122pub 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        // Record what a `[install]` block provisions so `remove` can uninstall
154        // it (#1105). The bootstrap itself runs in the CLI layer before this
155        // call; here we only persist the receipt, keeping `install` pure.
156        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
173/// Result of a successful [`remove`].
174pub struct RemoveOutcome {
175    pub name: String,
176    pub gateway_server: String,
177    /// `true` when no addons remain installed afterwards.
178    pub last_removed: bool,
179}
180
181/// Unwire an installed addon: drop its gateway server and store entry.
182pub 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        // Config now carries the server + gateway enabled.
230        let cfg = Config::load();
231        assert!(cfg.gateway.enabled);
232        assert!(cfg.gateway.servers.iter().any(|s| s.name == "demo"));
233
234        // Store records it.
235        assert!(InstalledStore::load().get("demo").is_some());
236
237        // Re-install is idempotent (no duplicate server).
238        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        // Remove unwinds both config + store.
251        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    /// GH #725: the managed-artifact receipt the CLI layer hands over is
265    /// persisted verbatim, so `doctor`/`update`/`remove` can re-verify and
266    /// clean up exactly what was installed.
267    #[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        // Reinstall without a receipt clears it (idempotent upsert semantics).
282        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        // #1080: a manifest that launches `npx` (needs network) but declares
295        // `network = none` would be sandbox-blocked at runtime. The install gate
296        // must refuse it with an actionable message — and `--force` must override.
297        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        // --force overrides the coherence gate.
319        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    /// The running binary's own version always clears its own gate.
333    #[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    /// A requirement above the running binary aborts, naming both versions.
346    #[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        // Nothing was wired.
370        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}