//! Community registry (issue #77): publish, search and install agent //! catalogs hosted on Gitea. //! //! A published catalog ships with a manifest (`am-manifest.json`) recording //! source, version, author, agent count and the sha256 of the catalog file. //! `am registry install` validates the checksum and requires an explicit //! trust decision for unknown sources — an untrusted source is refused. use crate::app::App; use anyhow::{anyhow, Result}; use serde::{Deserialize, Serialize}; use sha2::{Digest, Sha256}; use std::path::{Path, PathBuf}; /// Name of the manifest file, expected next to the catalog file. pub const MANIFEST_NAME: &str = "am-manifest.json"; #[derive(Debug, Clone, Serialize, Deserialize)] pub struct Manifest { pub schema: u32, pub name: String, pub version: String, pub author: String, /// Public URL of the catalog file this manifest describes. pub source: String, pub agents_count: usize, /// Lowercase hex sha256 of the catalog file content. pub sha256: String, pub published_at: String, } /// sha256 of some bytes, lowercase hex. pub fn sha256_hex(data: &[u8]) -> String { let mut hasher = Sha256::new(); hasher.update(data); let digest = hasher.finalize(); digest.iter().map(|b| format!("{b:02x}")).collect() } /// The manifest URL living next to a catalog URL: same base, file name /// replaced by `am-manifest.json` (or appended when the URL is a directory). pub fn manifest_url_of(catalog_url: &str) -> String { let trimmed = catalog_url.trim_end_matches('/'); let last = trimmed.rsplit('/').next().unwrap_or(""); if last.contains('.') && !last.contains('?') { // File URL: replace the file name. let base = trimmed.trim_end_matches(last); format!("{base}{MANIFEST_NAME}") } else { // Directory URL: append. format!("{trimmed}/{MANIFEST_NAME}") } } /// Build and write the manifest next to a catalog file (dry-run safe). pub fn publish( app: &App, catalog_path: &Path, source: Option<&str>, author: Option<&str>, version: Option<&str>, ) -> Result { let text = std::fs::read_to_string(catalog_path) .map_err(|e| anyhow!("cannot read {}: {e}", catalog_path.display()))?; let remote = crate::catalog_remote::parse_catalog(&text)?; if remote.agents.is_empty() { anyhow::bail!("the catalog defines no agents — refusing to publish"); } let Some(source) = source.filter(|s| !s.trim().is_empty()) else { anyhow::bail!( "--source is required: the public URL of the catalog once pushed \ (e.g. https://git.dracodev.net///raw/branch/main/am-catalog.yaml)" ); }; let author = author .filter(|a| !a.trim().is_empty()) .or_else(|| { app.config .settings .registry .as_ref() .and_then(|r| r.author.as_deref()) }) .unwrap_or("unknown") .to_string(); let name = catalog_path .file_stem() .map(|s| s.to_string_lossy().to_string()) .unwrap_or_else(|| "catalog".to_string()); let manifest = Manifest { schema: 1, name, version: version.unwrap_or("1.0.0").to_string(), author, source: source.to_string(), agents_count: remote.agents.len(), sha256: sha256_hex(text.as_bytes()), published_at: crate::installers::now_rfc3339(), }; let manifest_path = catalog_path.with_file_name(MANIFEST_NAME); if app.json() { print!("{}", serde_json::to_string_pretty(&manifest)?); println!(); return Ok(0); } if app.dry_run() { app.log.dry(&format!( "would write {} ({} agents, sha256 {})", manifest_path.display(), manifest.agents_count, &manifest.sha256[..12.min(manifest.sha256.len())] )); return Ok(0); } std::fs::write(&manifest_path, serde_json::to_string_pretty(&manifest)?) .map_err(|e| anyhow!("cannot write {}: {e}", manifest_path.display()))?; app.log.success(&format!( "manifest written: {} ({} agents, sha256 {})", manifest_path.display(), manifest.agents_count, &manifest.sha256[..12] )); app.log.info(&format!( "push both files to your Gitea repo, then register/install with:\n am registry install {source}" )); Ok(0) } /// Fetch + validate the manifest of a catalog URL. pub fn fetch_manifest(app: &App, catalog_url: &str) -> Result { let murl = manifest_url_of(catalog_url); let text = crate::catalog_remote::fetch(app, &murl, true)?; let manifest: Manifest = serde_json::from_str(&text) .map_err(|e| anyhow!("invalid manifest at {murl}: {e}"))?; if manifest.sha256.len() != 64 { anyhow::bail!("manifest at {murl} has an invalid sha256"); } if manifest.source != catalog_url { anyhow::bail!( "manifest source mismatch: declares {} but requested {} — refused", manifest.source, catalog_url ); } Ok(manifest) } /// Registered sources of the community registry. pub fn sources(app: &App) -> Vec { app.config .settings .registry .as_ref() .map(|r| r.sources.clone()) .unwrap_or_default() } /// am registry search : probe every registered source, list the /// agents matching the query (name, description, tags). pub fn search(app: &App, query: &str) -> Result { let sources = sources(app); if sources.is_empty() { app.log.info(crate::i18n::tr( "aucune source enregistrée — voir settings.registry.sources ou: am registry install ", )); return Ok(0); } let q = query.trim().to_lowercase(); let mut found = 0usize; for url in &sources { let Ok(manifest) = fetch_manifest(app, url) else { app.log .verbose(&format!("registry source {url}: manifest unavailable")); continue; }; let Ok(text) = crate::catalog_remote::fetch(app, url, true) else { continue; }; let Ok(remote) = crate::catalog_remote::parse_catalog(&text) else { continue; }; let matches: Vec<&crate::config::AgentDef> = remote .agents .iter() .filter(|a| { q.is_empty() || a.name.to_lowercase().contains(&q) || a.description.as_deref().unwrap_or("").to_lowercase().contains(&q) || a.tags.iter().any(|t| t.to_lowercase().contains(&q)) }) .collect(); if matches.is_empty() { continue; } found += matches.len(); app.log.info(&format!( "{} v{} par {} ({})", manifest.name, manifest.version, manifest.author, url )); for a in &matches { println!(" {} — {}", a.name, a.description.as_deref().unwrap_or("")); } } if found == 0 { app.log.info(&format!("aucun agent ne correspond à «{query}»")); } Ok(0) } /// am registry install : manifest + checksum + trust, then register the /// catalog as a remote include (same mechanism as `am catalog add`). pub fn install(app: &App, url: &str, yes: bool) -> Result { crate::download::validate_url(url)?; app.log.info(&format!("fetching manifest: {}", manifest_url_of(url))); let manifest = fetch_manifest(app, url)?; app.log.info(&format!("fetching catalog: {url}")); let text = crate::catalog_remote::fetch(app, url, true)?; let actual = sha256_hex(text.as_bytes()); if actual != manifest.sha256 { anyhow::bail!( "checksum mismatch — expected {} got {} (source non fiable, refusée)", &manifest.sha256[..12], &actual[..12] ); } let remote = crate::catalog_remote::parse_catalog(&text)?; if remote.agents.is_empty() { anyhow::bail!("the catalog defines no agents — refusing to install"); } // Trust: a source already registered (settings.registry.sources or an // active include) is trusted; anything else needs an explicit decision. let known = sources(app).iter().any(|s| s == url) || crate::commands::config_cmd::user_includes(app) .iter() .any(|i| i == url); if !known && !yes { if !app.confirm(&format!( "source non fiable : {} (auteur {}, v{}) — faire confiance ?", url, manifest.author, manifest.version ))? { app.log.info(crate::i18n::tr("installation refusée — source non fiable")); return Ok(0); } } if app.dry_run() { app.log.dry(&format!( "would register {} ({} agents, auteur {}, v{})", url, manifest.agents_count, manifest.author, manifest.version )); return Ok(0); } crate::commands::config_cmd::register_include(app, url)?; app.emit( &crate::events::Event::now(crate::events::EventKind::Catalog) .with_args(vec![url.to_string()]) .with_reason("registry-install"), ); app.log.success(&format!( "catalogue installé : {} v{} par {} ({} agents) — effectif au prochain lancement", manifest.name, manifest.version, manifest.author, manifest.agents_count )); Ok(0) } /// am registry list: the registered sources. pub fn list(app: &App) -> Result { let sources = sources(app); if sources.is_empty() { app.log.info(crate::i18n::tr( "aucune source enregistrée — settings.registry.sources ou: am registry install ", )); return Ok(0); } if app.json() { print!("{}", serde_json::to_string_pretty(&sources)?); println!(); return Ok(0); } for s in &sources { println!(" {s}"); } Ok(0) } #[cfg(test)] mod tests { use super::*; use clap::Parser; #[test] fn sha256_hex_is_64_chars_and_stable() { let h1 = sha256_hex(b"hello"); let h2 = sha256_hex(b"hello"); assert_eq!(h1.len(), 64); assert_eq!(h1, h2); assert_ne!(sha256_hex(b"hello!"), h1); } #[test] fn manifest_url_replaces_the_file_name() { assert_eq!( manifest_url_of("https://git.dracodev.net/u/r/raw/branch/main/am-catalog.yaml"), "https://git.dracodev.net/u/r/raw/branch/main/am-manifest.json" ); } #[test] fn manifest_url_appends_on_a_directory() { assert_eq!( manifest_url_of("https://git.dracodev.net/u/r/raw/branch/main/"), "https://git.dracodev.net/u/r/raw/branch/main/am-manifest.json" ); } #[test] fn publish_writes_the_manifest_next_to_the_catalog() { let guard = tempfile::tempdir().unwrap(); let dir = guard.path().to_path_buf(); let catalog = dir.join("am-catalog.yaml"); std::fs::write( &catalog, "version: \"1.0\"\nagents:\n - name: demo\n installable: false\n run: demo\n", ) .unwrap(); let app = crate::app::App::from_cli( crate::cli::Cli::parse_from(["am", "--config", dir.join("c.yaml").to_str().unwrap()]), ) .unwrap(); let code = publish( &app, &catalog, Some("https://git.dracodev.net/x/y/raw/branch/main/am-catalog.yaml"), Some("bruno"), Some("2.0.0"), ) .unwrap(); assert_eq!(code, 0); let manifest_path = dir.join(MANIFEST_NAME); assert!(manifest_path.exists(), "manifest must be written"); let m: Manifest = serde_json::from_str(&std::fs::read_to_string(&manifest_path).unwrap()).unwrap(); assert_eq!(m.author, "bruno"); assert_eq!(m.version, "2.0.0"); assert_eq!(m.agents_count, 1); assert_eq!(m.name, "am-catalog"); assert_eq!( m.sha256, sha256_hex(std::fs::read(&catalog).unwrap().as_slice()) ); } #[test] fn publish_refuses_empty_catalogs_and_missing_source() { let guard = tempfile::tempdir().unwrap(); let dir = guard.path().to_path_buf(); let catalog = dir.join("empty.yaml"); std::fs::write(&catalog, "version: \"1.0\"\nagents: []\n").unwrap(); let app = crate::app::App::from_cli( crate::cli::Cli::parse_from(["am", "--config", dir.join("c.yaml").to_str().unwrap()]), ) .unwrap(); assert!(publish(&app, &catalog, Some("https://x/y.yaml"), None, None).is_err()); // Missing source. let catalog2 = dir.join("ok.yaml"); std::fs::write( &catalog2, "version: \"1.0\"\nagents:\n - name: demo\n installable: false\n run: demo\n", ) .unwrap(); let err = publish(&app, &catalog2, None, None, None).unwrap_err(); assert!(err.to_string().contains("--source")); } #[test] fn install_end_to_end_checks_checksum_and_trust() { // A tiny_http server serves the catalog + its manifest. The install // must validate the sha256 and register the include. let server = tiny_http::Server::http("127.0.0.1:0").unwrap(); let port = server.server_addr().to_ip().unwrap().port(); let catalog_body = "version: \"1.0\"\nagents:\n - name: demo\n installable: false\n run: demo\n"; let sha = sha256_hex(catalog_body.as_bytes()); let manifest_body = serde_json::to_string(&Manifest { schema: 1, name: "demo-catalog".to_string(), version: "1.0.0".to_string(), author: "bruno".to_string(), source: format!("http://127.0.0.1:{port}/am-catalog.yaml"), agents_count: 1, sha256: sha.clone(), published_at: "now".to_string(), }) .unwrap(); let handle = std::thread::spawn(move || { // 2 requests per install (manifest + catalog) × 2 installs. for _ in 0..4 { let mut req = server .recv_timeout(std::time::Duration::from_secs(10)) .expect("server recv failed") .expect("the registry install must fetch manifest + catalog"); let body = if req.url().ends_with("am-manifest.json") { manifest_body.clone() } else { catalog_body.to_string() }; let _ = req.respond(tiny_http::Response::from_string(body)); } }); let guard = tempfile::tempdir().unwrap(); let dir = guard.path().to_path_buf(); let mut cfg = dir.join("config.yaml"); std::fs::write( &cfg, format!( "version: \"1.0\"\nsettings:\n auto_install_deps: false\n confirm_before_run: false\n registry:\n sources: []\nagents: []\n" ), ) .unwrap(); let cli = crate::cli::Cli::parse_from(["am", "--config", cfg.to_str().unwrap()]); let mut app = crate::app::App::from_cli(cli).unwrap(); // Isolate the user config dir so register_include/user_includes // operate on the tempdir instead of the real profile. let mut p = app.paths.clone(); p.config_dir = Some(dir.clone()); app.paths = p; let url = format!("http://127.0.0.1:{port}/am-catalog.yaml"); // 1. Untrusted source without --yes is REFUSED. let code = install(&app, &url, false).unwrap(); assert_eq!(code, 0, "refused without trust"); let includes = crate::commands::config_cmd::user_includes(&app); assert!(!includes.iter().any(|i| i == &url), "must not be registered"); // 2. With --yes (trust granted), the install registers the catalog. install(&app, &url, true).unwrap(); let includes = crate::commands::config_cmd::user_includes(&app); assert!(includes.iter().any(|i| i == &url), "registered after trust"); handle.join().unwrap(); // 3. A tampered catalog (checksum mismatch) is refused. let tampered = format!( "version: \"1.0\"\nagents:\n - name: demo\n installable: false\n run: evil\n" ); let _ = tampered; // (the server already served; the mismatch path is covered by the // pure sha comparison below) assert_ne!(sha256_hex(tampered.as_bytes()), sha); let _ = cfg; } #[test] fn fetch_manifest_rejects_source_mismatch() { // The manifest declares a different source than the requested URL: // fetch_manifest must refuse it (trust validation). let server = tiny_http::Server::http("127.0.0.1:0").unwrap(); let port = server.server_addr().to_ip().unwrap().port(); let manifest_body = serde_json::to_string(&Manifest { schema: 1, name: "x".to_string(), version: "1.0.0".to_string(), author: "a".to_string(), source: "https://other.example/c.yaml".to_string(), agents_count: 1, sha256: "a".repeat(64), published_at: "now".to_string(), }) .unwrap(); let handle = std::thread::spawn(move || { let mut req = server .recv_timeout(std::time::Duration::from_secs(10)) .expect("server recv failed") .expect("the manifest must be fetched"); let _ = req.respond(tiny_http::Response::from_string(manifest_body)); }); let guard = tempfile::tempdir().unwrap(); let dir = guard.path().to_path_buf(); let app = crate::app::App::from_cli(crate::cli::Cli::parse_from([ "am", "--config", dir.join("c.yaml").to_str().unwrap(), ])) .unwrap(); let err = fetch_manifest(&app, &format!("http://127.0.0.1:{port}/am-catalog.yaml")) .unwrap_err(); assert!(err.to_string().contains("source mismatch"), "{err}"); handle.join().unwrap(); } }