Файли
openidea/crates/ide-agent/src/registry.rs
T
l0ng-ai fec391b9c1 fix(agents): keep registry installs running after Settings closes (#1460)
Closing Settings while an ACP Registry agent was installing cancelled the install, and Install was offered again while a previous attempt could still hold the agent's folder. Installs now outlive the page as a background task of the project windows (stoppable from the status bar); the backend refuses a second install of the same agent and marks it installing in the list, which a reopened page follows until it ends.

Fixes #1430
2026-11-05 12:43:42 +08:00

1011 рядки
40 KiB
Rust

//! The ACP Registry (agentclientprotocol.com): third-party agents, installed into an
//! app-owned directory and saved as `<config>/agents/<id>.toml` with a `[registry]` table.
//!
//! An installed agent runs from `<data>/agents/tools/<id>/<version>/`: a release archive
//! (SHA-256 checked when the registry gives one), an npm package installed there at the
//! registry's exact version, or a uv tool. Updating installs the new version alongside
//! and switches the agent file; [`maintain`] removes versions no agent file uses at the
//! next start, when no task runs on them.
use crate::provider::{self, CustomAgent, Driver, RegistryRef};
use crate::{AgentProvider, AgentSource};
use ide_core::error::ErrorKind;
use ide_core::{CancellationToken, IdeError, IdeResult};
use ide_tools::archive::{self, ArchiveKind};
use ide_tools::platform::{Arch, Os, Platform};
use ide_tools::{Emit, Http, InstallEvent};
use serde::{Deserialize, Serialize};
use specta::Type;
use std::cmp::Ordering;
use std::collections::BTreeMap;
use std::fs;
use std::path::{Path, PathBuf};
use std::process::Command;
use std::time::Duration;
pub const REGISTRY_URL: &str = "https://cdn.agentclientprotocol.com/registry/v1/latest/registry.json";
const MAX_REGISTRY_BYTES: u64 = 1024 * 1024;
/// [`maintain`] refreshes a saved index older than this, so updates show without opening
/// the registry.
const REFRESH_AFTER: Duration = Duration::from_secs(24 * 60 * 60);
#[derive(Clone, Debug)]
pub struct RegistryPaths {
/// Agent files, `<config>/agents`.
pub agents_dir: PathBuf,
/// Installed agents, `<data>/agents/tools/<id>/<version>`.
pub tools_dir: PathBuf,
/// The index as last fetched, for offline use and update checks.
pub cache_file: PathBuf,
}
impl RegistryPaths {
pub fn new(config_dir: &Path, data_dir: &Path) -> Self {
Self {
agents_dir: config_dir.join("agents"),
tools_dir: data_dir.join("agents").join("tools"),
cache_file: data_dir.join("agents").join("registry.json"),
}
}
fn agent_file(&self, id: &str) -> PathBuf {
self.agents_dir.join(format!("{id}.toml"))
}
}
/// A registry agent this machine can run (or has installed).
#[derive(Clone, Debug, PartialEq, Serialize, Type)]
#[serde(rename_all = "camelCase")]
pub struct RegistryAgent {
pub id: String,
pub name: String,
/// The registry's current version.
pub version: String,
pub description: String,
pub repository: Option<String>,
pub website: Option<String>,
pub authors: Vec<String>,
pub license: Option<String>,
/// How it installs here; `None` when it no longer can (an installed agent whose
/// runtime went away).
pub distribution: Option<RegistryDistribution>,
/// The release archive comes without a SHA-256, so the download cannot be checked.
/// npm and uv packages are checked by those tools.
pub no_checksum: bool,
/// The installed version.
pub installed: Option<String>,
/// Whether [`Self::version`] is newer than the installed one.
pub update: bool,
/// An install of it is running (it goes on after the Settings page that started it closes).
pub installing: bool,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Type)]
#[serde(rename_all = "camelCase")]
pub enum RegistryDistribution {
/// A release archive for this OS and architecture.
Binary,
/// An npm package (needs Node.js).
Npx,
/// A Python package run by uv.
Uvx,
}
#[derive(Clone, Debug, Serialize, Type)]
#[serde(rename_all = "camelCase")]
pub struct AgentRegistry {
pub agents: Vec<RegistryAgent>,
/// Set when the registry could not be reached and the copy saved earlier is shown.
pub warning: Option<String>,
}
// ---- the index (https://github.com/agentclientprotocol/registry) ----
#[derive(Clone, Debug, Deserialize)]
struct Entry {
id: String,
name: String,
version: String,
#[serde(default)]
description: String,
#[serde(default)]
repository: Option<String>,
#[serde(default)]
website: Option<String>,
#[serde(default)]
authors: Vec<String>,
#[serde(default)]
license: Option<String>,
#[serde(default)]
distribution: Distribution,
}
#[derive(Clone, Debug, Default, Deserialize)]
struct Distribution {
/// By `<os>-<arch>`, e.g. `darwin-aarch64`.
#[serde(default)]
binary: BTreeMap<String, Binary>,
#[serde(default)]
npx: Option<Package>,
#[serde(default)]
uvx: Option<Package>,
}
#[derive(Clone, Debug, Deserialize)]
struct Binary {
archive: String,
/// Path of the executable inside the archive.
cmd: String,
#[serde(default)]
args: Vec<String>,
#[serde(default)]
env: BTreeMap<String, String>,
#[serde(default)]
sha256: Option<String>,
}
#[derive(Clone, Debug, Deserialize)]
struct Package {
/// `name@version` (npm) or `name==version` (uv).
package: String,
#[serde(default)]
args: Vec<String>,
#[serde(default)]
env: BTreeMap<String, String>,
}
fn parse(text: &str) -> IdeResult<Vec<Entry>> {
#[derive(Deserialize)]
struct Index {
agents: Vec<serde_json::Value>,
}
let index: Index = serde_json::from_str(text)
.map_err(|e| IdeError::new(ErrorKind::Protocol, format!("the ACP Registry index is invalid: {e}")))?;
// One malformed entry does not hide the others; ids and versions name directories.
Ok(index
.agents
.into_iter()
.filter_map(|v| serde_json::from_value::<Entry>(v).ok())
.filter(|e| safe_segment(&e.id) && safe_segment(&e.version))
.map(|mut e| {
e.id = canonical_id(&e.id).to_owned();
if let Some(name) = provider::builtin_name(&e.id) {
e.name = name.to_owned();
}
e
})
.collect())
}
fn safe_segment(s: &str) -> bool {
!s.is_empty()
&& !s.starts_with('.')
&& s.len() <= 128
&& s.chars().all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '-' | '+'))
}
/// How an entry installs on `platform`: its binary for this OS/arch first, then npm
/// (needs `npm` and `node`), then uv.
enum Choice<'a> {
Binary(&'a Binary),
Npx(&'a Package),
Uvx(&'a Package),
}
impl Choice<'_> {
fn kind(&self) -> RegistryDistribution {
match self {
Choice::Binary(_) => RegistryDistribution::Binary,
Choice::Npx(_) => RegistryDistribution::Npx,
Choice::Uvx(_) => RegistryDistribution::Uvx,
}
}
}
fn platform_key(p: Platform) -> String {
let os = match p.os {
Os::Mac => "darwin",
Os::Linux => "linux",
Os::Windows => "windows",
};
let arch = match p.arch {
Arch::Arm64 => "aarch64",
Arch::X64 => "x86_64",
};
format!("{os}-{arch}")
}
fn choose<'a>(entry: &'a Entry, platform: Platform, on_path: &dyn Fn(&str) -> bool) -> Option<Choice<'a>> {
let d = &entry.distribution;
if let Some(b) = d.binary.get(&platform_key(platform)) {
return Some(Choice::Binary(b));
}
if let Some(p) = &d.npx
&& on_path("npm")
&& on_path("node")
{
return Some(Choice::Npx(p));
}
if let Some(p) = &d.uvx
&& on_path("uv")
{
return Some(Choice::Uvx(p));
}
// Last resort: npx and uvx run natively, this one under OS emulation.
if platform.os == Os::Windows
&& platform.arch == Arch::Arm64
&& let Some(b) = d.binary.get("windows-x86_64")
{
return Some(Choice::Binary(b));
}
None
}
/// Registry ids that map to a shorter built-in provider id (`<config>/agents/<id>.toml`).
const BUILTIN_ALIASES: &[(&str, &str)] =
&[("github-copilot-cli", "copilot"), ("qwen-code", "qwen"), ("mistral-vibe", "vibe")];
fn canonical_id(id: &str) -> &str {
BUILTIN_ALIASES.iter().find_map(|&(alias, canonical)| (alias == id).then_some(canonical)).unwrap_or(id)
}
fn is_builtin(id: &str) -> bool {
provider::is_builtin(canonical_id(id))
}
fn on_path(command: &str) -> bool {
which::which(command).is_ok()
}
/// `1.10.0` > `1.9.2`; a release is newer than its pre-releases.
fn is_newer(latest: &str, current: &str) -> bool {
fn parts(v: &str) -> Vec<&str> {
v.trim_start_matches('v').split(['.', '-', '+']).collect()
}
let (a, b) = (parts(latest), parts(current));
for (x, y) in a.iter().zip(&b) {
let ord = match (x.parse::<u64>(), y.parse::<u64>()) {
(Ok(x), Ok(y)) => x.cmp(&y),
(Ok(_), Err(_)) => Ordering::Greater,
(Err(_), Ok(_)) => Ordering::Less,
(Err(_), Err(_)) => x.cmp(y),
};
if ord != Ordering::Equal {
return ord == Ordering::Greater;
}
}
// `1.0.0` vs `1.0.0-beta`: the shorter is the release.
a.len() < b.len()
}
// ---- listing ----
/// Registry agents this machine can run, except built-in ones and ids the user's own
/// agent files take. Falls back to the saved index when the registry is unreachable.
pub fn list(paths: &RegistryPaths, cancel: &CancellationToken) -> IdeResult<AgentRegistry> {
let (text, warning) = match fetch(paths, cancel) {
Ok(text) => (text, None),
Err(e) if e.kind == ErrorKind::Cancelled => return Err(e),
Err(e) => match fs::read_to_string(&paths.cache_file) {
Ok(text) => (
text,
Some(format!("Could not reach the ACP Registry ({}); showing the list saved earlier.", e.message)),
),
Err(_) => return Err(e),
},
};
let agents = visible(paths, &parse(&text)?, Platform::current(), &on_path);
Ok(AgentRegistry { agents, warning })
}
fn visible(
paths: &RegistryPaths,
entries: &[Entry],
platform: Platform,
on_path: &dyn Fn(&str) -> bool,
) -> Vec<RegistryAgent> {
let mut agents: Vec<RegistryAgent> = entries
.iter()
.filter(|e| !is_builtin(&e.id))
.filter_map(|e| {
let file = paths.agent_file(&e.id);
let installed = if file.exists() {
// A file of the user's own (or one that does not parse) keeps its id.
Some(CustomAgent::read(&file).ok()?.registry?.version)
} else {
None
};
let choice = choose(e, platform, on_path);
let no_checksum = matches!(choice, Some(Choice::Binary(b)) if b.sha256.is_none());
let distribution = choice.map(|c| c.kind());
if distribution.is_none() && installed.is_none() {
return None;
}
Some(RegistryAgent {
update: installed.as_deref().is_some_and(|v| is_newer(&e.version, v)),
id: e.id.clone(),
name: e.name.clone(),
version: e.version.clone(),
description: e.description.clone(),
repository: e.repository.clone(),
website: e.website.clone(),
authors: e.authors.clone(),
license: e.license.clone(),
distribution,
no_checksum,
installed,
installing: false,
})
})
.collect();
agents.sort_by_key(|a| a.name.to_lowercase());
agents
}
/// Fills in [`AgentSource::Registry::update`] from the saved index.
pub fn mark_updates(paths: &RegistryPaths, providers: &mut [AgentProvider]) {
let Some(entries) = fs::read_to_string(&paths.cache_file).ok().and_then(|t| parse(&t).ok()) else { return };
for p in providers {
if let AgentSource::Registry { version, update } = &mut p.source
&& let Some(e) = entries.iter().find(|e| e.id == p.id)
&& is_newer(&e.version, version)
{
*update = Some(e.version.clone());
}
}
}
/// HTTPS only, redirects included. Tests serve downloads over `file://`.
fn http() -> IdeResult<Http> {
let http = Http::new(&ide_core::tool_path::search_path())?;
Ok(if cfg!(test) { http } else { http.https_only() })
}
fn check_url(url: &str) -> IdeResult<()> {
if url.starts_with("https://") || (cfg!(test) && url.starts_with("file://")) {
Ok(())
} else {
Err(IdeError::new(ErrorKind::Protocol, format!("refusing to download {url}: only HTTPS downloads are allowed")))
}
}
/// An npm / uv package spec from the registry, which must not read as an option.
fn check_spec(spec: &str) -> IdeResult<()> {
if spec.is_empty() || spec.starts_with('-') || spec.chars().any(char::is_whitespace) {
Err(IdeError::new(ErrorKind::Protocol, format!("unsupported package `{spec}`")))
} else {
Ok(())
}
}
fn fetch(paths: &RegistryPaths, cancel: &CancellationToken) -> IdeResult<String> {
let http = http()?;
let text = http.get_text_limited(REGISTRY_URL, Some(MAX_REGISTRY_BYTES), cancel)?;
parse(&text)?;
if let Err(e) = write_atomic(&paths.cache_file, text.as_bytes()) {
tracing::warn!("cannot save the ACP Registry index: {e}");
}
Ok(text)
}
fn write_atomic(path: &Path, bytes: &[u8]) -> std::io::Result<()> {
if let Some(dir) = path.parent() {
fs::create_dir_all(dir)?;
}
let tmp = path.with_extension("tmp");
fs::write(&tmp, bytes)?;
fs::rename(&tmp, path)
}
// ---- install / remove ----
/// Installs (or updates to) the registry's current version of `id`, streaming progress
/// through `emit`, and points the agent file at it.
pub fn install(paths: &RegistryPaths, id: &str, cancel: &CancellationToken, emit: Emit) -> IdeResult<()> {
let id = canonical_id(id);
// The saved index is what the user picked from; fetch only when there is none.
let text = match fs::read_to_string(&paths.cache_file) {
Ok(text) => text,
Err(_) => fetch(paths, cancel)?,
};
let entries = parse(&text)?;
let entry = entries
.iter()
.find(|e| e.id == id)
.ok_or_else(|| IdeError::not_found(format!("{id} is not in the ACP Registry")))?;
install_entry(paths, entry, Platform::current(), &on_path, cancel, emit)
}
fn install_entry(
paths: &RegistryPaths,
entry: &Entry,
platform: Platform,
on_path: &dyn Fn(&str) -> bool,
cancel: &CancellationToken,
emit: Emit,
) -> IdeResult<()> {
if is_builtin(&entry.id) {
return Err(IdeError::invalid(format!("{} is built in", entry.name)));
}
let file = paths.agent_file(&entry.id);
if file.exists() && CustomAgent::read(&file).ok().and_then(|a| a.registry).is_none() {
return Err(IdeError::new(
ErrorKind::AlreadyExists,
format!("{} already defines an agent with this id", file.display()),
));
}
let choice = choose(entry, platform, on_path).ok_or_else(|| {
IdeError::unavailable(format!("{} has no build for this system (or needs Node.js or uv)", entry.name))
})?;
let dir = paths.tools_dir.join(&entry.id).join(&entry.version);
if dir.exists() {
fs::remove_dir_all(&dir)?;
}
fs::create_dir_all(&dir)?;
let installed = match &choice {
Choice::Binary(b) => install_binary(b, &dir, cancel, emit).map(|exe| (exe, b.args.clone(), b.env.clone())),
Choice::Npx(p) => {
install_npm(p, entry, &dir, platform, cancel, emit).map(|exe| (exe, p.args.clone(), p.env.clone()))
}
Choice::Uvx(p) => {
install_uv(p, entry, &dir, platform, cancel, emit).map(|exe| (exe, p.args.clone(), p.env.clone()))
}
};
let (exe, args, env) = match installed {
Ok(x) => x,
Err(e) => {
let _ = fs::remove_dir_all(&dir);
return Err(e);
}
};
let agent = CustomAgent {
name: Some(entry.name.clone()),
driver: Driver::Acp,
command: exe.to_string_lossy().into_owned(),
args,
env,
registry: Some(RegistryRef { id: entry.id.clone(), version: entry.version.clone() }),
};
agent.write(&file)?;
emit(InstallEvent::Line { text: format!("{} {} installed", entry.name, entry.version) });
Ok(())
}
pub(crate) fn verify_sha256(file: &Path, expected: &str) -> IdeResult<()> {
let actual = ide_tools::sources::sha256_file(file)?;
if actual.eq_ignore_ascii_case(expected.trim()) {
Ok(())
} else {
Err(IdeError::new(ErrorKind::Io, format!("checksum mismatch: expected SHA-256 {expected}, got {actual}")))
}
}
/// The segments of an archive's `cmd` (`./bin/agent`, `./dist\agent.cmd`), refusing
/// paths that leave the archive.
fn command_path(cmd: &str) -> IdeResult<Vec<String>> {
let normalized = cmd.replace('\\', "/");
let bad = || IdeError::new(ErrorKind::Protocol, format!("unsupported command path `{cmd}`"));
if normalized.starts_with('/') || normalized.contains(':') {
return Err(bad());
}
let parts: Vec<String> = normalized.split('/').filter(|p| !p.is_empty() && *p != ".").map(str::to_owned).collect();
if parts.is_empty() || parts.iter().any(|p| p == "..") {
return Err(bad());
}
Ok(parts)
}
fn install_binary(b: &Binary, dir: &Path, cancel: &CancellationToken, emit: Emit) -> IdeResult<PathBuf> {
let segments = command_path(&b.cmd)?;
check_url(&b.archive)?;
let url_path = b.archive.split(['?', '#']).next().unwrap_or_default();
let name = url_path
.rsplit('/')
.next()
.filter(|n| !n.is_empty() && *n != "." && *n != ".." && !n.contains('\\'))
.unwrap_or("download");
let downloads = dir.join(".download");
fs::create_dir_all(&downloads)?;
let file = downloads.join(name);
emit(InstallEvent::Line { text: format!("Downloading {}", b.archive) });
http()?.download(&b.archive, &file, None, cancel, emit)?;
match &b.sha256 {
Some(expected) => {
verify_sha256(&file, expected)?;
emit(InstallEvent::Line { text: "SHA-256 checksum verified".into() });
}
None => emit(InstallEvent::Line { text: "The registry gives no checksum for this download".into() }),
}
if cancel.is_cancelled() {
return Err(IdeError::cancelled());
}
emit(InstallEvent::Line { text: "Extracting…".into() });
let kind = ArchiveKind::from_name(name);
let (exe_name, parents) = segments.split_last().expect("command_path is never empty");
let dest = match kind {
// A bare executable goes where `cmd` expects it.
ArchiveKind::Raw | ArchiveKind::Gz => parents.iter().fold(dir.to_path_buf(), |p, s| p.join(s)),
_ => dir.to_path_buf(),
};
archive::extract(&file, kind, &dest, exe_name, cancel)?;
let _ = fs::remove_dir_all(&downloads);
let exe = segments.iter().fold(dir.to_path_buf(), |p, s| p.join(s));
if !exe.is_file() {
return Err(IdeError::new(ErrorKind::Protocol, format!("the download has no {}", b.cmd)));
}
archive::make_executable(&exe)?;
Ok(exe)
}
/// `@scope/[email protected]` → (`@scope/name`, `1.2.3`).
fn split_npm_spec(spec: &str) -> (&str, Option<&str>) {
let start = usize::from(spec.starts_with('@'));
match spec[start..].find('@') {
Some(i) => (&spec[..start + i], Some(&spec[start + i + 1..])),
None => (spec, None),
}
}
/// The command of an npm package: its only one, else the one named after the package
/// or the agent.
fn npm_bin(manifest: &serde_json::Value, name: &str, agent_id: &str) -> Option<String> {
let base = name.rsplit('/').next().unwrap_or(name);
match manifest.get("bin")? {
serde_json::Value::String(_) => Some(base.to_owned()),
serde_json::Value::Object(bins) if bins.len() == 1 => bins.keys().next().cloned(),
serde_json::Value::Object(bins) => {
[base, agent_id].into_iter().find(|k| bins.contains_key(*k)).map(str::to_owned)
}
_ => None,
}
}
fn install_npm(
p: &Package,
entry: &Entry,
dir: &Path,
platform: Platform,
cancel: &CancellationToken,
emit: Emit,
) -> IdeResult<PathBuf> {
let (name, version) = split_npm_spec(&p.package);
let spec = match version {
Some(v) if !v.is_empty() => p.package.clone(),
_ => format!("{name}@{}", entry.version),
};
check_spec(&spec)?;
let npm = which::which("npm").map_err(|_| IdeError::unavailable(format!("npm is required to install {name}")))?;
let mut cmd = Command::new(npm);
cmd.arg("install").arg("--prefix").arg(dir).args(["--no-audit", "--no-fund"]).arg(&spec).current_dir(dir);
ide_tools::run_streamed(cmd, cancel, emit)?;
let modules = dir.join("node_modules");
let manifest: serde_json::Value = fs::read_to_string(modules.join(name).join("package.json"))
.ok()
.and_then(|t| serde_json::from_str(&t).ok())
.ok_or_else(|| IdeError::new(ErrorKind::Protocol, format!("{spec} was not installed")))?;
let bin = npm_bin(&manifest, name, &entry.id)
.ok_or_else(|| IdeError::new(ErrorKind::Protocol, format!("{spec} has no command to run")))?;
let file = if platform.os == Os::Windows { format!("{bin}.cmd") } else { bin };
let exe = modules.join(".bin").join(file);
if !exe.exists() {
return Err(IdeError::new(ErrorKind::Protocol, format!("{spec} did not install {}", exe.display())));
}
Ok(exe)
}
/// `name[extra]==1.2` → (`name`, `1.2`).
fn split_uv_spec(spec: &str) -> (&str, Option<&str>) {
let (name, version) = match spec.split_once("==") {
Some((n, v)) => (n, Some(v.trim())),
None => (spec, None),
};
(name.split('[').next().unwrap_or(name).trim(), version)
}
fn install_uv(
p: &Package,
entry: &Entry,
dir: &Path,
platform: Platform,
cancel: &CancellationToken,
emit: Emit,
) -> IdeResult<PathBuf> {
let (name, version) = split_uv_spec(&p.package);
let spec = match version {
Some(v) if !v.is_empty() => p.package.clone(),
_ => format!("{name}=={}", entry.version),
};
check_spec(&spec)?;
let uv = which::which("uv").map_err(|_| IdeError::unavailable(format!("uv is required to install {name}")))?;
let bin = dir.join("bin");
let mut cmd = Command::new(uv);
cmd.args(["tool", "install", "--force"])
.arg(&spec)
.env("UV_TOOL_DIR", dir.join("tools"))
.env("UV_TOOL_BIN_DIR", &bin)
.current_dir(dir);
ide_tools::run_streamed(cmd, cancel, emit)?;
let exe = [name, entry.id.as_str()].iter().map(|n| bin.join(platform.exe(n))).find(|p| p.is_file()).or_else(|| {
let files: Vec<PathBuf> = fs::read_dir(&bin).ok()?.flatten().map(|e| e.path()).collect();
(files.len() == 1).then(|| files[0].clone())
});
exe.ok_or_else(|| IdeError::new(ErrorKind::Protocol, format!("{spec} installed no command named {name}")))
}
/// Deletes a registry agent's file and installed files.
pub fn remove(paths: &RegistryPaths, id: &str) -> IdeResult<()> {
let id = canonical_id(id);
if !safe_segment(id) {
return Err(IdeError::invalid(format!("invalid agent id {id}")));
}
let file = paths.agent_file(id);
let agent = CustomAgent::read(&file).map_err(|_| IdeError::not_found(format!("{id} is not installed")))?;
if agent.registry.is_none() {
return Err(IdeError::invalid(format!("{id} was not installed from the ACP Registry")));
}
fs::remove_file(&file)?;
// What a running agent still holds open (Windows) goes at the next start (`maintain`).
let _ = fs::remove_dir_all(paths.tools_dir.join(id));
Ok(())
}
/// Once per process, on its own thread: removes installed versions no agent file uses
/// (left by updates and removals) and refreshes a saved index older than a day.
pub fn maintain(paths: RegistryPaths) {
static ONCE: std::sync::Once = std::sync::Once::new();
ONCE.call_once(|| {
let spawned = std::thread::Builder::new().name("agent-registry".into()).spawn(move || {
prune(&paths);
let stale = fs::metadata(&paths.cache_file)
.and_then(|m| m.modified())
.ok()
.and_then(|t| t.elapsed().ok())
.is_none_or(|age| age > REFRESH_AFTER);
// Only for agents installed from the registry: nobody else needs the index.
if stale
&& has_registry_agents(&paths)
&& let Err(e) = fetch(&paths, &CancellationToken::new())
{
tracing::debug!("ACP Registry not refreshed: {e}");
}
});
if let Err(e) = spawned {
tracing::warn!("cannot start the ACP Registry maintenance: {e}");
}
});
}
fn has_registry_agents(paths: &RegistryPaths) -> bool {
fs::read_dir(&paths.tools_dir).is_ok_and(|mut d| d.next().is_some())
}
fn prune(paths: &RegistryPaths) {
let Ok(ids) = fs::read_dir(&paths.tools_dir) else { return };
for id_dir in ids.flatten() {
let id = id_dir.file_name().to_string_lossy().into_owned();
let file = paths.agent_file(&id);
let keep = if file.exists() {
// An agent file that does not parse keeps everything until it is fixed.
match CustomAgent::read(&file) {
Ok(agent) => agent.registry.map(|r| r.version),
Err(_) => continue,
}
} else {
None
};
let Some(keep) = keep else {
let _ = fs::remove_dir_all(id_dir.path());
continue;
};
for version in fs::read_dir(id_dir.path()).into_iter().flatten().flatten() {
if version.file_name().to_string_lossy() != keep {
let _ = fs::remove_dir_all(version.path());
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
const MAC_ARM: Platform = Platform { os: Os::Mac, arch: Arch::Arm64 };
const LINUX: Platform = Platform { os: Os::Linux, arch: Arch::X64 };
const WINDOWS: Platform = Platform { os: Os::Windows, arch: Arch::X64 };
#[test]
fn windows_arm_binary_prefers_native_then_x64() {
let mut entry = parse(INDEX).unwrap().remove(0);
let arm = Platform { os: Os::Windows, arch: Arch::Arm64 };
assert!(matches!(choose(&entry, arm, &|_| false), Some(Choice::Binary(_))));
assert!(choose(&entry, Platform { os: Os::Linux, arch: Arch::Arm64 }, &|_| false).is_none());
entry.distribution.npx = Some(serde_json::from_str(r#"{"package":"amp-acp"}"#).unwrap());
assert!(matches!(choose(&entry, arm, &|_| true), Some(Choice::Npx(_))));
let mut native = entry.distribution.binary["windows-x86_64"].clone();
native.cmd = "native.exe".into();
entry.distribution.binary.insert("windows-aarch64".into(), native);
assert!(matches!(choose(&entry, arm, &|_| false), Some(Choice::Binary(b)) if b.cmd == "native.exe"));
}
const INDEX: &str = r#"{"version":"1.0.0","agents":[
{"id":"amp-acp","name":"Amp","version":"0.10.0","description":"Amp over ACP",
"repository":"https://github.com/x/amp-acp","authors":["x"],"license":"Apache-2.0",
"icon":"https://cdn/amp.svg",
"distribution":{"binary":{
"darwin-aarch64":{"archive":"https://x/amp-darwin-aarch64.tar.gz","cmd":"./amp-acp","sha256":"ab"},
"windows-x86_64":{"archive":"https://x/amp.zip","cmd":"amp-acp.exe"}}}},
{"id":"cline","name":"Cline","version":"3.0.74","description":"",
"distribution":{"npx":{"package":"[email protected]","args":["--acp"]}}},
{"id":"fast-agent","name":"fast-agent","version":"0.10.1",
"distribution":{"uvx":{"package":"fast-agent-acp==0.10.1","args":["-x"],"env":{"K":"v"}}}},
{"id":"claude-acp","name":"Claude Agent","version":"0.98.0",
"distribution":{"npx":{"package":"@agentclientprotocol/[email protected]"}}},
{"id":"../evil","name":"Evil","version":"1","distribution":{}},
{"id":"broken","name":"Broken"}
],"extensions":[]}"#;
fn paths(root: &Path) -> RegistryPaths {
RegistryPaths::new(&root.join("config"), &root.join("data"))
}
#[test]
fn parses_the_index_and_skips_bad_entries() {
let entries = parse(INDEX).unwrap();
let ids: Vec<&str> = entries.iter().map(|e| e.id.as_str()).collect();
assert_eq!(ids, ["amp-acp", "cline", "fast-agent", "claude-acp"]);
let amp = &entries[0];
assert_eq!(amp.distribution.binary["darwin-aarch64"].sha256.as_deref(), Some("ab"));
assert_eq!(entries[2].distribution.uvx.as_ref().unwrap().env["K"], "v");
assert!(parse("{\"agents\": 3}").is_err());
}
#[test]
fn shows_what_this_platform_can_run() {
let dir = tempfile::tempdir().unwrap();
let entries = parse(INDEX).unwrap();
let ids = |platform, on_path: &dyn Fn(&str) -> bool| -> Vec<(String, Option<RegistryDistribution>)> {
visible(&paths(dir.path()), &entries, platform, on_path)
.into_iter()
.map(|a| (a.id, a.distribution))
.collect()
};
let none = |_: &str| false;
let all = |_: &str| true;
// Built-in agents (claude-acp) never show.
assert_eq!(ids(MAC_ARM, &none), [("amp-acp".to_owned(), Some(RegistryDistribution::Binary))]);
assert!(ids(LINUX, &none).is_empty());
let node_only = |c: &str| c == "npm" || c == "node";
assert_eq!(ids(LINUX, &node_only), [("cline".to_owned(), Some(RegistryDistribution::Npx))]);
assert_eq!(
ids(LINUX, &all),
[
("cline".to_owned(), Some(RegistryDistribution::Npx)),
("fast-agent".to_owned(), Some(RegistryDistribution::Uvx))
]
);
assert_eq!(platform_key(WINDOWS), "windows-x86_64");
assert_eq!(platform_key(MAC_ARM), "darwin-aarch64");
}
#[test]
fn flags_release_archives_without_a_checksum() {
let dir = tempfile::tempdir().unwrap();
let entries = parse(INDEX).unwrap();
let flags = |platform| -> Vec<(String, bool)> {
visible(&paths(dir.path()), &entries, platform, &|_| true)
.into_iter()
.map(|a| (a.id, a.no_checksum))
.collect()
};
assert_eq!(flags(MAC_ARM)[0], ("amp-acp".to_owned(), false));
assert_eq!(flags(WINDOWS)[0], ("amp-acp".to_owned(), true));
// npm and uv check their own packages.
assert!(flags(LINUX).iter().all(|(_, no_checksum)| !no_checksum));
}
#[test]
fn installed_versions_updates_and_the_users_own_agents() {
let dir = tempfile::tempdir().unwrap();
let p = paths(dir.path());
let entries = parse(INDEX).unwrap();
let installed = |id: &str, version: &str| CustomAgent {
name: None,
driver: Driver::Acp,
command: "/x".into(),
args: vec![],
env: BTreeMap::new(),
registry: Some(RegistryRef { id: id.into(), version: version.into() }),
};
installed("amp-acp", "0.9.0").write(&p.agent_file("amp-acp")).unwrap();
// The user's own `cline.toml` hides the registry's cline.
fs::write(p.agent_file("cline"), "command = \"cline\"\n").unwrap();
let shown = visible(&p, &entries, LINUX, &|_| true);
let amp = shown.iter().find(|a| a.id == "amp-acp").unwrap();
// Installed agents stay listed (to remove them) even without a build for this system.
assert_eq!((amp.installed.as_deref(), amp.update, amp.distribution), (Some("0.9.0"), true, None));
assert!(!shown.iter().any(|a| a.id == "cline"));
write_atomic(&p.cache_file, INDEX.as_bytes()).unwrap();
let mut providers: Vec<AgentProvider> = provider::load(Some(&p.agents_dir))
.into_iter()
.filter(|s| s.id == "amp-acp")
.map(|s| provider::describe(&s))
.collect();
mark_updates(&p, &mut providers);
assert_eq!(
providers[0].source,
AgentSource::Registry { version: "0.9.0".into(), update: Some("0.10.0".into()) }
);
}
#[test]
fn version_order() {
assert!(is_newer("0.10.0", "0.9.2"));
assert!(is_newer("1.0.0", "1.0.0-beta.1"));
assert!(!is_newer("1.0.0-beta.1", "1.0.0"));
assert!(!is_newer("2.3.0", "2.3.0"));
assert!(is_newer("2026.10.22", "2026.9.30"));
}
#[test]
fn package_specs_and_commands() {
assert_eq!(split_npm_spec("@scope/[email protected]"), ("@scope/pkg", Some("1.2.3")));
assert_eq!(split_npm_spec("[email protected]"), ("cline", Some("3.0.74")));
assert_eq!(split_npm_spec("@scope/pkg"), ("@scope/pkg", None));
assert_eq!(split_uv_spec("fast-agent-acp[all]==0.10.1"), ("fast-agent-acp", Some("0.10.1")));
let m = |v: &str| serde_json::from_str::<serde_json::Value>(v).unwrap();
assert_eq!(npm_bin(&m(r#"{"bin":"cli.js"}"#), "@google/gemini-cli", "gemini").as_deref(), Some("gemini-cli"));
assert_eq!(npm_bin(&m(r#"{"bin":{"copilot":"a.js"}}"#), "@github/copilot", "x").as_deref(), Some("copilot"));
assert_eq!(npm_bin(&m(r#"{"bin":{"a":"a.js","droid":"d.js"}}"#), "droid", "factory").as_deref(), Some("droid"));
assert_eq!(npm_bin(&m(r#"{"bin":{"a":"a.js","b":"b.js"}}"#), "pkg", "id"), None);
assert_eq!(npm_bin(&m(r#"{}"#), "pkg", "id"), None);
assert_eq!(command_path("./dist-package\\cursor-agent.cmd").unwrap(), ["dist-package", "cursor-agent.cmd"]);
assert_eq!(command_path("harn.exe").unwrap(), ["harn.exe"]);
assert!(command_path("../x").is_err());
assert!(command_path("/usr/bin/x").is_err());
assert!(command_path("C:/x").is_err());
assert!(check_spec("[email protected]").is_ok());
assert!(check_spec("--prefix=/").is_err());
assert!(check_spec("a b").is_err());
assert!(check_url("https://x/a.tar.gz").is_ok());
assert!(check_url("http://x/a.tar.gz").is_err());
assert!(check_url("ftp://x/a").is_err());
}
#[test]
fn checksums_are_verified() {
let dir = tempfile::tempdir().unwrap();
let f = dir.path().join("a");
fs::write(&f, "hello").unwrap();
let sha = "2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824";
verify_sha256(&f, sha).unwrap();
verify_sha256(&f, &sha.to_uppercase()).unwrap();
assert!(verify_sha256(&f, &"0".repeat(64)).is_err());
}
/// A raw executable served over `file://` (curl), installed, updated and removed.
#[cfg(unix)]
#[test]
fn installs_updates_and_removes_a_binary_agent() {
let dir = tempfile::tempdir().unwrap();
let p = paths(dir.path());
let served = dir.path().join("served");
fs::create_dir_all(&served).unwrap();
let exe = served.join("agent-bin");
fs::write(&exe, "#!/bin/sh\necho hi\n").unwrap();
let sha = ide_tools::sources::sha256_file(&exe).unwrap();
let index = |version: &str, sha: &str| {
format!(
r#"{{"agents":[{{"id":"demo","name":"Demo","version":"{version}","distribution":{{"binary":{{
"linux-x86_64":{{"archive":"file://{}","cmd":"./agent-bin","args":["acp"],"env":{{"A":"1"}},"sha256":"{sha}"}}}}}}}}]}}"#,
exe.display()
)
};
let cancel = CancellationToken::new();
let lines = std::sync::Mutex::new(Vec::new());
let emit = |e: InstallEvent| {
if let InstallEvent::Line { text } = e {
lines.lock().unwrap().push(text);
}
};
let entries = parse(&index("1.0.0", &sha)).unwrap();
install_entry(&p, &entries[0], LINUX, &|_| false, &cancel, &emit).unwrap();
assert!(lines.lock().unwrap().iter().any(|l| l == "SHA-256 checksum verified"));
let v1 = p.tools_dir.join("demo/1.0.0/agent-bin");
assert!(v1.is_file());
assert!(!p.tools_dir.join("demo/1.0.0/.download").exists());
let agent = CustomAgent::read(&p.agent_file("demo")).unwrap();
assert_eq!(agent.command, v1.to_string_lossy());
assert_eq!((agent.args.as_slice(), agent.env["A"].as_str()), (&["acp".to_owned()][..], "1"));
assert_eq!(agent.registry, Some(RegistryRef { id: "demo".into(), version: "1.0.0".into() }));
let spec = provider::load(Some(&p.agents_dir)).into_iter().find(|s| s.id == "demo").unwrap();
assert_eq!(spec.source, AgentSource::Registry { version: "1.0.0".into(), update: None });
assert!(provider::installed(&spec.command));
// A bad checksum fails and leaves the installed version in place.
let bad = parse(&index("2.0.0", &"0".repeat(64))).unwrap();
let err = install_entry(&p, &bad[0], LINUX, &|_| false, &cancel, &emit).unwrap_err();
assert!(err.message.contains("checksum mismatch"), "{err:?}");
assert!(!p.tools_dir.join("demo/2.0.0").exists());
assert_eq!(CustomAgent::read(&p.agent_file("demo")).unwrap().registry.unwrap().version, "1.0.0");
// An update goes alongside; the old version goes at the next start.
let v2 = parse(&index("2.0.0", &sha)).unwrap();
install_entry(&p, &v2[0], LINUX, &|_| false, &cancel, &emit).unwrap();
assert!(v1.is_file());
prune(&p);
assert!(!v1.exists());
assert!(p.tools_dir.join("demo/2.0.0/agent-bin").is_file());
remove(&p, "demo").unwrap();
assert!(!p.agent_file("demo").exists());
assert!(!p.tools_dir.join("demo").exists());
assert!(remove(&p, "demo").is_err());
}
#[test]
fn refuses_to_replace_the_users_own_agent() {
let dir = tempfile::tempdir().unwrap();
let p = paths(dir.path());
fs::create_dir_all(&p.agents_dir).unwrap();
fs::write(p.agent_file("amp-acp"), "command = \"amp\"\n").unwrap();
let entries = parse(INDEX).unwrap();
let err = install_entry(&p, &entries[0], MAC_ARM, &|_| false, &CancellationToken::new(), &|_| {}).unwrap_err();
assert_eq!(err.kind, ErrorKind::AlreadyExists);
assert!(remove(&p, "amp-acp").is_err());
assert!(p.agent_file("amp-acp").exists());
}
#[test]
fn maps_github_copilot_cli_to_builtin_copilot_and_tracks_updates() {
let dir = tempfile::tempdir().unwrap();
let p = paths(dir.path());
let index = r#"{"agents":[
{"id":"github-copilot-cli","name":"Copilot","version":"1.0.105",
"description":"GitHub's AI pair programmer",
"distribution":{"npx":{"package":"@github/[email protected]","args":["--acp"]}}}
]}"#;
let entries = parse(index).unwrap();
assert_eq!(entries.len(), 1);
assert_eq!((entries[0].id.as_str(), entries[0].name.as_str()), ("copilot", "GitHub Copilot"));
let shown = visible(&p, &entries, MAC_ARM, &|c| c == "npm" || c == "node");
assert_eq!(shown.len(), 1);
assert_eq!(
(shown[0].id.as_str(), shown[0].name.as_str(), shown[0].installed.as_deref()),
("copilot", "GitHub Copilot", None)
);
CustomAgent {
name: Some("GitHub Copilot".into()),
driver: Driver::Acp,
command: "/data/tools/copilot/1.0.104/node_modules/.bin/copilot".into(),
args: vec!["--acp".into()],
env: BTreeMap::new(),
registry: Some(RegistryRef { id: "copilot".into(), version: "1.0.104".into() }),
}
.write(&p.agent_file("copilot"))
.unwrap();
let shown = visible(&p, &entries, MAC_ARM, &|c| c == "npm" || c == "node");
assert_eq!((shown[0].installed.as_deref(), shown[0].update), (Some("1.0.104"), true));
write_atomic(&p.cache_file, index.as_bytes()).unwrap();
let spec = provider::load(Some(&p.agents_dir)).into_iter().find(|s| s.id == "copilot").unwrap();
assert_eq!(spec.name, "GitHub Copilot");
assert_eq!(spec.login_hint, "Run `copilot` in a terminal and sign in.");
assert_eq!(spec.source, AgentSource::Registry { version: "1.0.104".into(), update: None });
let mut providers = vec![provider::describe(&spec)];
mark_updates(&p, &mut providers);
assert_eq!(
providers[0].source,
AgentSource::Registry { version: "1.0.104".into(), update: Some("1.0.105".into()) }
);
remove(&p, "github-copilot-cli").unwrap();
let fallback = provider::load(Some(&p.agents_dir)).into_iter().find(|s| s.id == "copilot").unwrap();
assert_eq!(fallback.source, AgentSource::Builtin);
}
}