diff --git a/.gitguardian.yml b/.gitguardian.yml new file mode 100644 index 0000000000..090268a4c2 --- /dev/null +++ b/.gitguardian.yml @@ -0,0 +1,10 @@ +# GitGuardian scan configuration. +# paths-ignore entries are deliberate, documented test fixtures — never real +# credentials. Each entry must name its reason inline. +paths-ignore: + # Deliberately public TEST-ONLY Ed25519 keypair committed as a fixture for the + # signed cloud-facts envelope tests (Rust + web). It is never pinned in + # crates/config/src/cloud_facts/keys.rs or web/lib/cloud-facts/keys.ts, signs + # nothing outside tests, and exists so the suites share one stable fixture. + # Introduced in 9e83f8184 with its rationale in the commit message. + - docs/cloud-facts/fixtures/test-only-signing-key.pem diff --git a/CHANGELOG.md b/CHANGELOG.md index b9915c95d3..c8be8cc4b1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,19 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- Cloud facts channel (`facts/v1`), behind `[cloud_facts].enabled` (default + **off**): a signed (Ed25519, keys pinned in the binary), versioned, ETag + + TTL cached overlay for model catalog deltas, provider defaults, release + truth, and one-line announcements. Bundled facts remain the floor; a + missing, rejected, or out-of-scope payload changes nothing, and the fetch + is never a startup dependency. New crate `codewhale-cloud-facts`, + `codewhale_config::cloud_facts`, catalog layer 15 + (`CatalogSource::CloudFacts`), `/status` rows `Catalog:` and + `Cloud facts:`. Website route `/api/facts/v1/` (Supabase + `facts_*` tables, anon read-only, RLS forced) and the authoring tool + `web/scripts/facts-publish.mjs`. Note: codewhale.net is served by a + Cloudflare Worker (OpenNext), not Vercel as the PRD states; the route is + host-agnostic. See [CLOUD_FACTS.md](docs/CLOUD_FACTS.md). - Website: the public site moves to the Tideline deep-ocean design language (dark by default with an opt-in light documentation sheet, palette grounded in the TUI's WHALE_* tokens) and the new whale brand mark across the favicon, diff --git a/Cargo.lock b/Cargo.lock index 318cf79353..946fb0fa7b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -830,6 +830,21 @@ dependencies = [ "zeroize", ] +[[package]] +name = "codewhale-cloud-facts" +version = "0.9.11" +dependencies = [ + "codewhale-config", + "codewhale-release", + "reqwest 0.13.4", + "semver", + "serde", + "serde_json", + "tempfile", + "tokio", + "tracing", +] + [[package]] name = "codewhale-command-contract" version = "0.9.11" @@ -842,11 +857,14 @@ name = "codewhale-config" version = "0.9.11" dependencies = [ "anyhow", + "base64 0.22.1", "codewhale-execpolicy", "codewhale-paths", "codewhale-secrets", "fd-lock", "libc", + "ring", + "semver", "serde", "serde_json", "sha2 0.11.0", @@ -1044,6 +1062,7 @@ dependencies = [ "clap", "clap_complete", "codewhale-build-support", + "codewhale-cloud-facts", "codewhale-command-contract", "codewhale-config", "codewhale-core", diff --git a/Cargo.toml b/Cargo.toml index 8bca889124..57bc862146 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -4,6 +4,7 @@ members = [ "crates/app-server", "crates/build-support", "crates/cli", + "crates/cloud-facts", "crates/command-contract", "crates/config", "crates/core", diff --git a/config.example.toml b/config.example.toml index 284b6c1b77..00504cb420 100644 --- a/config.example.toml +++ b/config.example.toml @@ -123,6 +123,22 @@ check_for_updates = true check_interval_hours = 24 # update_uri = "https://internal.mirror.example/codewhale/releases/latest" +# ───────────────────────────────────────────────────────────────────────────────── +# Cloud facts (docs/CLOUD_FACTS.md) — OFF by default +# ───────────────────────────────────────────────────────────────────────────────── +# A signed, versioned overlay of facts that move faster than releases (model +# catalog deltas, provider defaults, release truth, one-line notices). Bundled +# facts are always the floor; a rejected or unreachable payload changes nothing. +# Env: CODEWHALE_CLOUD_FACTS=1|0 overrides `enabled`; CODEWHALE_DISABLE_CLOUD_FACTS=1 +# is a hard kill switch; CODEWHALE_CLOUD_FACTS_URL / _CHANNEL / _PATH override +# the endpoint, channel, or read a local envelope (air-gapped). `/status` shows +# the provenance ("Cloud facts:" row). +[cloud_facts] +enabled = false +channel = "stable" +ttl_hours = 6 +# url = "https://codewhale.net/api/facts/v1/{channel}" + # ───────────────────────────────────────────────────────────────────────────────── # Hotbar slots (#2061 / #2064) # ───────────────────────────────────────────────────────────────────────────────── diff --git a/crates/cloud-facts/Cargo.toml b/crates/cloud-facts/Cargo.toml new file mode 100644 index 0000000000..aa0617a39a --- /dev/null +++ b/crates/cloud-facts/Cargo.toml @@ -0,0 +1,24 @@ +[package] +name = "codewhale-cloud-facts" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true +repository.workspace = true +description = "Fetch + verified disk cache for the Codewhale cloud facts channel (facts/v1)" + +[lints] +workspace = true + +[dependencies] +codewhale-config = { path = "../config", version = "0.9.11" } +codewhale-release = { path = "../release", version = "0.9.11" } +reqwest.workspace = true +semver.workspace = true +serde.workspace = true +serde_json.workspace = true +tokio.workspace = true +tracing.workspace = true + +[dev-dependencies] +tempfile.workspace = true diff --git a/crates/cloud-facts/examples/fetch_live.rs b/crates/cloud-facts/examples/fetch_live.rs new file mode 100644 index 0000000000..ab631a9431 --- /dev/null +++ b/crates/cloud-facts/examples/fetch_live.rs @@ -0,0 +1,66 @@ +//! Dogfood/proof harness: run the cloud facts client once against a real +//! endpoint with an isolated `CODEWHALE_HOME` and print the resulting status. +//! +//! ```sh +//! CODEWHALE_HOME=$(mktemp -d) CODEWHALE_CLOUD_FACTS=1 \ +//! CODEWHALE_CLOUD_FACTS_URL=http://localhost:3000/api/facts/v1/{channel} \ +//! cargo run -p codewhale-cloud-facts --example fetch_live +//! ``` +//! +//! The flag stays off by default; this example only honours the env override. + +use codewhale_cloud_facts::{Settings, maybe_load_persisted_cache, refresh, status}; +use codewhale_config::catalog::now_unix; +use codewhale_config::cloud_facts::overlay; + +#[tokio::main(flavor = "current_thread")] +async fn main() { + let settings = Settings::default().resolve(); + println!( + "enabled={} channel={} url={}", + settings.enabled, + settings.channel, + settings.url() + ); + println!( + "cache={}", + codewhale_cloud_facts::cache_path().map_or("".into(), |p| p.display().to_string()) + ); + let seeded = maybe_load_persisted_cache(&settings); + println!("seeded_from_disk={seeded:?}"); + println!("before: {}", status().label(now_unix())); + match refresh(&settings, true).await { + Ok(outcome) => println!("refresh: {outcome:?}"), + Err(err) => println!("refresh error: {err}"), + } + let st = status(); + println!("after: {}", st.label(now_unix())); + println!( + "status_json={}", + serde_json::to_string(&st).unwrap_or_default() + ); + if let Some(facts) = overlay::overlay() { + println!( + "overlay: channel={} v{} key={} sha256={} patches={} defaults={} announcements={} dropped={}", + facts.channel, + facts.facts_version, + facts.key_id, + facts.sha256, + facts.models.len(), + facts.provider_defaults.len(), + facts.announcements.len(), + facts.dropped.len() + ); + if let Some(release) = &facts.release { + println!( + "release: latest={} yanked={:?}", + release.latest, release.yanked + ); + } + for a in &facts.announcements { + println!("announcement[{}] {:?}: {}", a.id, a.level, a.text); + } + } else { + println!("overlay: none (bundled facts in use)"); + } +} diff --git a/crates/cloud-facts/src/lib.rs b/crates/cloud-facts/src/lib.rs new file mode 100644 index 0000000000..94ad3fea82 --- /dev/null +++ b/crates/cloud-facts/src/lib.rs @@ -0,0 +1,716 @@ +//! Cloud facts client: fetch `https://codewhale.net/api/facts/v1/`, +//! verify the Ed25519 envelope against the keys pinned in +//! `codewhale_config::cloud_facts::keys`, cache it under +//! `$CODEWHALE_HOME/facts/cloud-facts.json`, and install the scoped view as the +//! process-wide overlay. Modeled on the TUI's `models_dev_live` producer. +//! +//! Guarantees: +//! - Never a startup dependency: [`maybe_load_persisted_cache`] is a bounded +//! synchronous disk read; all network happens in [`spawn_background_refresh`]. +//! - Off by default (`[cloud_facts].enabled = false`); `CODEWHALE_CLOUD_FACTS=1` +//! flips it, `CODEWHALE_DISABLE_CLOUD_FACTS=1` beats everything, CI markers +//! suppress the fetch. +//! - The disk cache is re-verified on every load; a tampered file is deleted. +//! - The fetch sends only a fixed user agent and `If-None-Match`; no +//! identifiers, cookies, or query parameters (PRD §5). +//! - With no active pinned key the layer is inert even when enabled. + +use std::path::{Path, PathBuf}; +use std::sync::Arc; +use std::time::Duration; + +use codewhale_config::catalog::now_unix; +use codewhale_config::cloud_facts::{ + CloudFactsState, CloudFactsStatus, FactsOrigin, FactsRejection, ScopedFacts, TrustedKey, + VerifiedFacts, has_active_trusted_key, overlay, scoped_view, verify_envelope, +}; +use codewhale_config::persistence::atomic_write; +use serde::{Deserialize, Serialize}; + +/// `{channel}` is replaced with the channel slug. +pub const DEFAULT_URL_TEMPLATE: &str = "https://codewhale.net/api/facts/v1/{channel}"; +/// Refresh interval for a verified payload (6 h). +pub const DEFAULT_TTL_SECS: u64 = 6 * 60 * 60; +/// Bounded HTTP budget. +pub const FETCH_TIMEOUT: Duration = Duration::from_secs(10); +pub const CONNECT_TIMEOUT: Duration = Duration::from_secs(5); +/// Largest response body accepted. +pub const MAX_BODY_BYTES: usize = codewhale_config::cloud_facts::MAX_ENVELOPE_BYTES; +/// Fixed, identifier-free user agent. +pub const USER_AGENT: &str = concat!("CodeWhale/", env!("CARGO_PKG_VERSION"), " (+cloud-facts)"); +/// State subdir + file under `$CODEWHALE_HOME`. +pub const STATE_SUBDIR: &str = "facts"; +pub const CACHE_FILE: &str = "cloud-facts.json"; +const CACHE_SCHEMA_VERSION: u32 = 1; +const BACKOFF_BASE_SECS: u64 = 10 * 60; + +/// Env: `1`/`0` overrides `[cloud_facts].enabled`. +pub const ENV_ENABLED: &str = "CODEWHALE_CLOUD_FACTS"; +/// Env: hard kill switch (truthy) — beats config and `ENV_ENABLED`. +pub const ENV_DISABLE: &str = "CODEWHALE_DISABLE_CLOUD_FACTS"; +/// Env: full URL override (may contain `{channel}`). +pub const ENV_URL: &str = "CODEWHALE_CLOUD_FACTS_URL"; +/// Env: channel slug override. +pub const ENV_CHANNEL: &str = "CODEWHALE_CLOUD_FACTS_CHANNEL"; +/// Env: read the envelope from a local file instead of the network. +pub const ENV_PATH: &str = "CODEWHALE_CLOUD_FACTS_PATH"; +const CI_MARKERS: &[&str] = &[ + "CI", + "GITHUB_ACTIONS", + "GITLAB_CI", + "BUILDKITE", + "CIRCLECI", + "JENKINS_URL", + "TEAMCITY_VERSION", + "TF_BUILD", +]; + +/// Resolved runtime settings (config + env). +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Settings { + pub enabled: bool, + pub channel: String, + pub url: Option, + pub ttl_secs: u64, + /// Explicit cache file (tests); otherwise `$CODEWHALE_HOME/facts/cloud-facts.json`. + pub cache_path: Option, + /// Local envelope path (`ENV_PATH`); skips the network. + pub local_path: Option, +} + +impl Default for Settings { + fn default() -> Self { + Self { + enabled: false, + channel: "stable".to_string(), + url: None, + ttl_secs: DEFAULT_TTL_SECS, + cache_path: None, + local_path: None, + } + } +} + +fn env_truthy(name: &str) -> Option { + let value = std::env::var(name).ok()?; + match value.trim().to_ascii_lowercase().as_str() { + "1" | "true" | "yes" | "on" => Some(true), + "0" | "false" | "no" | "off" => Some(false), + _ => None, + } +} + +impl Settings { + /// Apply env overrides on top of config-derived settings. + #[must_use] + pub fn resolve(mut self) -> Self { + if let Some(enabled) = env_truthy(ENV_ENABLED) { + self.enabled = enabled; + } + if let Ok(channel) = std::env::var(ENV_CHANNEL) { + let channel = channel.trim(); + if valid_channel(channel) { + self.channel = channel.to_string(); + } + } + if let Ok(url) = std::env::var(ENV_URL) { + let url = url.trim(); + if !url.is_empty() { + self.url = Some(url.to_string()); + } + } + if let Ok(path) = std::env::var(ENV_PATH) { + let path = path.trim(); + if !path.is_empty() { + self.local_path = Some(PathBuf::from(path)); + } + } + self.ttl_secs = self.ttl_secs.max(60); + self + } + + /// The effective envelope URL. + #[must_use] + pub fn url(&self) -> String { + self.url + .as_deref() + .unwrap_or(DEFAULT_URL_TEMPLATE) + .replace("{channel}", &self.channel) + } + + fn cache_file(&self) -> Option { + self.cache_path.clone().or_else(cache_path) + } +} + +/// Channel slugs are `[a-z0-9][a-z0-9-]{0,31}`. +#[must_use] +pub fn valid_channel(slug: &str) -> bool { + let bytes = slug.as_bytes(); + (1..=32).contains(&bytes.len()) + && bytes[0].is_ascii_lowercase() | bytes[0].is_ascii_digit() + && bytes + .iter() + .all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || *b == b'-') +} + +/// Hard kill switch or CI: never fetch (a local `ENV_PATH` is still honoured). +#[must_use] +pub fn fetch_suppressed() -> bool { + if env_truthy(ENV_DISABLE) == Some(true) { + return true; + } + CI_MARKERS.iter().any(|name| { + env_truthy(name).unwrap_or_else(|| std::env::var(name).is_ok_and(|v| !v.trim().is_empty())) + }) +} + +/// Default cache path under the CodeWhale state root. +#[must_use] +pub fn cache_path() -> Option { + codewhale_config::resolve_state_dir(STATE_SUBDIR) + .ok() + .map(|dir| dir.join(CACHE_FILE)) +} + +/// On-disk cache. The envelope is stored verbatim and re-verified on load. +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +struct PersistedCache { + schema_version: u32, + channel: String, + url: String, + fetched_at: u64, + #[serde(default)] + etag: Option, + #[serde(default)] + highest_seen_version: Option, + #[serde(default)] + backoff_until: Option, + #[serde(default)] + failures: u32, + /// Raw envelope document (secret-free by construction); empty when the + /// last fetch found no facts. + #[serde(default)] + envelope: String, +} + +fn load_cache(path: &Path) -> Option { + let bytes = std::fs::read(path).ok()?; + let cache: PersistedCache = serde_json::from_slice(&bytes).ok()?; + (cache.schema_version == CACHE_SCHEMA_VERSION).then_some(cache) +} + +fn save_cache(path: &Path, cache: &PersistedCache) { + match serde_json::to_vec(cache) { + Ok(bytes) => { + if let Err(err) = atomic_write(path, &bytes) { + tracing::debug!(target: "cloud_facts", error = %err, "cache write failed"); + } + } + Err(err) => tracing::debug!(target: "cloud_facts", error = %err, "cache encode failed"), + } +} + +/// Why a refresh did not install new facts. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum RefreshError { + Disabled, + Inert, + Suppressed, + BackingOff { until: u64 }, + Network(String), + HttpStatus(u16), + TooLarge(usize), + Rejected(FactsRejection), + Io(String), +} + +impl std::fmt::Display for RefreshError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Disabled => write!(f, "cloud facts disabled"), + Self::Inert => write!(f, "no active trusted key"), + Self::Suppressed => write!(f, "fetch suppressed (kill switch or CI)"), + Self::BackingOff { until } => write!(f, "backing off until {until}"), + Self::Network(msg) => write!(f, "network: {msg}"), + Self::HttpStatus(code) => write!(f, "HTTP {code}"), + Self::TooLarge(bytes) => write!(f, "response too large ({bytes} bytes)"), + Self::Rejected(reason) => write!(f, "{reason}"), + Self::Io(msg) => write!(f, "io: {msg}"), + } + } +} + +/// What a successful refresh did. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum RefreshOutcome { + /// Server returned 304; the verified facts on disk are current. + NotModified { facts_version: Option }, + /// A new envelope verified and is now the overlay. + Updated { facts_version: u64 }, + /// Within TTL; nothing fetched. + Fresh { facts_version: Option }, + /// Server has no facts for the channel (404); bundled in use. + NoFacts, +} + +fn set_state(state: CloudFactsState, etag: Option, source_label: &str) { + overlay::set_status(CloudFactsStatus { + state, + last_attempt: Some(now_unix()), + etag, + source_label: source_label.to_string(), + }); +} + +fn current_verified_version() -> Option { + match overlay::status().state { + CloudFactsState::Verified { facts_version, .. } => Some(facts_version), + _ => None, + } +} + +/// Install a verified payload as the overlay and report it. +fn install( + verified: &VerifiedFacts, + origin: FactsOrigin, + fetched_at: u64, + etag: Option, + source_label: &str, +) -> ScopedFacts { + let scoped = scoped_view( + verified, + &codewhale_config::cloud_facts::current_version(), + now_unix(), + ); + let (patches, defaults, announcements) = scoped.item_counts(); + for receipt in &scoped.dropped { + tracing::debug!(target: "cloud_facts", receipt, "cloud facts item dropped"); + } + overlay::set_overlay(Some(scoped.clone())); + set_state( + CloudFactsState::Verified { + channel: scoped.channel.clone(), + facts_version: scoped.facts_version, + key_id: scoped.key_id.clone(), + fetched_at, + origin, + stale: scoped.stale, + patches, + defaults, + announcements, + }, + etag, + source_label, + ); + scoped +} + +fn verify_with( + bytes: &[u8], + settings: &Settings, + highest_seen: Option, + keys: &[TrustedKey], +) -> Result { + verify_envelope( + bytes, + &settings.channel, + &codewhale_config::cloud_facts::current_version(), + highest_seen, + keys, + now_unix(), + ) +} + +/// Seed the overlay from the disk cache before any picker/status read. +/// +/// Returns the installed `facts_version`, if any. Bounded: one file read, one +/// verification. Tampered or unverifiable caches are deleted. +pub fn maybe_load_persisted_cache(settings: &Settings) -> Option { + maybe_load_persisted_cache_with_keys(settings, codewhale_config::cloud_facts::TRUSTED_KEYS) +} + +fn maybe_load_persisted_cache_with_keys(settings: &Settings, keys: &[TrustedKey]) -> Option { + if !settings.enabled { + set_state(CloudFactsState::Off, None, ""); + return None; + } + if !keys + .iter() + .any(|k| k.status == codewhale_config::cloud_facts::KeyStatus::Active) + { + set_state(CloudFactsState::Inert, None, ""); + return None; + } + set_state(CloudFactsState::BundledOnly, None, ""); + let path = settings.cache_file()?; + let cache = load_cache(&path)?; + if cache.channel != settings.channel || cache.envelope.trim().is_empty() { + return None; + } + match verify_with( + cache.envelope.as_bytes(), + settings, + cache.highest_seen_version, + keys, + ) { + Ok(verified) => { + let mut verified = verified; + // Past TTL the payload is still installed, but flagged stale. + if now_unix().saturating_sub(cache.fetched_at) > settings.ttl_secs { + verified.stale = true; + } + let scoped = install( + &verified, + FactsOrigin::DiskCache, + cache.fetched_at, + cache.etag.clone(), + &cache.url, + ); + Some(scoped.facts_version) + } + Err(FactsRejection::NotApplicable { applies_to }) => { + set_state( + CloudFactsState::NotApplicable { applies_to }, + cache.etag, + &cache.url, + ); + None + } + Err(reason) => { + tracing::debug!(target: "cloud_facts", error = %reason, "persisted cloud facts rejected; deleting cache"); + let _ = std::fs::remove_file(&path); + set_state( + CloudFactsState::Rejected { + reason: reason.to_string(), + at: now_unix(), + }, + None, + &cache.url, + ); + None + } + } +} + +enum Fetched { + NotModified, + NotFound, + Body { + bytes: Vec, + etag: Option, + }, +} + +async fn fetch(url: &str, etag: Option<&str>) -> Result { + let client = codewhale_release::tls::reqwest_client_builder() + .timeout(FETCH_TIMEOUT) + .connect_timeout(CONNECT_TIMEOUT) + .user_agent(USER_AGENT) + .build() + .map_err(|err| RefreshError::Network(err.to_string()))?; + let mut request = client.get(url).header("Accept", "application/json"); + if let Some(etag) = etag { + request = request.header("If-None-Match", etag); + } + let response = request + .send() + .await + .map_err(|err| RefreshError::Network(err.to_string()))?; + let status = response.status().as_u16(); + if status == 304 { + return Ok(Fetched::NotModified); + } + if status == 404 { + return Ok(Fetched::NotFound); + } + if !(200..300).contains(&status) { + return Err(RefreshError::HttpStatus(status)); + } + if let Some(len) = response.content_length() + && len as usize > MAX_BODY_BYTES + { + return Err(RefreshError::TooLarge(len as usize)); + } + let etag = response + .headers() + .get("etag") + .and_then(|v| v.to_str().ok()) + .map(str::to_string); + let bytes = response + .bytes() + .await + .map_err(|err| RefreshError::Network(err.to_string()))?; + if bytes.len() > MAX_BODY_BYTES { + return Err(RefreshError::TooLarge(bytes.len())); + } + Ok(Fetched::Body { + bytes: bytes.to_vec(), + etag, + }) +} + +fn record_failure(cache: &mut PersistedCache, err: &RefreshError, ttl_secs: u64) { + cache.failures = cache.failures.saturating_add(1); + let exp = cache.failures.min(10); + let delay = (BACKOFF_BASE_SECS << (exp - 1)).min(ttl_secs); + cache.backoff_until = Some(now_unix() + delay); + set_state( + CloudFactsState::Failed { + last_error: err.to_string(), + at: now_unix(), + keeping: current_verified_version(), + }, + cache.etag.clone(), + &cache.url, + ); +} + +/// Refresh from the local path or the network and install the result. +/// +/// `force` bypasses the TTL and backoff (manual `/facts refresh`). +pub async fn refresh(settings: &Settings, force: bool) -> Result { + refresh_with_keys(settings, force, codewhale_config::cloud_facts::TRUSTED_KEYS).await +} + +async fn refresh_with_keys( + settings: &Settings, + force: bool, + keys: &[TrustedKey], +) -> Result { + if !settings.enabled { + set_state(CloudFactsState::Off, None, ""); + return Err(RefreshError::Disabled); + } + if !keys + .iter() + .any(|k| k.status == codewhale_config::cloud_facts::KeyStatus::Active) + { + set_state(CloudFactsState::Inert, None, ""); + return Err(RefreshError::Inert); + } + let path = settings.cache_file(); + let mut cache = path + .as_deref() + .and_then(load_cache) + .filter(|c| c.channel == settings.channel) + .unwrap_or_else(|| PersistedCache { + schema_version: CACHE_SCHEMA_VERSION, + channel: settings.channel.clone(), + ..PersistedCache::default() + }); + + let now = now_unix(); + let (bytes, source_label, etag) = if let Some(local) = &settings.local_path { + let bytes = std::fs::read(local).map_err(|err| { + let mapped = RefreshError::Io(err.to_string()); + set_state( + CloudFactsState::Failed { + last_error: mapped.to_string(), + at: now, + keeping: current_verified_version(), + }, + None, + &local.display().to_string(), + ); + mapped + })?; + (bytes, format!("file:{}", local.display()), None) + } else { + if fetch_suppressed() { + return Err(RefreshError::Suppressed); + } + if !force { + if let Some(until) = cache.backoff_until + && now < until + { + return Err(RefreshError::BackingOff { until }); + } + if !cache.envelope.is_empty() + && now.saturating_sub(cache.fetched_at) < settings.ttl_secs + && current_verified_version().is_some() + { + return Ok(RefreshOutcome::Fresh { + facts_version: current_verified_version(), + }); + } + } + let url = settings.url(); + cache.url = url.clone(); + let etag_hint = if cache.envelope.is_empty() { + None + } else { + cache.etag.as_deref() + }; + match fetch(&url, etag_hint).await { + Ok(Fetched::NotModified) => { + cache.fetched_at = now; + cache.failures = 0; + cache.backoff_until = None; + if let Some(path) = &path { + save_cache(path, &cache); + } + // Keep the verified overlay; refresh the timestamp/origin. + if let CloudFactsState::Verified { + channel, + facts_version, + key_id, + patches, + defaults, + announcements, + .. + } = overlay::status().state + { + set_state( + CloudFactsState::Verified { + channel, + facts_version, + key_id, + fetched_at: now, + origin: FactsOrigin::Network, + stale: false, + patches, + defaults, + announcements, + }, + cache.etag.clone(), + &url, + ); + } + return Ok(RefreshOutcome::NotModified { + facts_version: current_verified_version(), + }); + } + Ok(Fetched::NotFound) => { + cache.fetched_at = now; + cache.failures = 0; + cache.backoff_until = None; + cache.envelope.clear(); + cache.etag = None; + if let Some(path) = &path { + save_cache(path, &cache); + } + overlay::set_overlay(None); + set_state(CloudFactsState::BundledOnly, None, &url); + return Ok(RefreshOutcome::NoFacts); + } + Ok(Fetched::Body { bytes, etag }) => (bytes, url, etag), + Err(err) => { + record_failure(&mut cache, &err, settings.ttl_secs); + if let Some(path) = &path { + save_cache(path, &cache); + } + return Err(err); + } + } + }; + + match verify_with(&bytes, settings, cache.highest_seen_version, keys) { + Ok(verified) => { + let origin = if settings.local_path.is_some() { + FactsOrigin::LocalFile + } else { + FactsOrigin::Network + }; + let scoped = install(&verified, origin, now, etag.clone(), &source_label); + cache.fetched_at = now; + cache.etag = etag; + cache.failures = 0; + cache.backoff_until = None; + cache.highest_seen_version = Some( + cache + .highest_seen_version + .map_or(scoped.facts_version, |h| h.max(scoped.facts_version)), + ); + cache.envelope = String::from_utf8_lossy(&bytes).into_owned(); + if let Some(path) = &path { + save_cache(path, &cache); + } + Ok(RefreshOutcome::Updated { + facts_version: scoped.facts_version, + }) + } + Err(FactsRejection::NotApplicable { applies_to }) => { + // Verified, just not for this build. Cache it so the ETag saves a + // round trip; the overlay stays whatever it was. + cache.fetched_at = now; + cache.etag = etag.clone(); + cache.failures = 0; + cache.backoff_until = None; + cache.envelope = String::from_utf8_lossy(&bytes).into_owned(); + if let Some(path) = &path { + save_cache(path, &cache); + } + set_state( + CloudFactsState::NotApplicable { + applies_to: applies_to.clone(), + }, + etag, + &source_label, + ); + Err(RefreshError::Rejected(FactsRejection::NotApplicable { + applies_to, + })) + } + Err(reason) => { + let err = RefreshError::Rejected(reason.clone()); + record_failure(&mut cache, &err, settings.ttl_secs); + if current_verified_version().is_none() { + set_state( + CloudFactsState::Rejected { + reason: reason.to_string(), + at: now, + }, + cache.etag.clone(), + &source_label, + ); + } + if let Some(path) = &path { + save_cache(path, &cache); + } + Err(err) + } + } +} + +/// Best-effort background refresh; never panics, never blocks the caller. +/// +/// `on_update` runs after a new payload is installed (the TUI uses it to +/// invalidate its memoized catalog merge). +pub fn spawn_background_refresh( + settings: Settings, + on_update: Option>, +) { + if !settings.enabled || !has_active_trusted_key() { + return; + } + if settings.local_path.is_none() && fetch_suppressed() { + return; + } + tokio::spawn(async move { + match refresh(&settings, false).await { + Ok(outcome) => { + tracing::debug!(target: "cloud_facts", ?outcome, "cloud facts refreshed"); + if matches!( + outcome, + RefreshOutcome::Updated { .. } | RefreshOutcome::NoFacts + ) && let Some(hook) = on_update + { + hook(); + } + } + Err(err) => { + tracing::debug!(target: "cloud_facts", error = %err, "cloud facts refresh skipped"); + } + } + }); +} + +/// Current status (re-exported for callers that only depend on this crate). +#[must_use] +pub fn status() -> CloudFactsStatus { + overlay::status() +} + +#[cfg(test)] +mod tests; diff --git a/crates/cloud-facts/src/tests.rs b/crates/cloud-facts/src/tests.rs new file mode 100644 index 0000000000..3d250d76df --- /dev/null +++ b/crates/cloud-facts/src/tests.rs @@ -0,0 +1,413 @@ +use std::path::PathBuf; +use std::sync::{Arc, Mutex, MutexGuard, OnceLock}; + +use codewhale_config::cloud_facts::{CloudFactsState, KeyStatus, TrustedKey, overlay}; +use tokio::io::{AsyncReadExt as _, AsyncWriteExt as _}; +use tokio::net::TcpListener; + +use super::*; + +/// Cross-language fixture signed with the TEST-ONLY key. +const FIXTURE_V7: &str = include_str!("../../../docs/cloud-facts/fixtures/envelope-stable-v7.json"); +const FIXTURE_FUTURE_V8: &str = + include_str!("../../../docs/cloud-facts/fixtures/envelope-future-only-v8.json"); + +fn test_keys() -> &'static [TrustedKey] { + static KEYS: OnceLock> = OnceLock::new(); + KEYS.get_or_init(|| { + vec![TrustedKey { + key_id: "cwf-test-only", + public_key: [ + 243, 225, 75, 13, 110, 14, 162, 181, 4, 77, 69, 100, 179, 72, 105, 64, 8, 185, 46, + 62, 48, 131, 121, 35, 42, 55, 216, 23, 50, 219, 39, 181, + ], + status: KeyStatus::Active, + }] + }) +} + +/// The overlay/status are process-wide; serialize tests that touch them. +fn lock() -> MutexGuard<'static, ()> { + static LOCK: OnceLock> = OnceLock::new(); + LOCK.get_or_init(|| Mutex::new(())) + .lock() + .unwrap_or_else(|p| p.into_inner()) +} + +fn settings(dir: &tempfile::TempDir, url: Option) -> Settings { + Settings { + enabled: true, + channel: "stable".into(), + url, + ttl_secs: 3600, + cache_path: Some(dir.path().join("facts").join(CACHE_FILE)), + local_path: None, + } +} + +/// One canned HTTP response per connection; records the request line/headers. +struct MockServer { + url: String, + requests: Arc>>, +} + +/// `(status, headers, body)` canned HTTP response. +type CannedResponse = (u16, Vec<(&'static str, String)>, String); + +async fn mock_server(responses: Vec) -> MockServer { + let listener = TcpListener::bind("127.0.0.1:0").await.expect("bind"); + let addr = listener.local_addr().expect("addr"); + let requests = Arc::new(Mutex::new(Vec::new())); + let seen = Arc::clone(&requests); + tokio::spawn(async move { + let mut responses = responses.into_iter(); + while let Ok((mut stream, _)) = listener.accept().await { + let mut buf = vec![0u8; 8192]; + let n = stream.read(&mut buf).await.unwrap_or(0); + let head = String::from_utf8_lossy(&buf[..n]).into_owned(); + seen.lock().unwrap().push(head); + let (status, headers, body) = + responses + .next() + .unwrap_or((500, vec![], "no more canned responses".into())); + let reason = match status { + 200 => "OK", + 304 => "Not Modified", + 404 => "Not Found", + _ => "Error", + }; + let mut out = format!("HTTP/1.1 {status} {reason}\r\nConnection: close\r\n"); + for (k, v) in headers { + out.push_str(&format!("{k}: {v}\r\n")); + } + out.push_str(&format!("Content-Length: {}\r\n\r\n{}", body.len(), body)); + let _ = stream.write_all(out.as_bytes()).await; + let _ = stream.shutdown().await; + } + }); + MockServer { + url: format!("http://{addr}/api/facts/v1/{{channel}}"), + requests, + } +} + +fn rt() -> tokio::runtime::Runtime { + tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .expect("runtime") +} + +#[test] +fn flag_off_means_no_client_no_file_and_off_status() { + let _lock = lock(); + overlay::clear(); + let dir = tempfile::tempdir().unwrap(); + let mut s = settings(&dir, Some("http://127.0.0.1:1/{channel}".into())); + s.enabled = false; + assert_eq!(maybe_load_persisted_cache_with_keys(&s, test_keys()), None); + assert_eq!(status().state, CloudFactsState::Off); + let err = rt() + .block_on(refresh_with_keys(&s, true, test_keys())) + .unwrap_err(); + assert_eq!(err, RefreshError::Disabled); + assert!( + !dir.path().join("facts").exists(), + "flag off must write nothing" + ); + assert!(overlay::overlay().is_none()); +} + +#[test] +fn enabled_with_no_active_key_is_inert_and_never_fetches() { + let _lock = lock(); + overlay::clear(); + let dir = tempfile::tempdir().unwrap(); + let s = settings(&dir, Some("http://127.0.0.1:1/{channel}".into())); + assert_eq!(maybe_load_persisted_cache_with_keys(&s, &[]), None); + assert_eq!(status().state, CloudFactsState::Inert); + let err = rt().block_on(refresh_with_keys(&s, true, &[])).unwrap_err(); + assert_eq!(err, RefreshError::Inert); + assert!(!dir.path().join("facts").exists()); +} + +#[test] +fn network_200_verifies_installs_caches_and_304_keeps_it() { + let _lock = lock(); + overlay::clear(); + let rt = rt(); + let server = rt.block_on(mock_server(vec![ + ( + 200, + vec![ + ("ETag", "\"stable-v7-abc\"".into()), + ("Content-Type", "application/json".into()), + ], + FIXTURE_V7.into(), + ), + ( + 304, + vec![("ETag", "\"stable-v7-abc\"".into())], + String::new(), + ), + ])); + let dir = tempfile::tempdir().unwrap(); + let s = settings(&dir, Some(server.url.clone())); + + let outcome = rt + .block_on(refresh_with_keys(&s, true, test_keys())) + .unwrap(); + assert_eq!(outcome, RefreshOutcome::Updated { facts_version: 7 }); + let st = status(); + assert!( + matches!( + st.state, + CloudFactsState::Verified { + facts_version: 7, + origin: FactsOrigin::Network, + patches: 5, + defaults: 1, + announcements: 1, + .. + } + ), + "{st:?}" + ); + assert_eq!(st.etag.as_deref(), Some("\"stable-v7-abc\"")); + let overlay = overlay::overlay().expect("overlay installed"); + assert_eq!(overlay.facts_version, 7); + assert_eq!( + overlay::cloud_default_model("deepseek") + .map(|(m, _)| m) + .as_deref(), + Some("deepseek-v4-pro") + ); + + // Cache file exists, is secret-free, and carries the envelope + etag. + let cache = std::fs::read_to_string(s.cache_path.as_ref().unwrap()).unwrap(); + assert!(cache.contains("stable-v7-abc")); + assert!(cache.contains("cwf-test-only")); + for needle in ["api_key", "authorization", "bearer", "password"] { + assert!(!cache.to_lowercase().contains(&format!("\"{needle}\""))); + } + + // Second fetch sends If-None-Match and keeps the overlay on 304. + let outcome = rt + .block_on(refresh_with_keys(&s, true, test_keys())) + .unwrap(); + assert_eq!( + outcome, + RefreshOutcome::NotModified { + facts_version: Some(7) + } + ); + let requests = server.requests.lock().unwrap(); + assert_eq!(requests.len(), 2); + assert!( + requests[1] + .to_lowercase() + .contains("if-none-match: \"stable-v7-abc\""), + "{}", + requests[1] + ); + for req in requests.iter() { + assert!( + req.contains(&format!("User-Agent: {USER_AGENT}")) + || req.to_lowercase().contains("user-agent: codewhale/") + ); + assert!(!req.to_lowercase().contains("cookie")); + assert!( + req.lines() + .next() + .unwrap() + .contains("/api/facts/v1/stable HTTP/1.1"), + "{}", + req.lines().next().unwrap() + ); + } + assert!(matches!( + status().state, + CloudFactsState::Verified { + facts_version: 7, + .. + } + )); + overlay::clear(); +} + +#[test] +fn persisted_cache_round_trips_and_a_tampered_cache_is_rejected_and_deleted() { + let _lock = lock(); + overlay::clear(); + let rt = rt(); + let server = rt.block_on(mock_server(vec![(200, vec![], FIXTURE_V7.into())])); + let dir = tempfile::tempdir().unwrap(); + let s = settings(&dir, Some(server.url.clone())); + rt.block_on(refresh_with_keys(&s, true, test_keys())) + .unwrap(); + overlay::clear(); + + // Fresh process: the cache seeds the overlay without a network call. + assert_eq!( + maybe_load_persisted_cache_with_keys(&s, test_keys()), + Some(7) + ); + assert!(matches!( + status().state, + CloudFactsState::Verified { + origin: FactsOrigin::DiskCache, + .. + } + )); + assert!(overlay::overlay().is_some()); + overlay::clear(); + + // Tamper one payload byte on disk. + let path = s.cache_path.clone().unwrap(); + let text = std::fs::read_to_string(&path).unwrap(); + let mut cache: serde_json::Value = serde_json::from_str(&text).unwrap(); + let env = cache["envelope"] + .as_str() + .unwrap() + .replace("\"facts_version\": 7", "\"facts_version\": 9"); + cache["envelope"] = serde_json::Value::String(env); + std::fs::write(&path, serde_json::to_vec(&cache).unwrap()).unwrap(); + assert_eq!(maybe_load_persisted_cache_with_keys(&s, test_keys()), None); + assert!( + matches!(status().state, CloudFactsState::Rejected { .. }), + "{:?}", + status().state + ); + assert!(!path.exists(), "tampered cache must be deleted"); + assert!(overlay::overlay().is_none()); +} + +#[test] +fn local_path_loads_without_network_and_scope_rejection_is_reported() { + let _lock = lock(); + overlay::clear(); + let dir = tempfile::tempdir().unwrap(); + let local = dir.path().join("envelope.json"); + std::fs::write(&local, FIXTURE_V7).unwrap(); + let mut s = settings(&dir, Some("http://127.0.0.1:1/{channel}".into())); + s.local_path = Some(local.clone()); + let outcome = rt() + .block_on(refresh_with_keys(&s, true, test_keys())) + .unwrap(); + assert_eq!(outcome, RefreshOutcome::Updated { facts_version: 7 }); + assert!(matches!( + status().state, + CloudFactsState::Verified { + origin: FactsOrigin::LocalFile, + .. + } + )); + + std::fs::write(&local, FIXTURE_FUTURE_V8).unwrap(); + let err = rt() + .block_on(refresh_with_keys(&s, true, test_keys())) + .unwrap_err(); + assert!(matches!( + err, + RefreshError::Rejected(FactsRejection::NotApplicable { .. }) + )); + assert!(matches!( + status().state, + CloudFactsState::NotApplicable { .. } + )); + overlay::clear(); +} + +#[test] +fn server_errors_keep_prior_facts_and_persist_backoff() { + let _lock = lock(); + overlay::clear(); + let rt = rt(); + let server = rt.block_on(mock_server(vec![ + (200, vec![], FIXTURE_V7.into()), + (500, vec![], "boom".into()), + (200, vec![], "x".repeat(MAX_BODY_BYTES + 1)), + ])); + let dir = tempfile::tempdir().unwrap(); + let s = settings(&dir, Some(server.url.clone())); + rt.block_on(refresh_with_keys(&s, true, test_keys())) + .unwrap(); + + let err = rt + .block_on(refresh_with_keys(&s, true, test_keys())) + .unwrap_err(); + assert_eq!(err, RefreshError::HttpStatus(500)); + assert!( + matches!( + status().state, + CloudFactsState::Failed { + keeping: Some(7), + .. + } + ), + "{:?}", + status().state + ); + assert!( + overlay::overlay().is_some(), + "prior verified facts survive a failure" + ); + + // Backoff is persisted and honoured by non-forced refreshes. + let cache: serde_json::Value = + serde_json::from_str(&std::fs::read_to_string(s.cache_path.as_ref().unwrap()).unwrap()) + .unwrap(); + assert!(cache["backoff_until"].as_u64().unwrap() > now_unix()); + let err = rt + .block_on(refresh_with_keys(&s, false, test_keys())) + .unwrap_err(); + assert!(matches!(err, RefreshError::BackingOff { .. })); + + // Oversized body is refused before verification. + let err = rt + .block_on(refresh_with_keys(&s, true, test_keys())) + .unwrap_err(); + assert!(matches!(err, RefreshError::TooLarge(_))); + assert!(overlay::overlay().is_some()); + overlay::clear(); +} + +#[test] +fn not_found_means_no_facts_not_failure() { + let _lock = lock(); + overlay::clear(); + let rt = rt(); + let server = rt.block_on(mock_server(vec![( + 404, + vec![], + "{\"error\":\"no-facts\"}".into(), + )])); + let dir = tempfile::tempdir().unwrap(); + let s = settings(&dir, Some(server.url.clone())); + let outcome = rt + .block_on(refresh_with_keys(&s, true, test_keys())) + .unwrap(); + assert_eq!(outcome, RefreshOutcome::NoFacts); + assert_eq!(status().state, CloudFactsState::BundledOnly); + assert!(overlay::overlay().is_none()); +} + +#[test] +fn settings_resolve_url_template_and_channel_validation() { + let s = Settings { + channel: "beta".into(), + ..Settings::default() + }; + assert_eq!(s.url(), "https://codewhale.net/api/facts/v1/beta"); + assert!(valid_channel("stable")); + assert!(valid_channel("beta-2")); + assert!(!valid_channel("-bad")); + assert!(!valid_channel("Stable")); + assert!(!valid_channel("")); + assert_eq!( + Settings::default().cache_path.as_deref(), + None::<&std::path::Path>, + "default settings resolve the cache under CODEWHALE_HOME" + ); + let _ = PathBuf::new(); +} diff --git a/crates/config/Cargo.toml b/crates/config/Cargo.toml index bc3c8ad652..68c74bfdd5 100644 --- a/crates/config/Cargo.toml +++ b/crates/config/Cargo.toml @@ -12,11 +12,15 @@ workspace = true [dependencies] anyhow.workspace = true +base64 = "0.22.1" codewhale-execpolicy = { path = "../execpolicy", version = "0.9.11" } codewhale-paths = { path = "../paths", version = "0.9.11" } codewhale-secrets = { path = "../secrets", version = "0.9.11" } fd-lock = "4.0.4" libc = "0.2" +# Ed25519 verification for cloud facts; already in the graph via rustls. +ring = "0.17.14" +semver.workspace = true serde.workspace = true serde_json.workspace = true sha2.workspace = true diff --git a/crates/config/src/catalog.rs b/crates/config/src/catalog.rs index c37bc5d4f6..5c066ada34 100644 --- a/crates/config/src/catalog.rs +++ b/crates/config/src/catalog.rs @@ -61,6 +61,13 @@ pub enum CatalogSource { ModelsDevLive { fetched_at: u64 }, /// `config.toml` `[providers.*]` override (layer 30). ConfigOverride, + /// Verified cloud facts patch (layer 15): above bundled / live models.dev, + /// below provider `/v1/models`, config, and user rows. + CloudFacts { + facts_version: u64, + key_id: String, + fetched_at: u64, + }, } /// One catalog-layer offering row. @@ -627,6 +634,7 @@ impl CatalogSnapshot { /// ```text /// 0 bundled committed models.dev-shaped snapshot /// 10 live models.dev models.dev refresh +/// 15 cloud facts verified, signed field-level patches (off by default) /// 20 provider per-provider /v1/models refresh /// 30 config config.toml [providers.*] overrides /// 40 user user approved set (Phase 3 hook; empty in Phase 1) @@ -641,6 +649,7 @@ pub struct CatalogCompiler { bundled: Vec, models_dev_live: Vec, live: Vec, + cloud_facts: Option<(crate::cloud_facts::ScopedFacts, u64)>, provider_live: Vec, config: Vec, overrides: Vec, @@ -687,6 +696,18 @@ impl CatalogCompiler { self } + /// Apply verified cloud facts patches (layer 15). `fetched_at` stamps the + /// resulting rows' provenance. + #[must_use] + pub fn with_cloud_facts( + mut self, + facts: &crate::cloud_facts::ScopedFacts, + fetched_at: u64, + ) -> Self { + self.cloud_facts = Some((facts.clone(), fetched_at)); + self + } + /// Add per-provider `/v1/models` refresh rows (layer 20). #[must_use] pub fn with_provider_live(mut self, rows: Vec) -> Self { @@ -724,7 +745,19 @@ impl CatalogCompiler { .into_iter() .chain(self.models_dev_live) .chain(self.live) - .chain(self.provider_live) + { + merged.insert(row.merge_key(), row); + } + if let Some((facts, fetched_at)) = &self.cloud_facts { + let _skipped = crate::cloud_facts::catalog_patch::apply_model_patches( + &mut merged, + facts, + *fetched_at, + ); + } + for row in self + .provider_live + .into_iter() .chain(self.config) .chain(self.overrides) { diff --git a/crates/config/src/cloud_facts/catalog_patch.rs b/crates/config/src/cloud_facts/catalog_patch.rs new file mode 100644 index 0000000000..135d365626 --- /dev/null +++ b/crates/config/src/cloud_facts/catalog_patch.rs @@ -0,0 +1,149 @@ +//! Apply cloud model patches to a catalog layer map (layer 15: above bundled +//! and live models.dev, below provider `/v1/models`, config, and user rows). +//! +//! Patch semantics: +//! - `Upsert`: only the fields the patch sets shadow the row; a patch for a +//! row that does not exist is materialized only when it carries a context +//! window (otherwise skipped with a receipt). +//! - `Deprecate`: annotates (the note is carried in `reasoning_options` as a +//! `{"cloud_facts": {...}}` marker); never removes. +//! - `Hide`: removes the row only when it came from the bundled or live +//! models.dev layers. Provider-live/config/user rows are never hidden. + +use std::collections::BTreeMap; + +use serde_json::json; + +use super::scope::ScopedFacts; +use super::types::{ModelFact, ModelOp}; +use crate::catalog::{CatalogOffering, CatalogSource}; +use crate::models_dev::ModelsDevCost; + +/// Merge key used by the catalog compiler. +type Key = (String, String); + +/// Receipt for one patch that changed nothing. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct SkippedPatch { + pub provider: String, + pub id: String, + pub reason: String, +} + +/// Apply every model patch in `facts` to `rows`, returning skip receipts. +pub fn apply_model_patches( + rows: &mut BTreeMap, + facts: &ScopedFacts, + fetched_at: u64, +) -> Vec { + let mut skipped = Vec::new(); + let source = CatalogSource::CloudFacts { + facts_version: facts.facts_version, + key_id: facts.key_id.clone(), + fetched_at, + }; + for patch in &facts.models { + let key = (patch.provider.clone(), patch.id.clone()); + match patch.op { + ModelOp::Hide => match rows.get(&key) { + Some(row) + if matches!( + row.source, + CatalogSource::Bundled | CatalogSource::ModelsDevLive { .. } + ) => + { + rows.remove(&key); + } + Some(_) => skipped.push(SkippedPatch { + provider: patch.provider.clone(), + id: patch.id.clone(), + reason: "hide ignored: row comes from a higher layer".into(), + }), + None => skipped.push(SkippedPatch { + provider: patch.provider.clone(), + id: patch.id.clone(), + reason: "hide ignored: no such row".into(), + }), + }, + ModelOp::Deprecate => match rows.get_mut(&key) { + Some(row) => { + annotate(row, patch, "deprecated"); + } + None => skipped.push(SkippedPatch { + provider: patch.provider.clone(), + id: patch.id.clone(), + reason: "deprecate ignored: no such row".into(), + }), + }, + ModelOp::Upsert => { + if let Some(row) = rows.get_mut(&key) { + patch_fields(row, patch); + row.source = source.clone(); + } else if patch.context_window.is_some() { + let mut row = CatalogOffering { + provider: patch.provider.clone(), + wire_model_id: patch.id.clone(), + endpoint_key: "chat".to_string(), + source: source.clone(), + ..CatalogOffering::default() + }; + patch_fields(&mut row, patch); + rows.insert(key, row); + } else { + skipped.push(SkippedPatch { + provider: patch.provider.clone(), + id: patch.id.clone(), + reason: "upsert ignored: new row needs context_window".into(), + }); + } + } + } + } + skipped +} + +fn patch_fields(row: &mut CatalogOffering, patch: &ModelFact) { + if patch.context_window.is_some() || patch.max_output.is_some() { + let mut limit = row.limit.clone().unwrap_or_default(); + if let Some(context) = patch.context_window { + limit.context = Some(context); + } + if let Some(output) = patch.max_output { + limit.output = Some(output); + } + row.limit = Some(limit); + } + if let Some(pricing) = &patch.pricing { + let mut cost: ModelsDevCost = row.cost.clone().unwrap_or_default(); + if pricing.input_per_m.is_some() { + cost.input = pricing.input_per_m; + } + if pricing.output_per_m.is_some() { + cost.output = pricing.output_per_m; + } + if pricing.cache_read_per_m.is_some() { + cost.cache_read = pricing.cache_read_per_m; + } + row.cost = Some(cost); + } + if patch.reasoning.is_some() { + row.reasoning = patch.reasoning; + } + if patch.display_name.is_some() || patch.note.is_some() { + annotate(row, patch, "upsert"); + } +} + +fn annotate(row: &mut CatalogOffering, patch: &ModelFact, kind: &str) { + row.reasoning_options + .retain(|value| value.get("cloud_facts").is_none()); + row.reasoning_options.push(json!({ + "cloud_facts": { + "op": kind, + "display_name": patch.display_name, + "deprecated_at": patch.deprecated_at, + "replacement": patch.replacement, + "note": patch.note, + } + })); +} diff --git a/crates/config/src/cloud_facts/keys.rs b/crates/config/src/cloud_facts/keys.rs new file mode 100644 index 0000000000..1db138b567 --- /dev/null +++ b/crates/config/src/cloud_facts/keys.rs @@ -0,0 +1,70 @@ +//! Trust anchors for the cloud facts channel. +//! +//! Keys are pinned in the binary. The Supabase `facts_key` table and the +//! website mirror (`web/lib/cloud-facts/keys.ts`) are informational; a facts +//! envelope is accepted only when its signature verifies under an `Active` key +//! listed here. `web/scripts/check-cloud-facts.mjs` fails CI if this table and +//! the TypeScript mirror diverge. +//! +//! Rotation (two-release rule): pin the new key here → ship → sign with both +//! keys (`sigs`) → mark the old key `Retired` → ship → drop it. Compromise: +//! revoke every release signed by the key server-side, ship a binary without +//! the key. There is deliberately no in-band "distrust this key" message. + +/// Domain separator prefixed to every signed message. +/// +/// Message = `DOMAIN || key_id || 0x00 || payload_bytes`. +pub const DOMAIN: &[u8] = b"codewhale-facts/v1\0"; + +/// Transport envelope version this client understands. +pub const ENVELOPE_VERSION: u64 = 1; + +/// Highest signed-payload `schema_version` this client understands. Newer +/// payloads are rejected as `SchemaTooNew` and the bundled facts stay in use. +pub const SUPPORTED_SCHEMA_VERSION: u32 = 1; + +/// Hard cap on the decoded payload; enforced before any crypto runs. +pub const MAX_PAYLOAD_BYTES: usize = 512 * 1024; + +/// Hard cap on the raw envelope document (payload base64 + metadata). +pub const MAX_ENVELOPE_BYTES: usize = 768 * 1024; + +/// Whether a pinned key may still authenticate new releases. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum KeyStatus { + /// Accepts signatures. + Active, + /// Still listed so `/status` can name it, but no longer accepted. + Retired, +} + +/// One pinned Ed25519 verifying key. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct TrustedKey { + /// `cwf-