Skip to main content

lean_ctx/core/context_package/
addons.rs

1//! `kind=addon` store layout and module materialization.
2//!
3//! Mirrors [`super::skills`] deliberately: an addon pack is the same verified
4//! blob mechanism, pointed at WASM modules instead of documents. Keeping the
5//! two shapes identical means the tamper detection, the path safety and the
6//! idempotent-reinstall behaviour are the ones already proven for skills,
7//! rather than a second implementation that drifts.
8//!
9//! The one difference that matters: these bytes are executable, so
10//! `super::verify::validate_addon` additionally pins the `.wasm` extension
11//! and the WebAssembly magic before anything is written.
12
13use std::path::{Path, PathBuf};
14
15use super::content::AddonContent;
16use super::manifest::PackageManifest;
17use super::verify;
18
19/// Store layout for materialized addons:
20/// `<store>/addons/<sanitized-name>/<version>/`.
21///
22/// `@ns/name` → `@ns__name`, mirroring `LocalRegistry::package_dir` and
23/// [`super::skills::skills_dir`].
24pub(crate) fn addons_dir(store_root: &Path, name: &str, version: &str) -> PathBuf {
25    let safe_name = name.replace('/', "__");
26    store_root.join("addons").join(safe_name).join(version)
27}
28
29/// The root installed addons are materialized under.
30///
31/// Install and discovery **must** agree on this path, and the way to guarantee
32/// that is to have one definition rather than two derivations that look alike.
33/// An earlier version of this code had `install` write under the registry root
34/// (`<data_dir>/packages`) while discovery scanned `<data_dir>` — every addon
35/// installed fine and none of them ever loaded.
36pub fn store_root() -> Result<PathBuf, String> {
37    Ok(crate::core::data_dir::lean_ctx_data_dir()?.join(super::registry::PACKAGES_DIR))
38}
39
40/// The root every installed addon lives under. Discovery walks this.
41pub fn addons_root(store_root: &Path) -> PathBuf {
42    store_root.join("addons")
43}
44
45/// Write the pack's modules under the store and return the version directory.
46///
47/// Re-install rebuilds the version directory from scratch so a module removed
48/// upstream does not linger and keep getting loaded — the same reasoning as the
49/// skills materializer, with more consequence, because a lingering module here
50/// is code that still runs.
51pub(crate) fn materialize_modules(
52    store_root: &Path,
53    manifest: &PackageManifest,
54    addon: &AddonContent,
55) -> Result<PathBuf, String> {
56    let version_root = addons_dir(store_root, &manifest.name, &manifest.version);
57
58    if version_root.exists() {
59        std::fs::remove_dir_all(&version_root).map_err(|e| e.to_string())?;
60    }
61    // Create the version directory up front rather than as a side effect of
62    // writing the first module: an addon may legitimately carry no module at
63    // all (it declares an `[mcp]` server instead), and the manifest below still
64    // has to land somewhere.
65    std::fs::create_dir_all(&version_root)
66        .map_err(|e| format!("create {}: {e}", version_root.display()))?;
67
68    for blob in &addon.modules {
69        verify::validate_document_path(&blob.path)?;
70        let plain = blob.decode_verified()?;
71        let dest = version_root.join(&blob.path);
72        if let Some(parent) = dest.parent() {
73            std::fs::create_dir_all(parent).map_err(|e| e.to_string())?;
74        }
75        std::fs::write(&dest, &plain).map_err(|e| format!("write {}: {e}", dest.display()))?;
76        // Read-only, and never executable: the module is fed to the wasmi
77        // interpreter, never handed to the OS loader. A `+x` bit here would
78        // only ever help something we do not want to happen.
79        #[cfg(unix)]
80        {
81            use std::os::unix::fs::PermissionsExt;
82            let _ = std::fs::set_permissions(&dest, std::fs::Permissions::from_mode(0o444));
83        }
84    }
85
86    // The authoring manifest travels with the modules so `addon info` can show
87    // what the author declared without re-opening the original pack.
88    let manifest_dest = version_root.join("lean-ctx-addon.toml");
89    std::fs::write(&manifest_dest, addon.manifest_toml.as_bytes())
90        .map_err(|e| format!("write {}: {e}", manifest_dest.display()))?;
91
92    Ok(version_root)
93}
94
95/// Every installed `.wasm` module under the store, sorted for determinism.
96///
97/// Sorting is not cosmetic: registration order decides which compressor wins a
98/// name collision, and #498 requires the same corpus to produce the same
99/// answer on every run.
100///
101/// Gated on `wasm`: this exists to feed the module loader, and a build without
102/// that feature (the `lean-ctx-embed` facade, for one) has nothing to feed.
103/// `addon list` deliberately does not use it — the CLI groups by name and
104/// version, which this flat list cannot express.
105#[cfg(any(feature = "wasm", test))]
106/// Every module the extension registry should load: one version per addon.
107///
108/// The store keeps versions side by side, like every other pack kind, so after
109/// an upgrade `@ns/demo/1.0.0` and `@ns/demo/1.1.0` both exist on disk. Walking
110/// the whole tree would hand the registry two modules with the same file stem
111/// and let load order decide which one wins — and load order was sorted paths,
112/// where `"10.0.0" < "9.0.0"`, so upgrading 9 to 10 would silently keep running
113/// version 9. Old code that keeps running is the failure this whole channel is
114/// careful about elsewhere; it should not arrive through the back door.
115///
116/// One version is chosen per addon by directory mtime — "the one you installed
117/// last". Not by parsing the version: the manifest contract calls `version`
118/// author-declared and free-form, so it is not reliably orderable, whereas the
119/// install that wrote the directory is a fact.
120pub fn installed_modules(store_root: &Path) -> Vec<PathBuf> {
121    let root = addons_root(store_root);
122    let Ok(addon_dirs) = std::fs::read_dir(&root) else {
123        return Vec::new();
124    };
125
126    let mut out = Vec::new();
127    for addon_dir in addon_dirs.flatten().filter(|e| e.path().is_dir()) {
128        let Ok(versions) = std::fs::read_dir(addon_dir.path()) else {
129            continue;
130        };
131        let newest = versions
132            .flatten()
133            .filter(|e| e.path().is_dir())
134            .max_by_key(|e| {
135                e.metadata()
136                    .and_then(|m| m.modified())
137                    .unwrap_or(std::time::UNIX_EPOCH)
138            });
139        if let Some(version_dir) = newest {
140            collect_modules(&version_dir.path(), &mut out, 0);
141        }
142    }
143    out.sort();
144    out
145}
146
147/// Bounded recursive walk. The depth cap is a belt-and-braces stop: paths are
148/// already validated on the way in, so a deep tree here would mean the store
149/// was edited by hand.
150#[cfg(any(feature = "wasm", test))]
151fn collect_modules(dir: &Path, out: &mut Vec<PathBuf>, depth: usize) {
152    if depth > 6 {
153        return;
154    }
155    let Ok(entries) = std::fs::read_dir(dir) else {
156        return;
157    };
158    for entry in entries.flatten() {
159        let path = entry.path();
160        if path.is_dir() {
161            collect_modules(&path, out, depth + 1);
162        } else if path.extension().and_then(|e| e.to_str()) == Some("wasm") {
163            out.push(path);
164        }
165    }
166}
167
168#[cfg(test)]
169mod tests {
170    use super::super::content::DocumentBlob;
171    use super::*;
172
173    fn manifest(name: &str, version: &str) -> PackageManifest {
174        use super::super::manifest::{
175            CompatibilitySpec, PackageIntegrity, PackageKind, PackageProvenance, PackageStats,
176        };
177        PackageManifest {
178            schema_version: crate::core::contracts::CONTEXT_PACKAGE_V2_SCHEMA_VERSION,
179            conformance_level: None,
180            kind: PackageKind::Addon,
181            name: name.to_string(),
182            version: version.to_string(),
183            description: "test addon".to_string(),
184            author: None,
185            scope: name
186                .starts_with('@')
187                .then(|| name.split('/').next().unwrap_or_default().to_string()),
188            created_at: chrono::Utc::now(),
189            updated_at: None,
190            layers: Vec::new(),
191            dependencies: Vec::new(),
192            tags: Vec::new(),
193            visibility: None,
194            integrity: PackageIntegrity {
195                sha256: "0".repeat(64),
196                content_hash: "0".repeat(64),
197                byte_size: 0,
198            },
199            provenance: PackageProvenance {
200                tool: "lean-ctx".into(),
201                tool_version: env!("CARGO_PKG_VERSION").into(),
202                project_hash: None,
203                source_session_id: None,
204            },
205            compatibility: CompatibilitySpec::default(),
206            stats: PackageStats::default(),
207            signature: None,
208            graph_summary: None,
209            marketplace: None,
210            redistributable: None,
211        }
212    }
213
214    /// A minimal valid WebAssembly module: magic + version, nothing else.
215    fn empty_wasm() -> Vec<u8> {
216        vec![0x00, 0x61, 0x73, 0x6d, 0x01, 0x00, 0x00, 0x00]
217    }
218
219    #[test]
220    fn materializes_modules_and_the_authoring_manifest() {
221        let tmp = tempfile::tempdir().unwrap();
222        let addon = AddonContent {
223            manifest_toml: "[addon]\nname = \"demo\"\n".to_string(),
224            modules: vec![DocumentBlob::from_plaintext("demo.wasm", &empty_wasm()).unwrap()],
225        };
226        let root = materialize_modules(tmp.path(), &manifest("@ns/demo", "1.0.0"), &addon).unwrap();
227
228        assert!(root.join("demo.wasm").is_file());
229        assert_eq!(
230            std::fs::read(root.join("demo.wasm")).unwrap(),
231            empty_wasm(),
232            "the stored bytes must be the verified plaintext"
233        );
234        assert!(
235            root.join("lean-ctx-addon.toml").is_file(),
236            "the authoring manifest travels with the modules"
237        );
238        assert!(
239            root.to_string_lossy().contains("@ns__demo"),
240            "scoped names are flattened like every other pack kind: {root:?}"
241        );
242    }
243
244    /// An MCP-only addon carries no module, and its manifest still has to land
245    /// on disk. Creating the version directory as a side effect of writing the
246    /// first module left this case with nowhere to write — install failed with
247    /// a raw ENOENT after the user had already consented.
248    #[test]
249    fn an_addon_without_modules_still_materializes_its_manifest() {
250        let tmp = tempfile::tempdir().unwrap();
251        let addon = AddonContent {
252            manifest_toml: "[addon]\nname = \"@ns/server-only\"\n[mcp]\ncommand = \"srv\"\n"
253                .to_string(),
254            modules: Vec::new(),
255        };
256
257        let root =
258            materialize_modules(tmp.path(), &manifest("@ns/server-only", "1.0.0"), &addon).unwrap();
259        assert!(root.is_dir(), "the version directory exists");
260        assert!(
261            root.join("lean-ctx-addon.toml").is_file(),
262            "the authoring manifest was written"
263        );
264    }
265
266    /// A module dropped upstream must stop being loaded. It is code, so a
267    /// leftover file is not clutter — it is a capability that outlives its pack.
268    #[test]
269    fn reinstall_drops_modules_the_new_version_no_longer_ships() {
270        let tmp = tempfile::tempdir().unwrap();
271        let m = manifest("@ns/demo", "1.0.0");
272
273        let two = AddonContent {
274            manifest_toml: "[addon]\n".into(),
275            modules: vec![
276                DocumentBlob::from_plaintext("a.wasm", &empty_wasm()).unwrap(),
277                DocumentBlob::from_plaintext("b.wasm", &empty_wasm()).unwrap(),
278            ],
279        };
280        materialize_modules(tmp.path(), &m, &two).unwrap();
281        assert_eq!(installed_modules(tmp.path()).len(), 2);
282
283        let one = AddonContent {
284            manifest_toml: "[addon]\n".into(),
285            modules: vec![DocumentBlob::from_plaintext("a.wasm", &empty_wasm()).unwrap()],
286        };
287        let root = materialize_modules(tmp.path(), &m, &one).unwrap();
288        assert!(!root.join("b.wasm").exists(), "b.wasm must be gone");
289        assert_eq!(installed_modules(tmp.path()).len(), 1);
290    }
291
292    #[test]
293    fn discovery_is_sorted_and_ignores_non_modules() {
294        let tmp = tempfile::tempdir().unwrap();
295        let addon = AddonContent {
296            manifest_toml: "[addon]\n".into(),
297            modules: vec![
298                DocumentBlob::from_plaintext("z.wasm", &empty_wasm()).unwrap(),
299                DocumentBlob::from_plaintext("a.wasm", &empty_wasm()).unwrap(),
300            ],
301        };
302        materialize_modules(tmp.path(), &manifest("@ns/demo", "1.0.0"), &addon).unwrap();
303
304        let found = installed_modules(tmp.path());
305        assert_eq!(found.len(), 2, "the .toml must not be picked up: {found:?}");
306        let names: Vec<String> = found
307            .iter()
308            .map(|p| p.file_name().unwrap().to_string_lossy().into_owned())
309            .collect();
310        assert_eq!(
311            names,
312            vec!["a.wasm", "z.wasm"],
313            "registration order must be deterministic (#498)"
314        );
315    }
316
317    /// After an upgrade both versions sit in the store. Loading both would give
318    /// the registry two modules with the same stem and let load order pick a
319    /// winner — and sorted paths put `"10.0.0"` before `"9.0.0"`, so the *old*
320    /// version would win exactly when the version number crosses ten. Only the
321    /// most recently installed version is loaded.
322    #[test]
323    fn only_the_most_recently_installed_version_is_loaded() {
324        let tmp = tempfile::tempdir().unwrap();
325        let addon = |body: &[u8]| AddonContent {
326            manifest_toml: "[addon]\nname = \"@ns/demo\"\n".into(),
327            modules: vec![DocumentBlob::from_plaintext("demo.wasm", body).unwrap()],
328        };
329
330        // The lexicographic trap: 10.0.0 sorts before 9.0.0.
331        let old = empty_wasm();
332        let mut new = empty_wasm();
333        new.extend_from_slice(&[0x00; 4]);
334        materialize_modules(tmp.path(), &manifest("@ns/demo", "9.0.0"), &addon(&old)).unwrap();
335        std::thread::sleep(std::time::Duration::from_millis(20));
336        materialize_modules(tmp.path(), &manifest("@ns/demo", "10.0.0"), &addon(&new)).unwrap();
337
338        let found = installed_modules(tmp.path());
339        assert_eq!(found.len(), 1, "one version's modules, not both: {found:?}");
340        assert_eq!(
341            std::fs::read(&found[0]).unwrap(),
342            new,
343            "the upgrade must win, not the version that sorts first"
344        );
345        assert!(
346            found[0].to_string_lossy().contains("10.0.0"),
347            "{:?}",
348            found[0]
349        );
350    }
351
352    #[test]
353    fn an_empty_store_yields_no_modules() {
354        let tmp = tempfile::tempdir().unwrap();
355        {
356            let found = &installed_modules(tmp.path());
357            assert!(found.is_empty(), "{found:?}");
358        }
359    }
360}