From 886d9377d3996084b38f35d34846c64e47d60b3d Mon Sep 17 00:00:00 2001 From: gt-oai Date: Wed, 11 Feb 2026 14:06:41 +0000 Subject: [PATCH] Cache cloud requirements (#11305) We're loading these from the web on every startup. This puts them in a local file with a 1hr TTL. We sign the downloaded requirements with a key compiled into the Codex CLI to prevent unsophisticated tampering (determined circumvention is outside of our threat model: after all, one could just compile Codex without any of these checks). If any of the following are true, we ignore the local cache and re-fetch from Cloud: * The signature is invalid for the payload (== requirements, sign time, ttl, user identity) * The identity does not match the auth'd user's identity * The TTL has expired * We cannot parse requirements.toml from the payload --- codex-rs/Cargo.lock | 5 + .../app-server/src/codex_message_processor.rs | 8 +- codex-rs/app-server/src/lib.rs | 6 +- codex-rs/cloud-requirements/Cargo.toml | 11 +- codex-rs/cloud-requirements/src/lib.rs | 655 +++++++++++++++++- codex-rs/exec/src/lib.rs | 3 +- codex-rs/tui/src/lib.rs | 7 +- 7 files changed, 666 insertions(+), 29 deletions(-) diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index 913fa21c3..9481950fa 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -1567,13 +1567,18 @@ version = "0.0.0" dependencies = [ "async-trait", "base64 0.22.1", + "chrono", "codex-backend-client", "codex-core", "codex-otel", "codex-protocol", + "hmac", "pretty_assertions", + "serde", "serde_json", + "sha2", "tempfile", + "thiserror 2.0.18", "tokio", "toml 0.9.12+spec-1.1.0", "tracing", diff --git a/codex-rs/app-server/src/codex_message_processor.rs b/codex-rs/app-server/src/codex_message_processor.rs index c44b5e83d..cd1c8416c 100644 --- a/codex-rs/app-server/src/codex_message_processor.rs +++ b/codex-rs/app-server/src/codex_message_processor.rs @@ -905,6 +905,7 @@ impl CodexMessageProcessor { let auth_manager = self.auth_manager.clone(); let cloud_requirements = self.cloud_requirements.clone(); let chatgpt_base_url = self.config.chatgpt_base_url.clone(); + let codex_home = self.config.codex_home.clone(); let cli_overrides = self.cli_overrides.clone(); let auth_url = server.auth_url.clone(); tokio::spawn(async move { @@ -939,6 +940,7 @@ impl CodexMessageProcessor { cloud_requirements.as_ref(), auth_manager.clone(), chatgpt_base_url, + codex_home.clone(), ); sync_default_client_residency_requirement( &cli_overrides, @@ -1011,6 +1013,7 @@ impl CodexMessageProcessor { let auth_manager = self.auth_manager.clone(); let cloud_requirements = self.cloud_requirements.clone(); let chatgpt_base_url = self.config.chatgpt_base_url.clone(); + let codex_home = self.config.codex_home.clone(); let cli_overrides = self.cli_overrides.clone(); let auth_url = server.auth_url.clone(); tokio::spawn(async move { @@ -1045,6 +1048,7 @@ impl CodexMessageProcessor { cloud_requirements.as_ref(), auth_manager.clone(), chatgpt_base_url, + codex_home.clone(), ); sync_default_client_residency_requirement( &cli_overrides, @@ -1212,6 +1216,7 @@ impl CodexMessageProcessor { self.cloud_requirements.as_ref(), self.auth_manager.clone(), self.config.chatgpt_base_url.clone(), + self.config.codex_home.clone(), ); sync_default_client_residency_requirement( &self.cli_overrides, @@ -5516,8 +5521,9 @@ fn replace_cloud_requirements_loader( cloud_requirements: &RwLock, auth_manager: Arc, chatgpt_base_url: String, + codex_home: std::path::PathBuf, ) { - let loader = cloud_requirements_loader(auth_manager, chatgpt_base_url); + let loader = cloud_requirements_loader(auth_manager, chatgpt_base_url, codex_home); if let Ok(mut guard) = cloud_requirements.write() { *guard = loader; } else { diff --git a/codex-rs/app-server/src/lib.rs b/codex-rs/app-server/src/lib.rs index 0c3af0ea8..5b412d257 100644 --- a/codex-rs/app-server/src/lib.rs +++ b/codex-rs/app-server/src/lib.rs @@ -237,7 +237,11 @@ pub async fn run_main( false, config.cli_auth_credentials_store_mode, ); - cloud_requirements_loader(auth_manager, config.chatgpt_base_url) + cloud_requirements_loader( + auth_manager, + config.chatgpt_base_url, + config.codex_home.clone(), + ) } Err(err) => { warn!(error = %err, "Failed to preload config for cloud requirements"); diff --git a/codex-rs/cloud-requirements/Cargo.toml b/codex-rs/cloud-requirements/Cargo.toml index 071c98b9b..bbaa3a6db 100644 --- a/codex-rs/cloud-requirements/Cargo.toml +++ b/codex-rs/cloud-requirements/Cargo.toml @@ -9,17 +9,22 @@ workspace = true [dependencies] async-trait = { workspace = true } +base64 = { workspace = true } +chrono = { workspace = true, features = ["serde"] } codex-backend-client = { workspace = true } codex-core = { workspace = true } codex-otel = { workspace = true } codex-protocol = { workspace = true } -tokio = { workspace = true, features = ["sync", "time"] } +hmac = "0.12.1" +serde = { workspace = true, features = ["derive"] } +serde_json = { workspace = true } +sha2 = { workspace = true } +thiserror = { workspace = true } +tokio = { workspace = true, features = ["fs", "sync", "time"] } toml = { workspace = true } tracing = { workspace = true } [dev-dependencies] -base64 = { workspace = true } pretty_assertions = { workspace = true } -serde_json = { workspace = true } tempfile = { workspace = true } tokio = { workspace = true, features = ["macros", "rt", "test-util", "time"] } diff --git a/codex-rs/cloud-requirements/src/lib.rs b/codex-rs/cloud-requirements/src/lib.rs index 36787e98f..6f6bf3b6d 100644 --- a/codex-rs/cloud-requirements/src/lib.rs +++ b/codex-rs/cloud-requirements/src/lib.rs @@ -9,6 +9,11 @@ //! requirements before Codex will run. use async_trait::async_trait; +use base64::Engine; +use base64::engine::general_purpose::STANDARD as BASE64_STANDARD; +use chrono::DateTime; +use chrono::Duration as ChronoDuration; +use chrono::Utc; use codex_backend_client::Client as BackendClient; use codex_core::AuthManager; use codex_core::auth::CodexAuth; @@ -16,20 +21,118 @@ use codex_core::config_loader::CloudRequirementsLoader; use codex_core::config_loader::ConfigRequirementsToml; use codex_core::util::backoff; use codex_protocol::account::PlanType; +use hmac::Hmac; +use hmac::Mac; +use serde::Deserialize; +use serde::Serialize; +use sha2::Sha256; +use std::path::PathBuf; use std::sync::Arc; use std::time::Duration; use std::time::Instant; +use thiserror::Error; +use tokio::fs; use tokio::time::sleep; use tokio::time::timeout; const CLOUD_REQUIREMENTS_TIMEOUT: Duration = Duration::from_secs(15); const CLOUD_REQUIREMENTS_MAX_ATTEMPTS: usize = 5; +const CLOUD_REQUIREMENTS_CACHE_FILENAME: &str = "cloud-requirements-cache.json"; +const CLOUD_REQUIREMENTS_CACHE_TTL: Duration = Duration::from_secs(60 * 60); +const CLOUD_REQUIREMENTS_CACHE_WRITE_HMAC_KEY: &[u8] = + b"codex-cloud-requirements-cache-v3-064f8542-75b4-494c-a294-97d3ce597271"; +const CLOUD_REQUIREMENTS_CACHE_READ_HMAC_KEYS: &[&[u8]] = + &[CLOUD_REQUIREMENTS_CACHE_WRITE_HMAC_KEY]; + +type HmacSha256 = Hmac; #[derive(Clone, Copy, Debug, Eq, PartialEq)] enum FetchCloudRequirementsStatus { BackendClientInit, Request, - Parse, +} + +#[derive(Clone, Debug, Eq, Error, PartialEq)] +enum CacheLoadStatus { + #[error("Skipping cloud requirements cache read because auth identity is incomplete.")] + AuthIdentityIncomplete, + #[error("Cloud requirements cache file not found.")] + CacheFileNotFound, + #[error("Failed to read cloud requirements cache: {0}.")] + CacheReadFailed(String), + #[error("Failed to parse cloud requirements cache: {0}.")] + CacheParseFailed(String), + #[error("Cloud requirements cache failed signature verification.")] + CacheSignatureInvalid, + #[error("Ignoring cloud requirements cache because cached identity is incomplete.")] + CacheIdentityIncomplete, + #[error("Ignoring cloud requirements cache for different auth identity.")] + CacheIdentityMismatch, + #[error("Cloud requirements cache expired.")] + CacheExpired, +} + +#[derive(Debug, Error)] +enum CloudRequirementsError { + #[error("failed to write cloud requirements cache")] + CacheWrite, +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +struct CloudRequirementsCacheFile { + signed_payload: CloudRequirementsCacheSignedPayload, + signature: String, +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +struct CloudRequirementsCacheSignedPayload { + cached_at: DateTime, + expires_at: DateTime, + chatgpt_user_id: Option, + account_id: Option, + contents: Option, +} + +impl CloudRequirementsCacheSignedPayload { + fn requirements(&self) -> Option { + self.contents + .as_deref() + .and_then(|contents| parse_cloud_requirements(contents).ok().flatten()) + } +} +fn sign_cache_payload(payload_bytes: &[u8]) -> Option { + let mut mac = HmacSha256::new_from_slice(CLOUD_REQUIREMENTS_CACHE_WRITE_HMAC_KEY).ok()?; + mac.update(payload_bytes); + let signature = mac.finalize().into_bytes(); + Some(BASE64_STANDARD.encode(signature)) +} + +fn verify_cache_signature_with_key( + payload_bytes: &[u8], + signature_bytes: &[u8], + key: &[u8], +) -> bool { + let mut mac = match HmacSha256::new_from_slice(key) { + Ok(mac) => mac, + Err(_) => return false, + }; + mac.update(payload_bytes); + mac.verify_slice(signature_bytes).is_ok() +} + +fn verify_cache_signature(payload_bytes: &[u8], signature: &str) -> bool { + let signature_bytes = match BASE64_STANDARD.decode(signature) { + Ok(signature_bytes) => signature_bytes, + Err(_) => return false, + }; + + CLOUD_REQUIREMENTS_CACHE_READ_HMAC_KEYS + .iter() + .any(|key| verify_cache_signature_with_key(payload_bytes, &signature_bytes, key)) +} + +fn cache_payload_bytes(payload: &CloudRequirementsCacheSignedPayload) -> Option> { + serde_json::to_vec(&payload).ok() } #[async_trait] @@ -88,6 +191,7 @@ impl RequirementsFetcher for BackendRequirementsFetcher { struct CloudRequirementsService { auth_manager: Arc, fetcher: Arc, + cache_path: PathBuf, timeout: Duration, } @@ -95,11 +199,13 @@ impl CloudRequirementsService { fn new( auth_manager: Arc, fetcher: Arc, + codex_home: PathBuf, timeout: Duration, ) -> Self { Self { auth_manager, fetcher, + cache_path: codex_home.join(CLOUD_REQUIREMENTS_CACHE_FILENAME), timeout, } } @@ -144,27 +250,39 @@ impl CloudRequirementsService { { return None; } + let token_data = auth.get_token_data().ok(); + let chatgpt_user_id = token_data + .as_ref() + .and_then(|token_data| token_data.id_token.chatgpt_user_id.as_deref()); + let account_id = auth.get_account_id(); + let account_id = account_id.as_deref(); - self.fetch_with_retries(&auth).await + match self.load_cache(chatgpt_user_id, account_id).await { + Ok(signed_payload) => { + tracing::info!( + path = %self.cache_path.display(), + "Using cached cloud requirements" + ); + return signed_payload.requirements(); + } + Err(cache_load_status) => { + self.log_cache_load_status(&cache_load_status); + } + } + + self.fetch_with_retries(&auth, chatgpt_user_id, account_id) + .await? } - async fn fetch_with_retries(&self, auth: &CodexAuth) -> Option { + async fn fetch_with_retries( + &self, + auth: &CodexAuth, + chatgpt_user_id: Option<&str>, + account_id: Option<&str>, + ) -> Option> { for attempt in 1..=CLOUD_REQUIREMENTS_MAX_ATTEMPTS { - let fetch_result = self - .fetcher - .fetch_requirements(auth) - .await - .and_then(|contents| { - contents.map_or(Ok(None), |contents| { - parse_cloud_requirements(&contents).map_err(|err| { - tracing::warn!(error = %err, "Failed to parse cloud requirements"); - FetchCloudRequirementsStatus::Parse - }) - }) - }); - - match fetch_result { - Ok(requirements) => return requirements, + let contents = match self.fetcher.fetch_requirements(auth).await { + Ok(contents) => contents, Err(status) => { if attempt < CLOUD_REQUIREMENTS_MAX_ATTEMPTS { tracing::warn!( @@ -175,21 +293,163 @@ impl CloudRequirementsService { ); sleep(backoff(attempt as u64)).await; } + continue; } + }; + + let requirements = match contents.as_deref() { + Some(contents) => match parse_cloud_requirements(contents) { + Ok(requirements) => requirements, + Err(err) => { + tracing::warn!(error = %err, "Failed to parse cloud requirements"); + return None; + } + }, + None => None, + }; + + if let Err(err) = self + .save_cache( + chatgpt_user_id.map(str::to_owned), + account_id.map(str::to_owned), + contents, + ) + .await + { + tracing::warn!(error = %err, "Failed to write cloud requirements cache"); } + + return Some(requirements); } None } + + async fn load_cache( + &self, + chatgpt_user_id: Option<&str>, + account_id: Option<&str>, + ) -> Result { + let (Some(chatgpt_user_id), Some(account_id)) = (chatgpt_user_id, account_id) else { + return Err(CacheLoadStatus::AuthIdentityIncomplete); + }; + + let bytes = match fs::read(&self.cache_path).await { + Ok(bytes) => bytes, + Err(err) => { + if err.kind() != std::io::ErrorKind::NotFound { + return Err(CacheLoadStatus::CacheReadFailed(err.to_string())); + } + return Err(CacheLoadStatus::CacheFileNotFound); + } + }; + + let cache_file: CloudRequirementsCacheFile = match serde_json::from_slice(&bytes) { + Ok(cache_file) => cache_file, + Err(err) => { + return Err(CacheLoadStatus::CacheParseFailed(err.to_string())); + } + }; + let payload_bytes = match cache_payload_bytes(&cache_file.signed_payload) { + Some(payload_bytes) => payload_bytes, + None => { + return Err(CacheLoadStatus::CacheParseFailed( + "failed to serialize cache payload".to_string(), + )); + } + }; + if !verify_cache_signature(&payload_bytes, &cache_file.signature) { + return Err(CacheLoadStatus::CacheSignatureInvalid); + } + + let (Some(cached_chatgpt_user_id), Some(cached_account_id)) = ( + cache_file.signed_payload.chatgpt_user_id.as_deref(), + cache_file.signed_payload.account_id.as_deref(), + ) else { + return Err(CacheLoadStatus::CacheIdentityIncomplete); + }; + + if cached_chatgpt_user_id != chatgpt_user_id || cached_account_id != account_id { + return Err(CacheLoadStatus::CacheIdentityMismatch); + } + + if cache_file.signed_payload.expires_at <= Utc::now() { + return Err(CacheLoadStatus::CacheExpired); + } + + Ok(cache_file.signed_payload) + } + + fn log_cache_load_status(&self, status: &CacheLoadStatus) { + if matches!(status, CacheLoadStatus::CacheFileNotFound) { + return; + } + + let warn = matches!( + status, + CacheLoadStatus::CacheReadFailed(_) + | CacheLoadStatus::CacheParseFailed(_) + | CacheLoadStatus::CacheSignatureInvalid + ); + + if warn { + tracing::warn!(path = %self.cache_path.display(), "{status}"); + } else { + tracing::info!(path = %self.cache_path.display(), "{status}"); + } + } + + async fn save_cache( + &self, + chatgpt_user_id: Option, + account_id: Option, + contents: Option, + ) -> Result<(), CloudRequirementsError> { + let now = Utc::now(); + let expires_at = now + .checked_add_signed( + ChronoDuration::from_std(CLOUD_REQUIREMENTS_CACHE_TTL) + .map_err(|_| CloudRequirementsError::CacheWrite)?, + ) + .ok_or(CloudRequirementsError::CacheWrite)?; + let signed_payload = CloudRequirementsCacheSignedPayload { + cached_at: now, + expires_at, + chatgpt_user_id, + account_id, + contents, + }; + let payload_bytes = + cache_payload_bytes(&signed_payload).ok_or(CloudRequirementsError::CacheWrite)?; + let serialized = serde_json::to_vec_pretty(&CloudRequirementsCacheFile { + signature: sign_cache_payload(&payload_bytes) + .ok_or(CloudRequirementsError::CacheWrite)?, + signed_payload, + }) + .map_err(|_| CloudRequirementsError::CacheWrite)?; + + if let Some(parent) = self.cache_path.parent() { + fs::create_dir_all(parent) + .await + .map_err(|_| CloudRequirementsError::CacheWrite)?; + } + + fs::write(&self.cache_path, serialized) + .await + .map_err(|_| CloudRequirementsError::CacheWrite)?; + Ok(()) + } } pub fn cloud_requirements_loader( auth_manager: Arc, chatgpt_base_url: String, + codex_home: PathBuf, ) -> CloudRequirementsLoader { let service = CloudRequirementsService::new( auth_manager, Arc::new(BackendRequirementsFetcher::new(chatgpt_base_url)), + codex_home, CLOUD_REQUIREMENTS_TIMEOUT, ); let task = tokio::spawn(async move { service.fetch_with_timeout().await }); @@ -252,13 +512,17 @@ mod tests { )) } - fn auth_manager_with_plan(plan_type: &str) -> Arc { + fn auth_manager_with_plan_and_identity( + plan_type: &str, + chatgpt_user_id: Option<&str>, + account_id: Option<&str>, + ) -> Arc { let tmp = tempdir().expect("tempdir"); let header = json!({ "alg": "none", "typ": "JWT" }); let auth_payload = json!({ "chatgpt_plan_type": plan_type, - "chatgpt_user_id": "user-12345", - "user_id": "user-12345", + "chatgpt_user_id": chatgpt_user_id, + "user_id": chatgpt_user_id, }); let payload = json!({ "email": "user@example.com", @@ -275,8 +539,9 @@ mod tests { "id_token": fake_jwt, "access_token": "test-access-token", "refresh_token": "test-refresh-token", + "account_id": account_id, }, - "last_refresh": null, + "last_refresh": "2025-01-01T00:00:00Z", }); write_auth_json(tmp.path(), auth_json).expect("write auth"); Arc::new(AuthManager::new( @@ -286,6 +551,10 @@ mod tests { )) } + fn auth_manager_with_plan(plan_type: &str) -> Arc { + auth_manager_with_plan_and_identity(plan_type, Some("user-12345"), Some("account-12345")) + } + fn parse_for_fetch(contents: Option<&str>) -> Option { contents.and_then(|contents| parse_cloud_requirements(contents).ok().flatten()) } @@ -347,9 +616,11 @@ mod tests { #[tokio::test] async fn fetch_cloud_requirements_skips_non_chatgpt_auth() { let auth_manager = auth_manager_with_api_key(); + let codex_home = tempdir().expect("tempdir"); let service = CloudRequirementsService::new( auth_manager, Arc::new(StaticFetcher { contents: None }), + codex_home.path().to_path_buf(), CLOUD_REQUIREMENTS_TIMEOUT, ); let result = service.fetch().await; @@ -358,9 +629,11 @@ mod tests { #[tokio::test] async fn fetch_cloud_requirements_skips_non_business_or_enterprise_plan() { + let codex_home = tempdir().expect("tempdir"); let service = CloudRequirementsService::new( auth_manager_with_plan("pro"), Arc::new(StaticFetcher { contents: None }), + codex_home.path().to_path_buf(), CLOUD_REQUIREMENTS_TIMEOUT, ); let result = service.fetch().await; @@ -369,11 +642,13 @@ mod tests { #[tokio::test] async fn fetch_cloud_requirements_allows_business_plan() { + let codex_home = tempdir().expect("tempdir"); let service = CloudRequirementsService::new( auth_manager_with_plan("business"), Arc::new(StaticFetcher { contents: Some("allowed_approval_policies = [\"never\"]".to_string()), }), + codex_home.path().to_path_buf(), CLOUD_REQUIREMENTS_TIMEOUT, ); assert_eq!( @@ -435,9 +710,11 @@ mod tests { #[tokio::test(start_paused = true)] async fn fetch_cloud_requirements_times_out() { let auth_manager = auth_manager_with_plan("enterprise"); + let codex_home = tempdir().expect("tempdir"); let service = CloudRequirementsService::new( auth_manager, Arc::new(PendingFetcher), + codex_home.path().to_path_buf(), CLOUD_REQUIREMENTS_TIMEOUT, ); let handle = tokio::spawn(async move { service.fetch_with_timeout().await }); @@ -453,9 +730,11 @@ mod tests { Err(FetchCloudRequirementsStatus::Request), Ok(Some("allowed_approval_policies = [\"never\"]".to_string())), ])); + let codex_home = tempdir().expect("tempdir"); let service = CloudRequirementsService::new( auth_manager_with_plan("business"), fetcher.clone(), + codex_home.path().to_path_buf(), CLOUD_REQUIREMENTS_TIMEOUT, ); @@ -478,15 +757,345 @@ mod tests { assert_eq!(fetcher.request_count.load(Ordering::SeqCst), 2); } + #[tokio::test] + async fn fetch_cloud_requirements_parse_error_does_not_retry() { + let fetcher = Arc::new(SequenceFetcher::new(vec![ + Ok(Some("not = [".to_string())), + Ok(Some("allowed_approval_policies = [\"never\"]".to_string())), + ])); + let codex_home = tempdir().expect("tempdir"); + let service = CloudRequirementsService::new( + auth_manager_with_plan("business"), + fetcher.clone(), + codex_home.path().to_path_buf(), + CLOUD_REQUIREMENTS_TIMEOUT, + ); + + assert!(service.fetch().await.is_none()); + assert_eq!(fetcher.request_count.load(Ordering::SeqCst), 1); + } + + #[tokio::test] + async fn fetch_cloud_requirements_uses_cache_when_valid() { + let codex_home = tempdir().expect("tempdir"); + let prime_service = CloudRequirementsService::new( + auth_manager_with_plan("business"), + Arc::new(StaticFetcher { + contents: Some("allowed_approval_policies = [\"never\"]".to_string()), + }), + codex_home.path().to_path_buf(), + CLOUD_REQUIREMENTS_TIMEOUT, + ); + let _ = prime_service.fetch().await; + + let fetcher = Arc::new(SequenceFetcher::new(vec![Err( + FetchCloudRequirementsStatus::Request, + )])); + let service = CloudRequirementsService::new( + auth_manager_with_plan("business"), + fetcher.clone(), + codex_home.path().to_path_buf(), + CLOUD_REQUIREMENTS_TIMEOUT, + ); + + assert_eq!( + service.fetch().await, + Some(ConfigRequirementsToml { + allowed_approval_policies: Some(vec![AskForApproval::Never]), + allowed_sandbox_modes: None, + allowed_web_search_modes: None, + mcp_servers: None, + rules: None, + enforce_residency: None, + network: None, + }) + ); + assert_eq!(fetcher.request_count.load(Ordering::SeqCst), 0); + } + + #[tokio::test] + async fn fetch_cloud_requirements_writes_cache_when_identity_is_incomplete() { + let codex_home = tempdir().expect("tempdir"); + let service = CloudRequirementsService::new( + auth_manager_with_plan_and_identity("business", None, Some("account-12345")), + Arc::new(StaticFetcher { + contents: Some("allowed_approval_policies = [\"never\"]".to_string()), + }), + codex_home.path().to_path_buf(), + CLOUD_REQUIREMENTS_TIMEOUT, + ); + + assert_eq!( + service.fetch().await, + Some(ConfigRequirementsToml { + allowed_approval_policies: Some(vec![AskForApproval::Never]), + allowed_sandbox_modes: None, + allowed_web_search_modes: None, + mcp_servers: None, + rules: None, + enforce_residency: None, + network: None, + }) + ); + + let path = codex_home.path().join(CLOUD_REQUIREMENTS_CACHE_FILENAME); + let cache_file: CloudRequirementsCacheFile = + serde_json::from_str(&std::fs::read_to_string(path).expect("read cache")) + .expect("parse cache"); + assert_eq!(cache_file.signed_payload.chatgpt_user_id, None); + assert_eq!( + cache_file.signed_payload.account_id, + Some("account-12345".to_string()) + ); + } + + #[tokio::test] + async fn fetch_cloud_requirements_does_not_use_cache_when_auth_identity_is_incomplete() { + let codex_home = tempdir().expect("tempdir"); + let prime_service = CloudRequirementsService::new( + auth_manager_with_plan("business"), + Arc::new(StaticFetcher { + contents: Some("allowed_approval_policies = [\"never\"]".to_string()), + }), + codex_home.path().to_path_buf(), + CLOUD_REQUIREMENTS_TIMEOUT, + ); + let _ = prime_service.fetch().await; + + let fetcher = Arc::new(SequenceFetcher::new(vec![Ok(Some( + "allowed_approval_policies = [\"on-request\"]".to_string(), + ))])); + let service = CloudRequirementsService::new( + auth_manager_with_plan_and_identity("business", None, Some("account-12345")), + fetcher.clone(), + codex_home.path().to_path_buf(), + CLOUD_REQUIREMENTS_TIMEOUT, + ); + + assert_eq!( + service.fetch().await, + Some(ConfigRequirementsToml { + allowed_approval_policies: Some(vec![AskForApproval::OnRequest]), + allowed_sandbox_modes: None, + allowed_web_search_modes: None, + mcp_servers: None, + rules: None, + enforce_residency: None, + network: None, + }) + ); + assert_eq!(fetcher.request_count.load(Ordering::SeqCst), 1); + } + + #[tokio::test] + async fn fetch_cloud_requirements_ignores_cache_for_different_auth_identity() { + let codex_home = tempdir().expect("tempdir"); + let prime_service = CloudRequirementsService::new( + auth_manager_with_plan_and_identity( + "business", + Some("user-12345"), + Some("account-12345"), + ), + Arc::new(StaticFetcher { + contents: Some("allowed_approval_policies = [\"never\"]".to_string()), + }), + codex_home.path().to_path_buf(), + CLOUD_REQUIREMENTS_TIMEOUT, + ); + let _ = prime_service.fetch().await; + + let fetcher = Arc::new(SequenceFetcher::new(vec![Ok(Some( + "allowed_approval_policies = [\"on-request\"]".to_string(), + ))])); + let service = CloudRequirementsService::new( + auth_manager_with_plan_and_identity( + "business", + Some("user-99999"), + Some("account-12345"), + ), + fetcher.clone(), + codex_home.path().to_path_buf(), + CLOUD_REQUIREMENTS_TIMEOUT, + ); + + assert_eq!( + service.fetch().await, + Some(ConfigRequirementsToml { + allowed_approval_policies: Some(vec![AskForApproval::OnRequest]), + allowed_sandbox_modes: None, + allowed_web_search_modes: None, + mcp_servers: None, + rules: None, + enforce_residency: None, + network: None, + }) + ); + assert_eq!(fetcher.request_count.load(Ordering::SeqCst), 1); + } + + #[tokio::test] + async fn fetch_cloud_requirements_ignores_tampered_cache() { + let codex_home = tempdir().expect("tempdir"); + let prime_service = CloudRequirementsService::new( + auth_manager_with_plan("business"), + Arc::new(StaticFetcher { + contents: Some("allowed_approval_policies = [\"never\"]".to_string()), + }), + codex_home.path().to_path_buf(), + CLOUD_REQUIREMENTS_TIMEOUT, + ); + let _ = prime_service.fetch().await; + + let path = codex_home.path().join(CLOUD_REQUIREMENTS_CACHE_FILENAME); + let mut cache_file: CloudRequirementsCacheFile = + serde_json::from_str(&std::fs::read_to_string(&path).expect("read cache")) + .expect("parse cache"); + cache_file.signed_payload.contents = + Some("allowed_approval_policies = [\"on-request\"]".to_string()); + std::fs::write( + &path, + serde_json::to_vec_pretty(&cache_file).expect("serialize cache"), + ) + .expect("write cache"); + + let fetcher = Arc::new(SequenceFetcher::new(vec![Ok(Some( + "allowed_approval_policies = [\"never\"]".to_string(), + ))])); + let service = CloudRequirementsService::new( + auth_manager_with_plan("enterprise"), + fetcher.clone(), + codex_home.path().to_path_buf(), + CLOUD_REQUIREMENTS_TIMEOUT, + ); + + assert_eq!( + service.fetch().await, + Some(ConfigRequirementsToml { + allowed_approval_policies: Some(vec![AskForApproval::Never]), + allowed_sandbox_modes: None, + allowed_web_search_modes: None, + mcp_servers: None, + rules: None, + enforce_residency: None, + network: None, + }) + ); + assert_eq!(fetcher.request_count.load(Ordering::SeqCst), 1); + } + + #[tokio::test] + async fn fetch_cloud_requirements_ignores_expired_cache() { + let codex_home = tempdir().expect("tempdir"); + let path = codex_home.path().join(CLOUD_REQUIREMENTS_CACHE_FILENAME); + let cache_file = CloudRequirementsCacheFile { + signed_payload: CloudRequirementsCacheSignedPayload { + cached_at: Utc::now(), + expires_at: Utc::now() - ChronoDuration::seconds(1), + chatgpt_user_id: Some("user-12345".to_string()), + account_id: Some("account-12345".to_string()), + contents: Some("allowed_approval_policies = [\"on-request\"]".to_string()), + }, + signature: String::new(), + }; + let payload_bytes = cache_payload_bytes(&cache_file.signed_payload).expect("payload"); + let signature = sign_cache_payload(&payload_bytes).expect("sign payload"); + let cache_file = CloudRequirementsCacheFile { + signature, + ..cache_file + }; + std::fs::write( + &path, + serde_json::to_vec_pretty(&cache_file).expect("serialize cache"), + ) + .expect("write cache"); + + let fetcher = Arc::new(SequenceFetcher::new(vec![Ok(Some( + "allowed_approval_policies = [\"never\"]".to_string(), + ))])); + let service = CloudRequirementsService::new( + auth_manager_with_plan("enterprise"), + fetcher.clone(), + codex_home.path().to_path_buf(), + CLOUD_REQUIREMENTS_TIMEOUT, + ); + + assert_eq!( + service.fetch().await, + Some(ConfigRequirementsToml { + allowed_approval_policies: Some(vec![AskForApproval::Never]), + allowed_sandbox_modes: None, + allowed_web_search_modes: None, + mcp_servers: None, + rules: None, + enforce_residency: None, + network: None, + }) + ); + assert_eq!(fetcher.request_count.load(Ordering::SeqCst), 1); + } + + #[tokio::test] + async fn fetch_cloud_requirements_writes_signed_cache() { + let codex_home = tempdir().expect("tempdir"); + let service = CloudRequirementsService::new( + auth_manager_with_plan("business"), + Arc::new(StaticFetcher { + contents: Some("allowed_approval_policies = [\"never\"]".to_string()), + }), + codex_home.path().to_path_buf(), + CLOUD_REQUIREMENTS_TIMEOUT, + ); + + let _ = service.fetch().await; + + let path = codex_home.path().join(CLOUD_REQUIREMENTS_CACHE_FILENAME); + let cache_file: CloudRequirementsCacheFile = + serde_json::from_str(&std::fs::read_to_string(path).expect("read cache")) + .expect("parse cache"); + assert!(cache_file.signed_payload.expires_at > Utc::now()); + assert!(cache_file.signed_payload.cached_at <= Utc::now()); + assert_eq!( + cache_file.signed_payload.chatgpt_user_id, + Some("user-12345".to_string()) + ); + assert_eq!( + cache_file.signed_payload.account_id, + Some("account-12345".to_string()) + ); + assert_eq!( + cache_file + .signed_payload + .contents + .as_deref() + .and_then(|contents| parse_cloud_requirements(contents).ok().flatten()), + Some(ConfigRequirementsToml { + allowed_approval_policies: Some(vec![AskForApproval::Never]), + allowed_sandbox_modes: None, + allowed_web_search_modes: None, + mcp_servers: None, + rules: None, + enforce_residency: None, + network: None, + }) + ); + let payload_bytes = cache_payload_bytes(&cache_file.signed_payload).expect("payload bytes"); + assert!(verify_cache_signature( + &payload_bytes, + &cache_file.signature + )); + } + #[tokio::test] async fn fetch_cloud_requirements_none_is_success_without_retry() { let fetcher = Arc::new(SequenceFetcher::new(vec![ Ok(None), Err(FetchCloudRequirementsStatus::Request), ])); + let codex_home = tempdir().expect("tempdir"); let service = CloudRequirementsService::new( auth_manager_with_plan("enterprise"), fetcher.clone(), + codex_home.path().to_path_buf(), CLOUD_REQUIREMENTS_TIMEOUT, ); @@ -502,9 +1111,11 @@ mod tests { ); CLOUD_REQUIREMENTS_MAX_ATTEMPTS ])); + let codex_home = tempdir().expect("tempdir"); let service = CloudRequirementsService::new( auth_manager_with_plan("enterprise"), fetcher.clone(), + codex_home.path().to_path_buf(), CLOUD_REQUIREMENTS_TIMEOUT, ); diff --git a/codex-rs/exec/src/lib.rs b/codex-rs/exec/src/lib.rs index f6a8c8b99..400d8c464 100644 --- a/codex-rs/exec/src/lib.rs +++ b/codex-rs/exec/src/lib.rs @@ -205,7 +205,8 @@ pub async fn run_main(cli: Cli, codex_linux_sandbox_exe: Option) -> any .clone() .unwrap_or_else(|| "https://chatgpt.com/backend-api/".to_string()); // TODO(gt): Make cloud requirements failures blocking once we can fail-closed. - let cloud_requirements = cloud_requirements_loader(cloud_auth_manager, chatgpt_base_url); + let cloud_requirements = + cloud_requirements_loader(cloud_auth_manager, chatgpt_base_url, codex_home.clone()); let model_provider = if oss { let resolved = resolve_oss_provider( diff --git a/codex-rs/tui/src/lib.rs b/codex-rs/tui/src/lib.rs index eb83f5196..f1c42f00a 100644 --- a/codex-rs/tui/src/lib.rs +++ b/codex-rs/tui/src/lib.rs @@ -225,7 +225,11 @@ pub async fn run_main( .chatgpt_base_url .clone() .unwrap_or_else(|| "https://chatgpt.com/backend-api/".to_string()); - let cloud_requirements = cloud_requirements_loader(cloud_auth_manager, chatgpt_base_url); + let cloud_requirements = cloud_requirements_loader( + cloud_auth_manager, + chatgpt_base_url, + codex_home.to_path_buf(), + ); let model_provider_override = if cli.oss { let resolved = resolve_oss_provider( @@ -502,6 +506,7 @@ async fn run_ratatui_app( cloud_requirements = cloud_requirements_loader( auth_manager.clone(), initial_config.chatgpt_base_url.clone(), + initial_config.codex_home.clone(), ); }