Skip to main content

agentos_client/
software_registry.rs

1//! Registry software resolution (= TS `packages/core/src/software-registry.ts`).
2//!
3//! Each agentOS release publishes its software catalog as digest-addressed
4//! `.aospkg` files next to `<registry>/<version>/software/manifest.json`. A
5//! registry source resolves to that release's URL and exact digest, then
6//! installs through the ordinary URL source, so the sidecar still verifies the
7//! bytes. The manifest is fetched by this host process, not by the guest, so VM
8//! network permissions do not apply to it.
9
10use std::collections::VecDeque;
11use std::sync::Arc;
12use std::time::{Duration, Instant};
13
14use once_cell::sync::Lazy;
15use reqwest::Url;
16use serde::Deserialize;
17
18use crate::error::ClientError;
19use crate::software::PackageSource;
20
21/// Root that holds `<version>/software/manifest.json` for each release.
22pub const DEFAULT_SOFTWARE_REGISTRY_URL: &str = "https://releases.rivet.dev/agent-os/";
23/// Environment variable that overrides the default registry root.
24pub const SOFTWARE_REGISTRY_URL_ENV: &str = "AGENTOS_SOFTWARE_REGISTRY_URL";
25
26const MANIFEST_FETCH_TIMEOUT: Duration = Duration::from_secs(30);
27const MANIFEST_MAX_BYTES: usize = 8 * 1024 * 1024;
28const MANIFEST_CACHE_LIMIT: usize = 32;
29// `latest` moves with every release; exact versions are immutable.
30const LATEST_MANIFEST_TTL: Duration = Duration::from_secs(5 * 60);
31
32/// Registry location and default release for name-based software.
33#[derive(Debug, Clone, Default, PartialEq, Eq)]
34pub struct SoftwareRegistryOptions {
35    /// Root URL that holds `<version>/software/manifest.json`. Defaults to
36    /// `AGENTOS_SOFTWARE_REGISTRY_URL`, then `https://releases.rivet.dev/agent-os/`.
37    pub url: Option<String>,
38    /// Release to resolve sources that name no version. Defaults to this
39    /// crate's version, or `latest` for unreleased workspace and preview builds.
40    pub version: Option<String>,
41}
42
43/// A software source for [`crate::AgentOs::install_software`] and
44/// [`crate::AgentOsConfig::software_sources`].
45#[derive(Debug, Clone, PartialEq, Eq)]
46pub enum SoftwareSource {
47    /// A URL or trusted local path.
48    Package(PackageSource),
49    /// A package in an agentOS release manifest, resolved on this host to a
50    /// digest-pinned URL source.
51    Registry {
52        name: String,
53        version: Option<String>,
54    },
55}
56
57impl From<PackageSource> for SoftwareSource {
58    fn from(source: PackageSource) -> Self {
59        SoftwareSource::Package(source)
60    }
61}
62
63/// A registry package resolved to its exact artifact.
64#[derive(Debug, Clone, PartialEq, Eq)]
65pub struct ResolvedRegistrySoftware {
66    pub name: String,
67    /// Release whose manifest listed the package, such as `0.4.2` or `latest`.
68    pub version: String,
69    /// Absolute `.aospkg` URL.
70    pub url: String,
71    /// Exact `sha256:<64 lowercase hex>` content digest.
72    pub digest: String,
73    pub size_bytes: u64,
74    pub manifest_url: String,
75}
76
77#[derive(Debug, Clone, Deserialize)]
78struct ManifestArtifact {
79    name: String,
80    digest: String,
81    size: u64,
82    path: String,
83}
84
85#[derive(Deserialize)]
86#[serde(rename_all = "camelCase")]
87struct Manifest {
88    schema_version: serde_json::Value,
89    artifacts: Vec<serde_json::Value>,
90}
91
92struct CachedManifest {
93    manifest_url: String,
94    expires_at: Option<Instant>,
95    artifacts: Arc<Vec<ManifestArtifact>>,
96}
97
98// Process-wide like the TS module cache, bounded by MANIFEST_CACHE_LIMIT
99// distinct registry/version pairs. The guard is never held across an await.
100static MANIFEST_CACHE: Lazy<parking_lot::Mutex<VecDeque<CachedManifest>>> =
101    Lazy::new(|| parking_lot::Mutex::new(VecDeque::new()));
102
103/// The release a registry source resolves against when it names no version.
104///
105/// A published client matches its own release so a given agentOS version gets
106/// the software built with it. The committed workspace version `0.0.1` and
107/// `0.0.0-*` preview builds never publish software, so they use `latest`.
108pub fn default_registry_version(version: &str) -> String {
109    if version == "0.0.1" || version == "0.0.0" || version.starts_with("0.0.0-") {
110        return String::from("latest");
111    }
112    version.to_string()
113}
114
115fn registry_root(options: Option<&SoftwareRegistryOptions>) -> Result<Url, ClientError> {
116    let raw = options
117        .and_then(|options| options.url.clone())
118        .or_else(|| std::env::var(SOFTWARE_REGISTRY_URL_ENV).ok())
119        .unwrap_or_else(|| DEFAULT_SOFTWARE_REGISTRY_URL.to_string());
120    let normalized = if raw.ends_with('/') {
121        raw.clone()
122    } else {
123        format!("{raw}/")
124    };
125    let root = Url::parse(&normalized).map_err(|_| {
126        ClientError::InvalidConfig(format!(
127            "invalid agentOS software registry URL {raw:?}; pass an absolute http(s) URL in software_registry.url or {SOFTWARE_REGISTRY_URL_ENV}"
128        ))
129    })?;
130    ensure_secure_registry_url(&root, "registry URL")?;
131    Ok(root)
132}
133
134// The manifest supplies the digest every package is verified against, so it
135// needs the same transport integrity as the packages: HTTPS, or plain HTTP only
136// to a loopback mirror.
137fn ensure_secure_registry_url(url: &Url, label: &str) -> Result<(), ClientError> {
138    let loopback = url.host_str().is_some_and(|host| {
139        host == "localhost"
140            || host
141                .trim_start_matches('[')
142                .trim_end_matches(']')
143                .parse::<std::net::IpAddr>()
144                .is_ok_and(|ip| ip.is_loopback())
145    });
146    if url.scheme() == "https" || (url.scheme() == "http" && loopback) {
147        return Ok(());
148    }
149    Err(ClientError::InvalidConfig(format!(
150        "agentOS software {label} must use https, or plain http only on a loopback address, got {url}"
151    )))
152}
153
154fn validate_name(name: &str) -> Result<(), ClientError> {
155    let mut chars = name.chars();
156    let valid = matches!(chars.next(), Some(c) if c.is_ascii_lowercase() || c.is_ascii_digit())
157        && chars.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-');
158    if !valid {
159        return Err(ClientError::InvalidPackageSource(format!(
160            "invalid agentOS registry package name {name:?}; names are lowercase letters, digits, and hyphens, such as \"git\""
161        )));
162    }
163    Ok(())
164}
165
166fn validate_version(version: &str) -> Result<(), ClientError> {
167    let valid = version == "latest" || {
168        let (core, prerelease) = match version.split_once('-') {
169            Some((core, prerelease)) => (core, Some(prerelease)),
170            None => (version, None),
171        };
172        let parts: Vec<&str> = core.split('.').collect();
173        parts.len() == 3
174            && parts
175                .iter()
176                .all(|part| !part.is_empty() && part.chars().all(|c| c.is_ascii_digit()))
177            && prerelease.is_none_or(|prerelease| {
178                !prerelease.is_empty()
179                    && prerelease
180                        .chars()
181                        .all(|c| c.is_ascii_alphanumeric() || c == '.' || c == '-')
182            })
183    };
184    if !valid {
185        return Err(ClientError::InvalidPackageSource(format!(
186            "invalid agentOS registry version {version:?}; pass a release version such as \"0.4.2\", or \"latest\""
187        )));
188    }
189    Ok(())
190}
191
192fn is_digest(value: &str) -> bool {
193    value.strip_prefix("sha256:").is_some_and(|hex| {
194        hex.len() == 64 && hex.chars().all(|c| matches!(c, '0'..='9' | 'a'..='f'))
195    })
196}
197
198fn parse_manifest(bytes: &[u8], manifest_url: &str) -> Result<Vec<ManifestArtifact>, ClientError> {
199    let manifest: Manifest = serde_json::from_slice(bytes).map_err(|error| {
200        ClientError::PackageDownload(format!(
201            "agentOS software manifest {manifest_url} is not a schemaVersion 1 manifest with an artifacts list: {error}"
202        ))
203    })?;
204    if manifest.schema_version != serde_json::json!(1) {
205        return Err(ClientError::PackageDownload(format!(
206            "agentOS software manifest {manifest_url} is not a schemaVersion 1 manifest with an artifacts list"
207        )));
208    }
209    manifest
210        .artifacts
211        .into_iter()
212        .enumerate()
213        .map(|(index, value)| {
214            serde_json::from_value::<ManifestArtifact>(value)
215                .ok()
216                .filter(|artifact| is_digest(&artifact.digest))
217                .ok_or_else(|| {
218                    ClientError::PackageDownload(format!(
219                        "agentOS software manifest {manifest_url} has a malformed artifact at index {index}"
220                    ))
221                })
222        })
223        .collect()
224}
225
226async fn fetch_manifest(
227    manifest_url: &str,
228    version: &str,
229) -> Result<Vec<ManifestArtifact>, ClientError> {
230    let unreachable = |error: &dyn std::fmt::Display| {
231        ClientError::PackageDownload(format!(
232            "failed to fetch agentOS software manifest {manifest_url}: {error}; check host network access or set software_registry.url / {SOFTWARE_REGISTRY_URL_ENV} to a reachable mirror"
233        ))
234    };
235    let client = reqwest::Client::builder()
236        .timeout(MANIFEST_FETCH_TIMEOUT)
237        .build()
238        .map_err(|error| unreachable(&error))?;
239    let mut response = client
240        .get(manifest_url)
241        .send()
242        .await
243        .map_err(|error| unreachable(&error))?;
244    ensure_secure_registry_url(response.url(), "manifest redirect")?;
245    let status = response.status();
246    if status == reqwest::StatusCode::NOT_FOUND {
247        return Err(ClientError::PackageDownload(format!(
248            "agentOS software registry has no release \"{version}\": GET {manifest_url} returned 404; pass a published agentOS version or \"latest\""
249        )));
250    }
251    if !status.is_success() {
252        return Err(ClientError::PackageDownload(
253            format!(
254                "failed to fetch agentOS software manifest {manifest_url}: HTTP {} {}",
255                status.as_u16(),
256                status.canonical_reason().unwrap_or_default()
257            )
258            .trim_end()
259            .to_string(),
260        ));
261    }
262    if response
263        .content_length()
264        .is_some_and(|length| length > MANIFEST_MAX_BYTES as u64)
265    {
266        return Err(ClientError::PackageDownload(format!(
267            "agentOS software manifest {manifest_url} exceeds the {MANIFEST_MAX_BYTES}-byte limit"
268        )));
269    }
270    let mut body = Vec::new();
271    while let Some(chunk) = response
272        .chunk()
273        .await
274        .map_err(|error| unreachable(&error))?
275    {
276        if body.len() + chunk.len() > MANIFEST_MAX_BYTES {
277            return Err(ClientError::PackageDownload(format!(
278                "agentOS software manifest {manifest_url} exceeds the {MANIFEST_MAX_BYTES}-byte limit"
279            )));
280        }
281        body.extend_from_slice(&chunk);
282    }
283    parse_manifest(&body, manifest_url)
284}
285
286async fn load_manifest(
287    manifest_url: &str,
288    version: &str,
289) -> Result<Arc<Vec<ManifestArtifact>>, ClientError> {
290    {
291        let mut cache = MANIFEST_CACHE.lock();
292        let now = Instant::now();
293        if let Some(index) = cache
294            .iter()
295            .position(|entry| entry.manifest_url == manifest_url)
296        {
297            if cache[index]
298                .expires_at
299                .is_none_or(|expires_at| expires_at > now)
300            {
301                return Ok(cache[index].artifacts.clone());
302            }
303            cache.remove(index);
304        }
305    }
306    // Failed fetches are never cached, so the next call retries.
307    let artifacts = Arc::new(fetch_manifest(manifest_url, version).await?);
308    let mut cache = MANIFEST_CACHE.lock();
309    cache.retain(|entry| entry.manifest_url != manifest_url);
310    while cache.len() >= MANIFEST_CACHE_LIMIT {
311        cache.pop_front();
312    }
313    cache.push_back(CachedManifest {
314        manifest_url: manifest_url.to_string(),
315        expires_at: if version == "latest" {
316            Some(Instant::now() + LATEST_MANIFEST_TTL)
317        } else {
318            None
319        },
320        artifacts: artifacts.clone(),
321    });
322    Ok(artifacts)
323}
324
325fn edit_distance(left: &str, right: &str) -> usize {
326    let right: Vec<char> = right.chars().collect();
327    let mut previous: Vec<usize> = (0..=right.len()).collect();
328    for (i, left_char) in left.chars().enumerate() {
329        let mut current = vec![i + 1];
330        for (j, right_char) in right.iter().enumerate() {
331            current.push(
332                (previous[j + 1] + 1)
333                    .min(current[j] + 1)
334                    .min(previous[j] + usize::from(left_char != *right_char)),
335            );
336        }
337        previous = current;
338    }
339    previous[right.len()]
340}
341
342fn close_matches<'a>(name: &str, names: impl Iterator<Item = &'a str>) -> Vec<&'a str> {
343    let mut scored: Vec<(usize, &str)> = names
344        .map(|candidate| {
345            let distance = if candidate.contains(name) || name.contains(candidate) {
346                0
347            } else {
348                edit_distance(name, candidate)
349            };
350            (distance, candidate)
351        })
352        .filter(|(distance, _)| *distance <= 2)
353        .collect();
354    scored.sort();
355    scored
356        .into_iter()
357        .take(5)
358        .map(|(_, candidate)| candidate)
359        .collect()
360}
361
362/// Resolve a registry package name to the exact `.aospkg` URL and digest listed
363/// in a release manifest. `version` overrides `registry.version`. Manifests are
364/// cached per registry and version for the life of the process; `latest` is
365/// refreshed every five minutes.
366pub async fn resolve_registry_software(
367    name: &str,
368    version: Option<&str>,
369    registry: Option<&SoftwareRegistryOptions>,
370) -> Result<ResolvedRegistrySoftware, ClientError> {
371    validate_name(name)?;
372    let version = version
373        .map(str::to_string)
374        .or_else(|| registry.and_then(|registry| registry.version.clone()))
375        .unwrap_or_else(|| default_registry_version(env!("CARGO_PKG_VERSION")));
376    validate_version(&version)?;
377    let manifest_url = registry_root(registry)?
378        .join(&format!("{version}/software/manifest.json"))
379        .map_err(|error| ClientError::InvalidConfig(format!("invalid registry URL: {error}")))?;
380    let manifest_url = manifest_url.to_string();
381    let artifacts = load_manifest(&manifest_url, &version).await?;
382    let Some(artifact) = artifacts.iter().find(|artifact| artifact.name == name) else {
383        let suggestions = close_matches(
384            name,
385            artifacts.iter().map(|artifact| artifact.name.as_str()),
386        );
387        let hint = if suggestions.is_empty() {
388            String::from("; browse https://rivet.dev/agentos/docs/registry for available packages")
389        } else {
390            format!(
391                "; did you mean {}?",
392                suggestions
393                    .iter()
394                    .map(|suggestion| format!("\"{suggestion}\""))
395                    .collect::<Vec<_>>()
396                    .join(", ")
397            )
398        };
399        return Err(ClientError::SoftwareNotFound(format!(
400            "agentOS registry release \"{version}\" has no package \"{name}\" ({manifest_url}){hint}"
401        )));
402    };
403    let url = Url::parse(&manifest_url)
404        .and_then(|base| base.join(&artifact.path))
405        .map_err(|error| {
406            ClientError::PackageDownload(format!(
407                "agentOS software manifest {manifest_url} lists {name} at an invalid path: {error}"
408            ))
409        })?;
410    if url.scheme() != "https" && url.scheme() != "http" {
411        return Err(ClientError::PackageDownload(format!(
412            "agentOS software manifest {manifest_url} lists {name} at a non-http(s) URL: {url}"
413        )));
414    }
415    Ok(ResolvedRegistrySoftware {
416        name: name.to_string(),
417        version,
418        url: url.to_string(),
419        digest: artifact.digest.clone(),
420        size_bytes: artifact.size,
421        manifest_url,
422    })
423}
424
425/// Resolve a [`SoftwareSource`] to the package source the sidecar acquires.
426pub async fn resolve_software_source(
427    source: SoftwareSource,
428    registry: Option<&SoftwareRegistryOptions>,
429) -> Result<PackageSource, ClientError> {
430    match source {
431        SoftwareSource::Package(source) => Ok(source),
432        SoftwareSource::Registry { name, version } => {
433            let resolved = resolve_registry_software(&name, version.as_deref(), registry).await?;
434            Ok(PackageSource::Url {
435                url: resolved.url,
436                expected_digest: Some(resolved.digest),
437            })
438        }
439    }
440}
441
442/// Test hook: forget cached manifests.
443#[doc(hidden)]
444pub fn clear_software_registry_cache() {
445    MANIFEST_CACHE.lock().clear();
446}
447
448/// Test hook: number of cached manifests.
449#[doc(hidden)]
450pub fn software_registry_cache_size() -> usize {
451    MANIFEST_CACHE.lock().len()
452}