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}