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 client = reqwest::Client::builder()
307        .timeout(Duration::from_secs(5))
308        .redirect(crate::egress::redirect_policy(
309            "provider_catalog_redirect",
310            5,
311        ))
312        .build()
313        .map_err(|error| format!("failed to build HTTP client: {error}"))?;
314    let mut request = client.get(url);
315    if let Some(etag) = metadata.and_then(|meta| meta.etag.as_deref()) {
316        request = request.header(reqwest::header::IF_NONE_MATCH, etag);
317    }
318    let response = request.send().await.map_err(|error| {
319        format!(
320            "failed to fetch runtime provider catalog: {}",
321            crate::egress::redact_reqwest_error(&error)
322        )
323    })?;
324    if response.status() == reqwest::StatusCode::NOT_MODIFIED {
325        return Ok(FetchedCatalog::NotModified);
326    }
327    if !response.status().is_success() {
328        return Err(format!(
329            "runtime provider catalog fetch returned HTTP {}",
330            response.status()
331        ));
332    }
333    let etag = response
334        .headers()
335        .get(reqwest::header::ETAG)
336        .and_then(|value| value.to_str().ok())
337        .map(str::to_string);
338    let body = response
339        .text()
340        .await
341        .map_err(|error| format!("failed to read runtime provider catalog body: {error}"))?;
342    Ok(FetchedCatalog::Body { body, etag })
343}
344
345pub(super) fn decode_and_validate_document(
346    body: &str,
347    allow_unsigned: bool,
348) -> Result<DecodedCatalogDocument, String> {
349    if let Ok(artifact) = serde_json::from_str::<ProviderCatalogArtifact>(body) {
350        if !allow_unsigned {
351            return Err(format!(
352                "unsigned provider catalog rejected; set {HARN_PROVIDER_CATALOG_ALLOW_UNSIGNED_ENV}=1 only for trusted development sources"
353            ));
354        }
355        validate_remote_artifact(artifact, DEFAULT_REMOTE_TTL_MS)
356    } else {
357        let document: CatalogDocument = serde_json::from_str(body)
358            .map_err(|error| format!("catalog JSON does not match the runtime schema: {error}"))?;
359        verify_document_signature(&document)?;
360        validate_remote_artifact(
361            document.catalog,
362            document.ttl_ms.unwrap_or(DEFAULT_REMOTE_TTL_MS),
363        )
364    }
365}
366
367fn validate_remote_artifact(
368    artifact: ProviderCatalogArtifact,
369    ttl_ms: u64,
370) -> Result<DecodedCatalogDocument, String> {
371    let report = validate_artifact(&artifact);
372    if !report.errors.is_empty() {
373        return Err(report.errors.join("; "));
374    }
375    Ok(DecodedCatalogDocument {
376        artifact,
377        ttl_ms: ttl_ms.max(1),
378    })
379}
380
381fn verify_document_signature(document: &CatalogDocument) -> Result<(), String> {
382    let signature = document
383        .signature
384        .as_ref()
385        .ok_or_else(|| "signed catalog envelope is missing signature metadata".to_string())?;
386    if signature.algorithm != "ed25519" {
387        return Err(format!(
388            "unsupported catalog signature algorithm {}",
389            signature.algorithm
390        ));
391    }
392    let trusted_keys = trusted_catalog_keys()?;
393    let public_key = trusted_keys.get(&signature.key_id).ok_or_else(|| {
394        format!(
395            "catalog signature key {} is not trusted; configure {HARN_PROVIDER_CATALOG_TRUSTED_KEYS_ENV}",
396            signature.key_id
397        )
398    })?;
399    let canonical = serde_json::to_vec(&document.catalog)
400        .map_err(|error| format!("failed to canonicalize signed catalog: {error}"))?;
401    let signature_bytes = base64::engine::general_purpose::STANDARD
402        .decode(&signature.signature)
403        .map_err(|error| format!("catalog signature is not valid base64: {error}"))?;
404    let signature = ed25519_dalek::Signature::from_slice(&signature_bytes)
405        .map_err(|error| format!("catalog signature has invalid length: {error}"))?;
406    public_key
407        .verify(&canonical, &signature)
408        .map_err(|error| format!("catalog signature did not verify: {error}"))
409}
410
411fn trusted_catalog_keys() -> Result<BTreeMap<String, ed25519_dalek::VerifyingKey>, String> {
412    let mut keys = BTreeMap::new();
413    let Some(raw) = env_nonempty(HARN_PROVIDER_CATALOG_TRUSTED_KEYS_ENV) else {
414        return Ok(keys);
415    };
416    for entry in raw
417        .split(',')
418        .map(str::trim)
419        .filter(|entry| !entry.is_empty())
420    {
421        let (key_id, encoded) = entry
422            .split_once('=')
423            .or_else(|| entry.split_once(':'))
424            .ok_or_else(|| {
425                format!(
426                    "{HARN_PROVIDER_CATALOG_TRUSTED_KEYS_ENV} entries must use key_id=base64_public_key"
427                )
428            })?;
429        let bytes = base64::engine::general_purpose::STANDARD
430            .decode(encoded.trim())
431            .map_err(|error| format!("catalog public key {key_id} is not valid base64: {error}"))?;
432        let public_key = ed25519_dalek::VerifyingKey::from_bytes(
433            bytes
434                .as_slice()
435                .try_into()
436                .map_err(|_| format!("catalog public key {key_id} must be 32 bytes"))?,
437        )
438        .map_err(|error| format!("catalog public key {key_id} is invalid: {error}"))?;
439        keys.insert(key_id.trim().to_string(), public_key);
440    }
441    Ok(keys)
442}
443
444fn default_refresh_cache_dir() -> PathBuf {
445    crate::runtime_paths::state_root(&crate::stdlib::process::runtime_root_base())
446        .join("cache")
447        .join(REMOTE_CACHE_DIR)
448}
449
450fn load_fresh_cached_catalog(
451    source_url: &str,
452    cache_dir: &std::path::Path,
453) -> Option<(CatalogCacheMetadata, String)> {
454    let (metadata, body) = load_any_cached_catalog(source_url, cache_dir)?;
455    let age = now_ms().saturating_sub(metadata.fetched_at_ms);
456    (age < metadata.ttl_ms).then_some((metadata, body))
457}
458
459fn load_any_cached_catalog(
460    source_url: &str,
461    cache_dir: &std::path::Path,
462) -> Option<(CatalogCacheMetadata, String)> {
463    let metadata = read_cache_metadata(cache_dir)?;
464    if metadata.source_url != source_url {
465        return None;
466    }
467    let body = std::fs::read_to_string(cache_dir.join(REMOTE_CACHE_BODY_FILE)).ok()?;
468    Some((metadata, body))
469}
470
471fn read_cache_metadata(cache_dir: &std::path::Path) -> Option<CatalogCacheMetadata> {
472    let body = std::fs::read_to_string(cache_dir.join(REMOTE_CACHE_META_FILE)).ok()?;
473    serde_json::from_str(&body).ok()
474}
475
476fn write_catalog_cache(
477    cache_dir: &std::path::Path,
478    body: &str,
479    metadata: &CatalogCacheMetadata,
480) -> std::io::Result<()> {
481    std::fs::create_dir_all(cache_dir)?;
482    std::fs::write(cache_dir.join(REMOTE_CACHE_BODY_FILE), body)?;
483    write_cache_metadata(cache_dir, metadata)
484}
485
486fn write_cache_metadata(
487    cache_dir: &std::path::Path,
488    metadata: &CatalogCacheMetadata,
489) -> std::io::Result<()> {
490    std::fs::create_dir_all(cache_dir)?;
491    let body = serde_json::to_string_pretty(metadata).unwrap_or_else(|_| "{}".to_string());
492    std::fs::write(cache_dir.join(REMOTE_CACHE_META_FILE), body)
493}
494
495fn now_ms() -> u64 {
496    harn_clock::now_wall_ms(&harn_clock::RealClock::new()).max(0) as u64
497}
498
499fn refresh_disabled() -> bool {
500    matches!(
501        env_nonempty(HARN_DISABLE_CATALOG_REFRESH_ENV)
502            .as_deref()
503            .map(|value| value.to_ascii_lowercase()),
504        Some(value) if matches!(value.as_str(), "1" | "true" | "yes" | "on")
505    )
506}
507
508fn allow_unsigned_for_url(url: &str) -> bool {
509    if matches!(
510        env_nonempty(HARN_PROVIDER_CATALOG_ALLOW_UNSIGNED_ENV)
511            .as_deref()
512            .map(|value| value.to_ascii_lowercase()),
513        Some(value) if matches!(value.as_str(), "1" | "true" | "yes" | "on")
514    ) {
515        return true;
516    }
517    url::Url::parse(url).ok().is_some_and(|parsed| {
518        matches!(
519            parsed.host_str(),
520            Some("localhost") | Some("127.0.0.1") | Some("::1")
521        )
522    })
523}
524
525fn env_nonempty(name: &str) -> Option<String> {
526    std::env::var(name)
527        .ok()
528        .map(|value| value.trim().to_string())
529        .filter(|value| !value.is_empty())
530}