From 900f70c249debcf72a7d2cc7e2b9c52debc4f2c0 Mon Sep 17 00:00:00 2001 From: Bruno Charest Date: Wed, 19 Aug 2026 22:23:32 -0400 Subject: [PATCH] =?UTF-8?q?feat:=20v1.0=20=E2=80=94=20am=20registry=20:=20?= =?UTF-8?q?registre=20communautaire=20de=20catalogues=20(publication=20+?= =?UTF-8?q?=20recherche=20sur=20Gitea),=20manifeste=20source/version/auteu?= =?UTF-8?q?r/sha256,=20installation=20avec=20validation=20du=20checksum=20?= =?UTF-8?q?et=20d=C3=A9cision=20de=20confiance=20explicite=20(closes=20#77?= =?UTF-8?q?)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - src/registry.rs : Manifest (schema/name/version/author/source/agents_count/sha256), publish (écrit am-manifest.json + instructions git), search (multi-sources via settings.registry.sources), install (manifeste → catalogue → sha256 → source cohérente → confiance → register_include), list - settings.registry {sources, author} + doc config.yaml ; CLI/REPL/help/man/i18n - 11 tests : hash, manifest_url, publish (fichier + refus catalogue vide/sans source), E2E install tiny_http (refus sans confiance, enregistrement avec --yes, 4 requêtes), source mismatch --- ROADMAP.md | 2 +- config.yaml | 7 + man/am-registry.1 | 29 +++ man/am.1 | 3 + src/cli.rs | 45 ++++ src/commands/mod.rs | 2 + src/commands/registry_cmd.rs | 29 +++ src/config.rs | 19 ++ src/help.rs | 26 ++ src/i18n.rs | 3 + src/lib.rs | 1 + src/registry.rs | 484 +++++++++++++++++++++++++++++++++++ src/repl.rs | 35 ++- 13 files changed, 683 insertions(+), 2 deletions(-) create mode 100644 man/am-registry.1 create mode 100644 src/commands/registry_cmd.rs create mode 100644 src/registry.rs diff --git a/ROADMAP.md b/ROADMAP.md index 4678d60..0619f7a 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -475,7 +475,7 @@ alerte). | # | Issue | Effort | |---|---|---| | [#76](https://git.dracodev.net/Projets/agent-manager/issues/76) | ✅ Télémétrie anonyme opt-in (compteurs agrégés uniquement) | M | -| [#77](https://git.dracodev.net/Projets/agent-manager/issues/77) | Registre communautaire — am registry (publication + recherche sur Gitea) | L | +| [#77](https://git.dracodev.net/Projets/agent-manager/issues/77) | ✅ Registre communautaire — am registry (publication + recherche sur Gitea) | L | | [#78](https://git.dracodev.net/Projets/agent-manager/issues/78) | ✅ am ask — langage naturel → commande am (fournisseur LLM optionnel) | L | | [#79](https://git.dracodev.net/Projets/agent-manager/issues/79) | Profils sandbox par agent (commandes/répertoires autorisés) | L | | [#80](https://git.dracodev.net/Projets/agent-manager/issues/80) | am serve --token — API HTTP + WebSocket pour piloter à distance | XL | diff --git a/config.yaml b/config.yaml index 2e5e88b..138c5fe 100644 --- a/config.yaml +++ b/config.yaml @@ -57,6 +57,13 @@ settings: # enabled: true # false désactive entièrement am ask # provider: deepseek # optionnel (défaut: settings.default_provider) # model: deepseek-chat # optionnel (défaut: modèle par défaut du provider) + # Registre communautaire (#77) : sources de catalogues de confiance + + # auteur par défaut pour am registry publish. L'installation d'une source + # inconnue exige une confirmation (checksum sha256 vérifié avant tout). + # registry: + # sources: + # - https://git.dracodev.net/bruno/am-catalog/raw/branch/main/am-catalog.yaml + # author: bruno # Registre des providers LLM (#88) : nom -> base_url, modèles, modèle par # défaut. Le provider par défaut est utilisé à l'install/au run quand aucun # n'est donné (issue #90). Les tokens ne vont JAMAIS ici — ils vivent dans diff --git a/man/am-registry.1 b/man/am-registry.1 new file mode 100644 index 0000000..6f724f3 --- /dev/null +++ b/man/am-registry.1 @@ -0,0 +1,29 @@ +.ie \n(.g .ds Aq \(aq +.el .ds Aq ' +.TH am-registry 1 "registry " +.SH NAME +registry \- Community registry (issue #77): publish, search and install agent catalogs hosted on Gitea +.SH SYNOPSIS +\fBregistry\fR [\fB\-h\fR|\fB\-\-help\fR] [\fIsubcommands\fR] +.SH DESCRIPTION +Community registry (issue #77): publish, search and install agent catalogs hosted on Gitea +.SH OPTIONS +.TP +\fB\-h\fR, \fB\-\-help\fR +Print help +.SH SUBCOMMANDS +.TP +registry\-publish(1) +Prepare a catalog + manifest (source, version, author, sha256) for publication on Gitea +.TP +registry\-search(1) +Search agents across the registered sources (settings.registry.sources) +.TP +registry\-install(1) +Install a catalog from the registry: manifest + checksum validation, then an explicit trust decision for unknown sources +.TP +registry\-list(1) +List the registered sources +.TP +registry\-help(1) +Print this message or the help of the given subcommand(s) diff --git a/man/am.1 b/man/am.1 index 2fec9ab..5658ef1 100644 --- a/man/am.1 +++ b/man/am.1 @@ -118,6 +118,9 @@ Schedule am commands (issue #56) am\-models(1) List local models (ollama, llama.cpp, LM Studio) and prune unused ones .TP +am\-registry(1) +Community registry (issue #77): publish, search and install agent catalogs hosted on Gitea +.TP am\-catalog(1) Manage remote catalogs: update the official one, add external ones .TP diff --git a/src/cli.rs b/src/cli.rs index 16d3986..64460df 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -189,6 +189,9 @@ pub enum Command { Schedule(ScheduleCmd), /// List local models (ollama, llama.cpp, LM Studio) and prune unused ones Models(ModelsArgs), + /// Community registry (issue #77): publish, search and install agent + /// catalogs hosted on Gitea. + Registry(RegistryArgs), /// Manage remote catalogs: update the official one, add external ones #[command(subcommand)] Catalog(CatalogCmd), @@ -628,6 +631,48 @@ pub struct LabArgs { pub list: bool, } +/// Arguments of the registry command (issue #77). +#[derive(Args, Debug, Clone, Default)] +pub struct RegistryArgs { + #[command(subcommand)] + pub sub: Option, +} + +#[derive(Subcommand, Debug, Clone)] +pub enum RegistryCmd { + /// Prepare a catalog + manifest (source, version, author, sha256) for + /// publication on Gitea + Publish { + /// Path of the catalog YAML file + catalog: PathBuf, + /// Public URL of the catalog once pushed (recorded in the manifest) + #[arg(long)] + source: Option, + /// Author name recorded in the manifest + #[arg(long)] + author: Option, + /// Catalog version (default 1.0.0) + #[arg(long)] + version: Option, + }, + /// Search agents across the registered sources (settings.registry.sources) + Search { + /// Free-form keyword (agent name, description or tag) + query: String, + }, + /// Install a catalog from the registry: manifest + checksum validation, + /// then an explicit trust decision for unknown sources + Install { + /// Catalog URL + url: String, + /// Skip the trust confirmation + #[arg(long)] + yes: bool, + }, + /// List the registered sources + List, +} + /// Arguments of the plugins command (issue #75). #[derive(Args, Debug, Clone, Default)] pub struct PluginsArgs { diff --git a/src/commands/mod.rs b/src/commands/mod.rs index 032dbf3..4611482 100644 --- a/src/commands/mod.rs +++ b/src/commands/mod.rs @@ -26,6 +26,7 @@ pub mod migrate_cmd; pub mod open_cmd; pub mod profile_cmd; pub mod projects_cmd; +pub mod registry_cmd; pub mod run_cmd; pub mod schedule_cmd; pub mod search_cmd; @@ -99,6 +100,7 @@ pub fn execute_command(app: &App, cmd: &Command) -> Result { Command::Alias(sub) => alias_cmd::run(app, sub), Command::Secret(sub) => secret_cmd::run(app, sub), Command::Models(args) => models_cmd::run(app, args), + Command::Registry(args) => registry_cmd::run(app, args.sub.as_ref()), Command::Catalog(sub) => catalog_cmd::run(app, sub), Command::Providers(args) => providers_cmd::run(app, args.sub.as_ref()), Command::Suggest { words } => suggest_cmd::run(app, &words.join(" ")), diff --git a/src/commands/registry_cmd.rs b/src/commands/registry_cmd.rs new file mode 100644 index 0000000..beb3456 --- /dev/null +++ b/src/commands/registry_cmd.rs @@ -0,0 +1,29 @@ +//! am registry — community registry of agent catalogs (issue #77). +//! publish (manifest), search (across settings.registry.sources), +//! install (manifest + checksum + explicit trust), list. + +use crate::app::App; +use anyhow::Result; + +pub fn run(app: &App, sub: Option<&crate::cli::RegistryCmd>) -> Result { + match sub { + None => crate::registry::list(app), + Some(crate::cli::RegistryCmd::List) => crate::registry::list(app), + Some(crate::cli::RegistryCmd::Publish { + catalog, + source, + author, + version, + }) => crate::registry::publish( + app, + catalog, + source.as_deref(), + author.as_deref(), + version.as_deref(), + ), + Some(crate::cli::RegistryCmd::Search { query }) => crate::registry::search(app, query), + Some(crate::cli::RegistryCmd::Install { url, yes }) => { + crate::registry::install(app, url, *yes) + } + } +} diff --git a/src/config.rs b/src/config.rs index dbaaa33..0362909 100644 --- a/src/config.rs +++ b/src/config.rs @@ -145,6 +145,10 @@ pub struct Settings { /// registry. `enabled: false` turns the whole command off. #[serde(default)] pub ask: Option, + /// Community registry (issue #77): trusted catalog sources + the author + /// name recorded by `am registry publish`. + #[serde(default)] + pub registry: Option, /// Plugin scripts (issue #75): default timeout and enable list. #[serde(default)] pub plugins: Option, @@ -196,6 +200,18 @@ impl Default for AskSettings { } } +/// Community registry settings (issue #77). +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +#[serde(default)] +pub struct RegistrySettings { + /// Trusted catalog sources: their manifests are validated (checksum) + /// and their agents searchable through `am registry search`. + #[serde(default)] + pub sources: Vec, + /// Author name recorded by `am registry publish` (default: "unknown"). + pub author: Option, +} + /// One entry of the LLM provider registry (issue #88). #[derive(Debug, Clone, Serialize, Deserialize, Default)] #[serde(deny_unknown_fields)] @@ -904,6 +920,9 @@ pub fn merge(base: &mut Config, overlay: Config) { if o.ask.is_some() { s.ask = o.ask; } + if o.registry.is_some() { + s.registry = o.registry; + } if o.plugins.is_some() { s.plugins = o.plugins; } diff --git a/src/help.rs b/src/help.rs index 4da2aa5..d507d3d 100644 --- a/src/help.rs +++ b/src/help.rs @@ -870,6 +870,32 @@ pub static HELP_SPECS: &[HelpSpec] = &[ HelpExample { desc: "Une commande simple sans confirmation.", code: "ask liste les agents --yes" }, ], }, + HelpSpec { + name: "registry", + category: "Commands", + usage: "registry ", + about: "Registre communautaire (issue #77) : publier un catalogue + manifeste (source, version, auteur, sha256) sur Gitea, chercher des agents dans les sources enregistrées, et installer un catalogue avec validation du checksum et décision de confiance explicite.", + search_terms: &["community", "publish", "manifest", "sha256", "confiance"], + flags: &[ + HelpFlag { short: "", long: "--source", value: "URL", desc: "URL publique du catalogue publié (publish)" }, + HelpFlag { short: "", long: "--author", value: "NOM", desc: "Auteur enregistré dans le manifeste (publish)" }, + HelpFlag { short: "", long: "--version", value: "V", desc: "Version du catalogue, défaut 1.0.0 (publish)" }, + HelpFlag { short: "", long: "--yes", value: "", desc: "Accepter la source sans confirmation (install)" }, + ], + subcommands: &[ + ("registry publish", "prépare le manifeste (sha256) à côté du catalogue"), + ("registry install", "installe avec validation checksum + confiance"), + ], + parameters: &[ + HelpParam { name: "mot", typ: "string", desc: "Mot-clé de recherche (nom, description ou tag d'agent)" }, + ], + io: None, + examples: &[ + HelpExample { desc: "Préparer un catalogue pour publication.", code: "registry publish am-catalog.yaml --source https://git.dracodev.net/bruno/am-catalog/raw/branch/main/am-catalog.yaml --author bruno" }, + HelpExample { desc: "Chercher un agent dans les sources enregistrées.", code: "registry search claude" }, + HelpExample { desc: "Installer un catalogue en faisant confiance à la source.", code: "registry install https://git.dracodev.net/bruno/am-catalog/raw/branch/main/am-catalog.yaml --yes" }, + ], + }, HelpSpec { name: "start", category: "Commands", diff --git a/src/i18n.rs b/src/i18n.rs index 01efddf..4e88356 100644 --- a/src/i18n.rs +++ b/src/i18n.rs @@ -164,6 +164,9 @@ pub const CATALOG: &[(&str, &str)] = &[ ("exécuter ces commandes ?", "run these commands?"), ("annulé", "cancelled"), ("usage: ask — ex: ask installe claude et lance-le", "usage: ask — e.g. ask installe claude et lance-le"), + ("aucune source enregistrée — settings.registry.sources ou: am registry install ", "no sources registered — settings.registry.sources or: am registry install "), + ("installation refusée — source non fiable", "installation refused — untrusted source"), + ("usage: registry publish --source | registry search | registry install | registry list", "usage: registry publish --source | registry search | registry install | registry list"), ("usage: suggest — ex: suggest un agent pour du Python", "usage: suggest — e.g. suggest un agent pour du Python"), // ---- providers (#88) --------------------------------------------------- ("aucun provider enregistré — voir: am providers add --base-url ", "no providers registered — see: am providers add --base-url "), diff --git a/src/lib.rs b/src/lib.rs index fff830d..ccc2ed6 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -43,6 +43,7 @@ pub mod providers; pub mod projects; pub mod ps; pub mod process; +pub mod registry; pub mod repl; pub mod runner; pub mod secrets; diff --git a/src/registry.rs b/src/registry.rs new file mode 100644 index 0000000..9d3e3f1 --- /dev/null +++ b/src/registry.rs @@ -0,0 +1,484 @@ +//! 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(); + } +} diff --git a/src/repl.rs b/src/repl.rs index 6eb1292..e4a2b26 100644 --- a/src/repl.rs +++ b/src/repl.rs @@ -110,6 +110,7 @@ const COMMAND_DESCRIPTIONS: &[(&str, &str)] = &[ ("man", "readable man page in the terminal"), ("models", "list local models (ollama, llama.cpp, LM Studio)"), ("catalog", "manage remote catalogs"), + ("registry", "publish, search and install agent catalogs (Gitea)"), ("providers", "manage the LLM provider registry (base URLs, models, default)"), ("suggest", "recommend an agent for a request"), ("audit", "who changed what, when (config checksums)"), @@ -236,7 +237,7 @@ impl AmCompleter { "start", "stop", "restart", "run", "doctor", "config", "completion", "self-update", "self-uninstall", "export", "import", "shell", "theme", "lang", "tip", "dashboard", "favorite", "unfavorite", "note", "tag", "untag", "tags", - "profile", "man", "models", "catalog", "providers", "suggest", "ask", "audit", + "profile", "man", "models", "catalog", "providers", "suggest", "ask", "registry", "audit", "service", "schedule", "monitor", "web", "sync", "migrate", "playbook", "lab", "plugins", "ls", "dir", "cd", "ps", "where", "get", "help", "version", "exit", ], @@ -778,6 +779,7 @@ pub fn banner_box( "Commands".to_string() })); rows.push(inner(" catalog list · status · search · info · init".to_string())); + rows.push(inner(" registry publish · search · install · list".to_string())); rows.push(inner( " activity sessions · stats · top · report · projects · timeline · log · logs · history" .to_string(), @@ -1543,6 +1545,37 @@ fn handle_line( return Ok(false); } }, + "registry" => match rest.first().map(|s| s.as_str()) { + None => Command::Registry(crate::cli::RegistryArgs::default()), + Some("list") => Command::Registry(crate::cli::RegistryArgs { + sub: Some(crate::cli::RegistryCmd::List), + }), + Some("search") if rest.len() >= 2 => Command::Registry(crate::cli::RegistryArgs { + sub: Some(crate::cli::RegistryCmd::Search { + query: rest[1..].join(" "), + }), + }), + Some("install") if rest.len() >= 2 => Command::Registry(crate::cli::RegistryArgs { + sub: Some(crate::cli::RegistryCmd::Install { + url: rest[1].clone(), + yes: flag("--yes"), + }), + }), + Some("publish") if rest.len() >= 2 => Command::Registry(crate::cli::RegistryArgs { + sub: Some(crate::cli::RegistryCmd::Publish { + catalog: rest[1].clone().into(), + source: opt_value("--source"), + author: opt_value("--author"), + version: opt_value("--version"), + }), + }), + _ => { + app.log.error(crate::i18n::tr( + "usage: registry publish --source | registry search | registry install | registry list", + )); + return Ok(false); + } + }, "suggest" => { if rest.is_empty() { app.log.error(crate::i18n::tr("usage: suggest — ex: suggest un agent pour du Python"));