1use 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
21pub const DEFAULT_SOFTWARE_REGISTRY_URL: &str = "https://releases.rivet.dev/agent-os/";
23pub 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;
29const LATEST_MANIFEST_TTL: Duration = Duration::from_secs(5 * 60);
31
32#[derive(Debug, Clone, Default, PartialEq, Eq)]
34pub struct SoftwareRegistryOptions {
35 pub url: Option<String>,
38 pub version: Option<String>,
41}
42
43#[derive(Debug, Clone, PartialEq, Eq)]
46pub enum SoftwareSource {
47 Package(PackageSource),
49 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#[derive(Debug, Clone, PartialEq, Eq)]
65pub struct ResolvedRegistrySoftware {
66 pub name: String,
67 pub version: String,
69 pub url: String,
71 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
98static MANIFEST_CACHE: Lazy<parking_lot::Mutex<VecDeque<CachedManifest>>> =
101 Lazy::new(|| parking_lot::Mutex::new(VecDeque::new()));
102
103pub 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
134fn 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 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
362pub 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
425pub 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#[doc(hidden)]
444pub fn clear_software_registry_cache() {
445 MANIFEST_CACHE.lock().clear();
446}
447
448#[doc(hidden)]
450pub fn software_registry_cache_size() -> usize {
451 MANIFEST_CACHE.lock().len()
452}