Skip to main content

harn_vm/provider_catalog/
remote.rs

1use std::collections::BTreeMap;
2use std::path::PathBuf;
3use std::time::Duration;
4
5use base64::Engine as _;
6use ed25519_dalek::Verifier as _;
7use serde::{Deserialize, Serialize};
8
9use super::*;
10
11#[derive(Debug, Clone, Default)]
12pub struct CatalogRefreshOptions {
13    pub url: Option<String>,
14    pub force: bool,
15}
16
17#[derive(Debug, Clone, Serialize)]
18pub struct CatalogRefreshReport {
19    pub status: String,
20    pub refreshed: bool,
21    pub source_url: String,
22    pub cache_path: String,
23    #[serde(skip_serializing_if = "Option::is_none")]
24    pub etag: Option<String>,
25    pub ttl_ms: u64,
26    pub provider_count: usize,
27    pub model_count: usize,
28    pub alias_count: usize,
29    pub warning: Option<String>,
30}
31
32#[derive(Debug, Clone, Serialize, Deserialize)]
33struct CatalogCacheMetadata {
34    source_url: String,
35    fetched_at_ms: u64,
36    ttl_ms: u64,
37    etag: Option<String>,
38}
39
40#[derive(Debug, Deserialize)]
41struct CatalogDocument {
42    #[serde(default, alias = "ttl_ms", alias = "ttlMS")]
43    ttl_ms: Option<u64>,
44    catalog: ProviderCatalogArtifact,
45    #[serde(default)]
46    signature: Option<CatalogDocumentSignature>,
47}
48
49#[derive(Debug, Deserialize)]
50struct CatalogDocumentSignature {
51    #[serde(default)]
52    algorithm: String,
53    key_id: String,
54    signature: String,
55}
56
57pub(super) struct DecodedCatalogDocument {
58    pub(super) artifact: ProviderCatalogArtifact,
59    pub(super) ttl_ms: u64,
60}
61
62pub async fn refresh_runtime_catalog(options: CatalogRefreshOptions) -> CatalogRefreshReport {
63    let source_url = options
64        .url
65        .clone()
66        .or_else(|| env_nonempty(HARN_PROVIDER_CATALOG_URL_ENV))
67        .unwrap_or_else(|| DEFAULT_PROVIDER_CATALOG_URL.to_string());
68    let cache_dir = default_refresh_cache_dir();
69    let cache_path = cache_dir.join(REMOTE_CACHE_BODY_FILE);
70    if refresh_disabled() {
71        return refresh_report(
72            "disabled",
73            false,
74            source_url,
75            cache_path,
76            None,
77            DEFAULT_REMOTE_TTL_MS,
78            None,
79        );
80    }
81    if crate::llm::current_agent_session_id().is_some() {
82        return refresh_report(
83            "skipped_agent_loop",
84            false,
85            source_url,
86            cache_path,
87            None,
88            DEFAULT_REMOTE_TTL_MS,
89            Some("catalog refresh is disabled inside a live agent loop".to_string()),
90        );
91    }
92
93    if !options.force {
94        if let Some((metadata, body)) = load_fresh_cached_catalog(&source_url, &cache_dir) {
95            return install_remote_catalog_from_body(
96                "cache_hit",
97                false,
98                &source_url,
99                &cache_path,
100                metadata.etag,
101                &body,
102                metadata.ttl_ms,
103                allow_unsigned_for_url(&source_url),
104            );
105        }
106    }
107
108    let metadata = read_cache_metadata(&cache_dir).filter(|meta| meta.source_url == source_url);
109    match fetch_remote_catalog(&source_url, metadata.as_ref()).await {
110        Ok(FetchedCatalog::NotModified) => {
111            if let Some((metadata, body)) = load_any_cached_catalog(&source_url, &cache_dir) {
112                let _ = write_cache_metadata(
113                    &cache_dir,
114                    &CatalogCacheMetadata {
115                        fetched_at_ms: now_ms(),
116                        ..metadata.clone()
117                    },
118                );
119                return install_remote_catalog_from_body(
120                    "not_modified",
121                    false,
122                    &source_url,
123                    &cache_path,
124                    metadata.etag,
125                    &body,
126                    metadata.ttl_ms,
127                    allow_unsigned_for_url(&source_url),
128                );
129            }
130            refresh_report(
131                "fallback",
132                false,
133                source_url,
134                cache_path,
135                None,
136                DEFAULT_REMOTE_TTL_MS,
137                Some("remote returned 304 but no cached catalog was available".to_string()),
138            )
139        }
140        Ok(FetchedCatalog::Body { body, etag }) => {
141            match decode_and_validate_document(&body, allow_unsigned_for_url(&source_url)) {
142                Ok(decoded) => {
143                    if let Err(error) = write_catalog_cache(
144                        &cache_dir,
145                        &body,
146                        &CatalogCacheMetadata {
147                            source_url: source_url.clone(),
148                            fetched_at_ms: now_ms(),
149                            ttl_ms: decoded.ttl_ms,
150                            etag: etag.clone(),
151                        },
152                    ) {
153                        eprintln!(
154                            "[provider_catalog] warning: failed to write runtime catalog cache: {error}"
155                        );
156                    }
157                    install_decoded_catalog(
158                        "refreshed",
159                        true,
160                        source_url,
161                        cache_path,
162                        etag,
163                        decoded,
164                        None,
165                    )
166                }
167                Err(error) => install_stale_or_fallback(
168                    source_url,
169                    cache_dir,
170                    cache_path,
171                    format!("remote catalog rejected: {error}"),
172                ),
173            }
174        }
175        Err(error) => install_stale_or_fallback(source_url, cache_dir, cache_path, error),
176    }
177}
178
179fn install_stale_or_fallback(
180    source_url: String,
181    cache_dir: PathBuf,
182    cache_path: PathBuf,
183    warning: String,
184) -> CatalogRefreshReport {
185    eprintln!("[provider_catalog] warning: {warning}");
186    if let Some((metadata, body)) = load_any_cached_catalog(&source_url, &cache_dir) {
187        return install_remote_catalog_from_body(
188            "stale_cache",
189            false,
190            &source_url,
191            &cache_path,
192            metadata.etag,
193            &body,
194            metadata.ttl_ms,
195            allow_unsigned_for_url(&source_url),
196        );
197    }
198    refresh_report(
199        "fallback",
200        false,
201        source_url,
202        cache_path,
203        None,
204        DEFAULT_REMOTE_TTL_MS,
205        Some(warning),
206    )
207}
208
209fn install_remote_catalog_from_body(
210    status: &str,
211    refreshed: bool,
212    source_url: &str,
213    cache_path: &std::path::Path,
214    etag: Option<String>,
215    body: &str,
216    fallback_ttl_ms: u64,
217    allow_unsigned: bool,
218) -> CatalogRefreshReport {
219    match decode_and_validate_document(body, allow_unsigned) {
220        Ok(mut decoded) => {
221            if decoded.ttl_ms == DEFAULT_REMOTE_TTL_MS {
222                decoded.ttl_ms = fallback_ttl_ms;
223            }
224            install_decoded_catalog(
225                status,
226                refreshed,
227                source_url.to_string(),
228                cache_path.to_path_buf(),
229                etag,
230                decoded,
231                None,
232            )
233        }
234        Err(error) => refresh_report(
235            "fallback",
236            false,
237            source_url.to_string(),
238            cache_path.to_path_buf(),
239            etag,
240            fallback_ttl_ms,
241            Some(format!("cached catalog rejected: {error}")),
242        ),
243    }
244}
245
246fn install_decoded_catalog(
247    status: &str,
248    refreshed: bool,
249    source_url: String,
250    cache_path: PathBuf,
251    etag: Option<String>,
252    decoded: DecodedCatalogDocument,
253    warning: Option<String>,
254) -> CatalogRefreshReport {
255    let provider_count = decoded.artifact.providers.len();
256    let model_count = decoded.artifact.models.len();
257    let alias_count = decoded.artifact.aliases.len();
258    crate::llm_config::set_runtime_catalog_overlay(Some(config_from_artifact(&decoded.artifact)));
259    CatalogRefreshReport {
260        status: status.to_string(),
261        refreshed,
262        source_url,
263        cache_path: cache_path.display().to_string(),
264        etag,
265        ttl_ms: decoded.ttl_ms,
266        provider_count,
267        model_count,
268        alias_count,
269        warning,
270    }
271}
272
273fn refresh_report(
274    status: &str,
275    refreshed: bool,
276    source_url: String,
277    cache_path: PathBuf,
278    etag: Option<String>,
279    ttl_ms: u64,
280    warning: Option<String>,
281) -> CatalogRefreshReport {
282    let current = artifact();
283    CatalogRefreshReport {
284        status: status.to_string(),
285        refreshed,
286        source_url,
287        cache_path: cache_path.display().to_string(),
288        etag,
289        ttl_ms,
290        provider_count: current.providers.len(),
291        model_count: current.models.len(),
292        alias_count: current.aliases.len(),
293        warning,
294    }
295}
296
297enum FetchedCatalog {
298    NotModified,
299    Body { body: String, etag: Option<String> },
300}
301
302async fn fetch_remote_catalog(
303    url: &str,
304    metadata: Option<&CatalogCacheMetadata>,
305) -> Result<FetchedCatalog, String> {
306    let builder = reqwest::Client::builder()
307        .timeout(Duration::from_secs(5))
308        .redirect(crate::egress::redirect_policy(
309            "provider_catalog_redirect",
310            5,
311        ));
312    let client = crate::egress::install_ssrf_guard(builder)
313        .build()
314        .map_err(|error| format!("failed to build HTTP client: {error}"))?;
315    let mut request = client.get(url);
316    if let Some(etag) = metadata.and_then(|meta| meta.etag.as_deref()) {
317        request = request.header(reqwest::header::IF_NONE_MATCH, etag);
318    }
319    let response = request.send().await.map_err(|error| {
320        format!(
321            "failed to fetch runtime provider catalog: {}",
322            crate::egress::redact_reqwest_error(&error)
323        )
324    })?;
325    if response.status() == reqwest::StatusCode::NOT_MODIFIED {
326        return Ok(FetchedCatalog::NotModified);
327    }
328    if !response.status().is_success() {
329        return Err(format!(
330            "runtime provider catalog fetch returned HTTP {}",
331            response.status()
332        ));
333    }
334    let etag = response
335        .headers()
336        .get(reqwest::header::ETAG)
337        .and_then(|value| value.to_str().ok())
338        .map(str::to_string);
339    let body = response
340        .text()
341        .await
342        .map_err(|error| format!("failed to read runtime provider catalog body: {error}"))?;
343    Ok(FetchedCatalog::Body { body, etag })
344}
345
346pub(super) fn decode_and_validate_document(
347    body: &str,
348    allow_unsigned: bool,
349) -> Result<DecodedCatalogDocument, String> {
350    if let Ok(artifact) = serde_json::from_str::<ProviderCatalogArtifact>(body) {
351        if !allow_unsigned {
352            return Err(format!(
353                "unsigned provider catalog rejected; set {HARN_PROVIDER_CATALOG_ALLOW_UNSIGNED_ENV}=1 only for trusted development sources"
354            ));
355        }
356        validate_remote_artifact(artifact, DEFAULT_REMOTE_TTL_MS)
357    } else {
358        let document: CatalogDocument = serde_json::from_str(body)
359            .map_err(|error| format!("catalog JSON does not match the runtime schema: {error}"))?;
360        verify_document_signature(&document)?;
361        validate_remote_artifact(
362            document.catalog,
363            document.ttl_ms.unwrap_or(DEFAULT_REMOTE_TTL_MS),
364        )
365    }
366}
367
368fn validate_remote_artifact(
369    artifact: ProviderCatalogArtifact,
370    ttl_ms: u64,
371) -> Result<DecodedCatalogDocument, String> {
372    let report = validate_artifact(&artifact);
373    if !report.errors.is_empty() {
374        return Err(report.errors.join("; "));
375    }
376    Ok(DecodedCatalogDocument {
377        artifact,
378        ttl_ms: ttl_ms.max(1),
379    })
380}
381
382fn verify_document_signature(document: &CatalogDocument) -> Result<(), String> {
383    let signature = document
384        .signature
385        .as_ref()
386        .ok_or_else(|| "signed catalog envelope is missing signature metadata".to_string())?;
387    if signature.algorithm != "ed25519" {
388        return Err(format!(
389            "unsupported catalog signature algorithm {}",
390            signature.algorithm
391        ));
392    }
393    let trusted_keys = trusted_catalog_keys()?;
394    let public_key = trusted_keys.get(&signature.key_id).ok_or_else(|| {
395        format!(
396            "catalog signature key {} is not trusted; configure {HARN_PROVIDER_CATALOG_TRUSTED_KEYS_ENV}",
397            signature.key_id
398        )
399    })?;
400    let canonical = serde_json::to_vec(&document.catalog)
401        .map_err(|error| format!("failed to canonicalize signed catalog: {error}"))?;
402    let signature_bytes = base64::engine::general_purpose::STANDARD
403        .decode(&signature.signature)
404        .map_err(|error| format!("catalog signature is not valid base64: {error}"))?;
405    let signature = ed25519_dalek::Signature::from_slice(&signature_bytes)
406        .map_err(|error| format!("catalog signature has invalid length: {error}"))?;
407    public_key
408        .verify(&canonical, &signature)
409        .map_err(|error| format!("catalog signature did not verify: {error}"))
410}
411
412fn trusted_catalog_keys() -> Result<BTreeMap<String, ed25519_dalek::VerifyingKey>, String> {
413    let mut keys = BTreeMap::new();
414    let Some(raw) = env_nonempty(HARN_PROVIDER_CATALOG_TRUSTED_KEYS_ENV) else {
415        return Ok(keys);
416    };
417    for entry in raw
418        .split(',')
419        .map(str::trim)
420        .filter(|entry| !entry.is_empty())
421    {
422        let (key_id, encoded) = entry
423            .split_once('=')
424            .or_else(|| entry.split_once(':'))
425            .ok_or_else(|| {
426                format!(
427                    "{HARN_PROVIDER_CATALOG_TRUSTED_KEYS_ENV} entries must use key_id=base64_public_key"
428                )
429            })?;
430        let bytes = base64::engine::general_purpose::STANDARD
431            .decode(encoded.trim())
432            .map_err(|error| format!("catalog public key {key_id} is not valid base64: {error}"))?;
433        let public_key = ed25519_dalek::VerifyingKey::from_bytes(
434            bytes
435                .as_slice()
436                .try_into()
437                .map_err(|_| format!("catalog public key {key_id} must be 32 bytes"))?,
438        )
439        .map_err(|error| format!("catalog public key {key_id} is invalid: {error}"))?;
440        keys.insert(key_id.trim().to_string(), public_key);
441    }
442    Ok(keys)
443}
444
445fn default_refresh_cache_dir() -> PathBuf {
446    crate::runtime_paths::state_root(&crate::stdlib::process::runtime_root_base())
447        .join("cache")
448        .join(REMOTE_CACHE_DIR)
449}
450
451fn load_fresh_cached_catalog(
452    source_url: &str,
453    cache_dir: &std::path::Path,
454) -> Option<(CatalogCacheMetadata, String)> {
455    let (metadata, body) = load_any_cached_catalog(source_url, cache_dir)?;
456    let age = now_ms().saturating_sub(metadata.fetched_at_ms);
457    (age < metadata.ttl_ms).then_some((metadata, body))
458}
459
460fn load_any_cached_catalog(
461    source_url: &str,
462    cache_dir: &std::path::Path,
463) -> Option<(CatalogCacheMetadata, String)> {
464    let metadata = read_cache_metadata(cache_dir)?;
465    if metadata.source_url != source_url {
466        return None;
467    }
468    let body = std::fs::read_to_string(cache_dir.join(REMOTE_CACHE_BODY_FILE)).ok()?;
469    Some((metadata, body))
470}
471
472fn read_cache_metadata(cache_dir: &std::path::Path) -> Option<CatalogCacheMetadata> {
473    let body = std::fs::read_to_string(cache_dir.join(REMOTE_CACHE_META_FILE)).ok()?;
474    serde_json::from_str(&body).ok()
475}
476
477fn write_catalog_cache(
478    cache_dir: &std::path::Path,
479    body: &str,
480    metadata: &CatalogCacheMetadata,
481) -> std::io::Result<()> {
482    std::fs::create_dir_all(cache_dir)?;
483    std::fs::write(cache_dir.join(REMOTE_CACHE_BODY_FILE), body)?;
484    write_cache_metadata(cache_dir, metadata)
485}
486
487fn write_cache_metadata(
488    cache_dir: &std::path::Path,
489    metadata: &CatalogCacheMetadata,
490) -> std::io::Result<()> {
491    std::fs::create_dir_all(cache_dir)?;
492    let body = serde_json::to_string_pretty(metadata).unwrap_or_else(|_| "{}".to_string());
493    std::fs::write(cache_dir.join(REMOTE_CACHE_META_FILE), body)
494}
495
496fn now_ms() -> u64 {
497    harn_clock::now_wall_ms(&harn_clock::RealClock::new()).max(0) as u64
498}
499
500fn refresh_disabled() -> bool {
501    matches!(
502        env_nonempty(HARN_DISABLE_CATALOG_REFRESH_ENV)
503            .as_deref()
504            .map(|value| value.to_ascii_lowercase()),
505        Some(value) if matches!(value.as_str(), "1" | "true" | "yes" | "on")
506    )
507}
508
509fn allow_unsigned_for_url(url: &str) -> bool {
510    if matches!(
511        env_nonempty(HARN_PROVIDER_CATALOG_ALLOW_UNSIGNED_ENV)
512            .as_deref()
513            .map(|value| value.to_ascii_lowercase()),
514        Some(value) if matches!(value.as_str(), "1" | "true" | "yes" | "on")
515    ) {
516        return true;
517    }
518    url::Url::parse(url).ok().is_some_and(|parsed| {
519        matches!(
520            parsed.host_str(),
521            Some("localhost") | Some("127.0.0.1") | Some("::1")
522        )
523    })
524}
525
526fn env_nonempty(name: &str) -> Option<String> {
527    std::env::var(name)
528        .ok()
529        .map(|value| value.trim().to_string())
530        .filter(|value| !value.is_empty())
531}