Files
agent-manager/src/registry.rs
T
bruno 900f70c249 feat: v1.0 — am registry : registre communautaire de catalogues (publication + recherche sur Gitea), manifeste source/version/auteur/sha256, installation avec validation du checksum et décision de confiance explicite (closes #77)
- 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
2026-08-19 22:23:32 -04:00

485 lines
18 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 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<i32> {
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 <url> is required: the public URL of the catalog once pushed \
(e.g. https://git.dracodev.net/<user>/<repo>/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<Manifest> {
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<String> {
app.config
.settings
.registry
.as_ref()
.map(|r| r.sources.clone())
.unwrap_or_default()
}
/// am registry search <query>: probe every registered source, list the
/// agents matching the query (name, description, tags).
pub fn search(app: &App, query: &str) -> Result<i32> {
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 <url>",
));
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 <url>: 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<i32> {
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<i32> {
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 <url>",
));
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();
}
}