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    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). `artifact` is the receipt
98/// of a managed prebuilt binary the CLI layer installed beforehand (GH #725) —
99/// like the bootstrap, the impure download runs in the CLI layer and only the
100/// receipt is persisted here. Replaces any existing gateway server / store
101/// entry with the same name (idempotent re-install). Returns an error if any
102/// [`preflight`] check rejects the addon.
103pub 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        // Record what a `[install]` block provisions so `remove` can uninstall
135        // it (#1105). The bootstrap itself runs in the CLI layer before this
136        // call; here we only persist the receipt, keeping `install` pure.
137        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
154/// Result of a successful [`remove`].
155pub struct RemoveOutcome {
156    pub name: String,
157    pub gateway_server: String,
158    /// `true` when no addons remain installed afterwards.
159    pub last_removed: bool,
160}
161
162/// Unwire an installed addon: drop its gateway server and store entry.
163pub 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        // Config now carries the server + gateway enabled.
211        let cfg = Config::load();
212        assert!(cfg.gateway.enabled);
213        assert!(cfg.gateway.servers.iter().any(|s| s.name == "demo"));
214
215        // Store records it.
216        assert!(InstalledStore::load().get("demo").is_some());
217
218        // Re-install is idempotent (no duplicate server).
219        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        // Remove unwinds both config + store.
232        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    /// GH #725: the managed-artifact receipt the CLI layer hands over is
246    /// persisted verbatim, so `doctor`/`update`/`remove` can re-verify and
247    /// clean up exactly what was installed.
248    #[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        // Reinstall without a receipt clears it (idempotent upsert semantics).
263        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        // #1080: a manifest that launches `npx` (needs network) but declares
276        // `network = none` would be sandbox-blocked at runtime. The install gate
277        // must refuse it with an actionable message — and `--force` must override.
278        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        // --force overrides the coherence gate.
300        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        // Nothing was wired.
324        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}