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