From 78f847fe2e6bb1ce9a10dbcc37243e7aea17d8a1 Mon Sep 17 00:00:00 2001 From: Zyy Date: Tue, 7 Jul 2026 15:01:28 +0800 Subject: [PATCH] fix: hot reload fingerprint updates Co-authored-by: Codex --- backend/src/admin/monitoring/dashboard.rs | 3 +- backend/src/runtime/bootstrap.rs | 3 +- backend/src/runtime/services.rs | 16 +-- backend/src/runtime/tasks/coordinator.rs | 15 +-- .../src/runtime/tasks/fingerprint_update.rs | 34 +++++-- backend/src/upstream/fingerprint.rs | 80 +++++++++++---- backend/src/upstream/transport/client.rs | 34 ++++--- backend/tests/admin/accounts/importing.rs | 18 +++- backend/tests/admin/accounts/lifecycle.rs | 18 +++- backend/tests/admin/accounts/list.rs | 6 +- backend/tests/admin/accounts/mod.rs | 11 ++- backend/tests/admin/auth/session.rs | 4 +- backend/tests/admin/keys/mod.rs | 11 ++- backend/tests/admin/monitoring/dashboard.rs | 10 +- .../admin/monitoring/usage_record_routes.rs | 11 ++- backend/tests/admin/settings/routes.rs | 23 ++++- backend/tests/admin/system/mod.rs | 10 +- backend/tests/http/web_assets/mod.rs | 6 +- .../tests/proxy/dispatch/chat_upstream/mod.rs | 2 +- backend/tests/proxy/openai/models_routes.rs | 9 +- .../tests/proxy/openai/responses_routes.rs | 9 +- backend/tests/runtime/services.rs | 6 +- backend/tests/runtime/tasks/fingerprint.rs | 51 ++++------ backend/tests/runtime/tasks/mod.rs | 11 ++- backend/tests/support/fingerprint.rs | 13 ++- .../tests/upstream/accounts/quota_refresh.rs | 14 +-- backend/tests/upstream/fingerprint/mod.rs | 28 ++++-- backend/tests/upstream/transport/client.rs | 2 +- backend/tests/upstream/transport/headers.rs | 98 +++++++++++++++++-- .../tests/upstream/transport/http_client.rs | 4 +- backend/tests/upstream/transport/websocket.rs | 14 +-- .../upstream/transport/websocket_pool.rs | 34 +++---- 32 files changed, 434 insertions(+), 174 deletions(-) diff --git a/backend/src/admin/monitoring/dashboard.rs b/backend/src/admin/monitoring/dashboard.rs index 34b96e95c..7ce863a1b 100644 --- a/backend/src/admin/monitoring/dashboard.rs +++ b/backend/src/admin/monitoring/dashboard.rs @@ -1033,7 +1033,8 @@ fn dashboard_usage_record_data( } fn service_status_data(state: &AppState) -> Vec { - let fingerprint = fingerprint_diagnostics(&state.services.fingerprint); + let current_fingerprint = state.services.fingerprint.snapshot(); + let fingerprint = fingerprint_diagnostics(¤t_fingerprint); vec![ DashboardServiceStatusData { label: "客户端版本".to_string(), diff --git a/backend/src/runtime/bootstrap.rs b/backend/src/runtime/bootstrap.rs index 093dcb9b7..5c044bc26 100644 --- a/backend/src/runtime/bootstrap.rs +++ b/backend/src/runtime/bootstrap.rs @@ -13,7 +13,7 @@ use crate::runtime::services::{BackgroundTaskStores, Services}; use crate::runtime::shutdown::{restart_executable_path, shutdown_signal}; use crate::runtime::state::{AppState, RuntimeConfig}; use crate::runtime::tasks::coordinator::TaskCoordinator; -use crate::upstream::fingerprint::{Fingerprint, FingerprintRepository}; +use crate::upstream::fingerprint::{Fingerprint, FingerprintRepository, RuntimeFingerprint}; const RESTART_DELAY_ENV: &str = "CPR_RESTART_DELAY_MS"; @@ -81,6 +81,7 @@ async fn build_application( let runtime_fingerprint = fingerprint_repository .ensure_current_seed(&default_fingerprint) .await?; + let runtime_fingerprint = RuntimeFingerprint::new(runtime_fingerprint); let runtime_config = RuntimeConfig::from(config.clone()); let services = Services::try_with_installation_id( &config, diff --git a/backend/src/runtime/services.rs b/backend/src/runtime/services.rs index 9a54786a2..d771705f8 100644 --- a/backend/src/runtime/services.rs +++ b/backend/src/runtime/services.rs @@ -35,7 +35,7 @@ use crate::{ }, }, upstream::{ - fingerprint::{Fingerprint, FingerprintRepository}, + fingerprint::{FingerprintRepository, RuntimeFingerprint}, models::{ config::ModelConfig, service::{ModelService, ModelServiceError}, @@ -98,7 +98,7 @@ pub struct Services { pub session_affinity: StdArc, pub codex: StdArc, pub websocket_pool: Option>, - pub fingerprint: Fingerprint, + pub fingerprint: RuntimeFingerprint, pub installation_id: Option, pub background_tasks: BackgroundTaskStores, } @@ -120,7 +120,11 @@ impl UsageRecordOptions { } impl Services { - pub fn new(config: &AppConfig, stores: BackgroundTaskStores, fingerprint: Fingerprint) -> Self { + pub fn new( + config: &AppConfig, + stores: BackgroundTaskStores, + fingerprint: RuntimeFingerprint, + ) -> Self { Self::try_new(config, stores, fingerprint) .expect("failed to build runtime services with configured TLS transport") } @@ -128,7 +132,7 @@ impl Services { pub fn try_new( config: &AppConfig, stores: BackgroundTaskStores, - fingerprint: Fingerprint, + fingerprint: RuntimeFingerprint, ) -> Result { Self::try_with_installation_id(config, stores, fingerprint, None) } @@ -136,7 +140,7 @@ impl Services { pub(crate) fn try_with_installation_id( config: &AppConfig, stores: BackgroundTaskStores, - fingerprint: Fingerprint, + fingerprint: RuntimeFingerprint, installation_id: Option, ) -> Result { Self::try_with_installation_id_and_usage_record_options( @@ -151,7 +155,7 @@ impl Services { pub fn try_with_installation_id_and_usage_record_options( config: &AppConfig, stores: BackgroundTaskStores, - fingerprint: Fingerprint, + fingerprint: RuntimeFingerprint, installation_id: Option, usage_record_options: UsageRecordOptions, ) -> Result { diff --git a/backend/src/runtime/tasks/coordinator.rs b/backend/src/runtime/tasks/coordinator.rs index 999f9da6c..0bf95b1c2 100644 --- a/backend/src/runtime/tasks/coordinator.rs +++ b/backend/src/runtime/tasks/coordinator.rs @@ -86,16 +86,17 @@ impl TaskCoordinator { .with_account_pool(services.account_pool.clone()) .start(), ); - coordinator.push( - "fingerprint_update", + coordinator.push("fingerprint_update", { + let fingerprint = services.fingerprint.snapshot(); start_fingerprint_update_task( - Some(stores.fingerprints.clone()), + stores.fingerprints.clone(), + services.fingerprint.clone(), CODEX_DESKTOP_APPCAST_URL.to_string(), PathBuf::from(DEFAULT_EXTRACTED_FINGERPRINT_PATH), - services.fingerprint.app_version.clone(), - services.fingerprint.build_number.clone(), - ), - ); + fingerprint.app_version, + fingerprint.build_number, + ) + }); if let Some(pool) = &services.websocket_pool { coordinator.push( "websocket_pool", diff --git a/backend/src/runtime/tasks/fingerprint_update.rs b/backend/src/runtime/tasks/fingerprint_update.rs index 1ae718b1f..d88809ef0 100644 --- a/backend/src/runtime/tasks/fingerprint_update.rs +++ b/backend/src/runtime/tasks/fingerprint_update.rs @@ -2,9 +2,11 @@ use std::path::PathBuf; -use tracing::warn; +use tracing::{info, warn}; -use crate::upstream::fingerprint::{FingerprintRepository, UpdateChecker, APPCAST_POLL_INTERVAL}; +use crate::upstream::fingerprint::{ + FingerprintRepository, RuntimeFingerprint, UpdateChecker, APPCAST_POLL_INTERVAL, +}; use super::{ coordinator::SchedulerHandle, @@ -19,7 +21,8 @@ pub const DEFAULT_EXTRACTED_FINGERPRINT_PATH: &str = "data/extracted-fingerprint /// 启动指纹自动更新后台任务。 pub fn start_fingerprint_update_task( - repository: Option, + repository: FingerprintRepository, + runtime_fingerprint: RuntimeFingerprint, appcast_url: String, extracted_fingerprint_path: PathBuf, current_version: String, @@ -36,6 +39,7 @@ pub fn start_fingerprint_update_task( spawn_periodic_task( FingerprintUpdateTask { checker: update_checker, + runtime_fingerprint, first_tick: true, }, PeriodicTaskConfig::new( @@ -48,6 +52,7 @@ pub fn start_fingerprint_update_task( struct FingerprintUpdateTask { checker: UpdateChecker, + runtime_fingerprint: RuntimeFingerprint, first_tick: bool, } @@ -55,11 +60,24 @@ impl PeriodicTaskRunner for FingerprintUpdateTask { fn tick(&mut self) -> super::periodic::TaskFuture<'_, ()> { Box::pin(async move { let first_tick = std::mem::take(&mut self.first_tick); - if let Err(error) = self.checker.check_and_apply_update().await { - if first_tick { - warn!(error = %error, "fingerprint 首次版本检查失败"); - } else { - warn!(error = %error, "fingerprint 定期版本检查失败"); + match self.checker.check_and_apply_update().await { + Ok(Some(fingerprint)) => { + let app_version = fingerprint.app_version.clone(); + let build_number = fingerprint.build_number.clone(); + self.runtime_fingerprint.replace(fingerprint); + info!( + app_version = %app_version, + build_number = %build_number, + "fingerprint 运行时版本已更新" + ); + } + Ok(None) => {} + Err(error) => { + if first_tick { + warn!(error = %error, "fingerprint 首次版本检查失败"); + } else { + warn!(error = %error, "fingerprint 定期版本检查失败"); + } } } }) diff --git a/backend/src/upstream/fingerprint.rs b/backend/src/upstream/fingerprint.rs index 0a3392427..de9dd0b62 100644 --- a/backend/src/upstream/fingerprint.rs +++ b/backend/src/upstream/fingerprint.rs @@ -1,6 +1,10 @@ //! 指纹类型、更新与持久化。 -use std::{path::PathBuf, sync::Arc, time::Duration}; +use std::{ + path::PathBuf, + sync::{Arc, RwLock}, + time::Duration, +}; use chrono::{DateTime, Utc}; use indexmap::IndexMap; @@ -89,6 +93,38 @@ impl Fingerprint { } } +/// 运行时共享指纹快照。 +#[derive(Debug, Clone)] +pub struct RuntimeFingerprint { + current: Arc>>, +} + +impl RuntimeFingerprint { + pub fn new(fingerprint: Fingerprint) -> Self { + Self { + current: Arc::new(RwLock::new(Arc::new(fingerprint))), + } + } + + pub fn current(&self) -> Arc { + self.current + .read() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .clone() + } + + pub fn snapshot(&self) -> Fingerprint { + self.current().as_ref().clone() + } + + pub fn replace(&self, fingerprint: Fingerprint) { + *self + .current + .write() + .unwrap_or_else(std::sync::PoisonError::into_inner) = Arc::new(fingerprint); + } +} + // --------------------------------------------------------------------------- // 指纹历史记录与仓储 // --------------------------------------------------------------------------- @@ -345,7 +381,7 @@ fn json_error(error: serde_json::Error) -> sqlx::Error { /// Appcast 指纹更新检查器。 #[derive(Clone)] pub struct UpdateChecker { - repository: Option, + repository: FingerprintRepository, client: reqwest::Client, appcast_url: String, extracted_fingerprint_path: PathBuf, @@ -360,7 +396,7 @@ struct InternalState { impl UpdateChecker { /// 使用自定义 client 和路径构造检查器。 pub fn with_client( - repository: Option, + repository: FingerprintRepository, client: reqwest::Client, appcast_url: impl Into, extracted_fingerprint_path: PathBuf, @@ -406,18 +442,18 @@ impl UpdateChecker { } /// 检查并在需要时应用更新。 - pub async fn check_and_apply_update(&self) -> Result { + pub async fn check_and_apply_update(&self) -> Result, UpdateError> { let update_state = self.check_for_update().await?; if !update_state.update_available { - return Ok(false); + return Ok(None); } let (Some(version), Some(build)) = ( update_state.latest_version.as_deref(), update_state.latest_build.as_deref(), ) else { - return Ok(false); + return Ok(None); }; let state = self.state.lock().await; @@ -433,13 +469,13 @@ impl UpdateChecker { "发现新的 fingerprint 版本" ); - self.apply_version_update(version, build).await?; + let updated_fingerprint = self.apply_version_update(version, build).await?; let mut state = self.state.lock().await; state.current_version = version.to_string(); state.current_build = build.to_string(); - Ok(true) + Ok(Some(updated_fingerprint)) } async fn fetch_appcast(&self) -> Result { @@ -457,20 +493,26 @@ impl UpdateChecker { response.text().await.map_err(UpdateError::from) } - async fn apply_version_update(&self, version: &str, build: &str) -> Result<(), UpdateError> { + async fn apply_version_update( + &self, + version: &str, + build: &str, + ) -> Result { let chromium_version = load_matching_chromium_version(&self.extracted_fingerprint_path, version, build); - if let Some(repository) = &self.repository { - repository - .update_current_version(version, build, chromium_version.as_deref()) - .await?; - repository - .insert_update_history(version, build, chromium_version.as_deref(), None) - .await?; - } - - Ok(()) + self.repository + .update_current_version(version, build, chromium_version.as_deref()) + .await?; + self.repository + .insert_update_history(version, build, chromium_version.as_deref(), None) + .await?; + let updated = self + .repository + .load_current() + .await? + .ok_or(sqlx::Error::RowNotFound)?; + Ok(updated) } } diff --git a/backend/src/upstream/transport/client.rs b/backend/src/upstream/transport/client.rs index 6630c26f6..fab00759e 100644 --- a/backend/src/upstream/transport/client.rs +++ b/backend/src/upstream/transport/client.rs @@ -22,7 +22,7 @@ use thiserror::Error; use tokio_tungstenite::tungstenite::handshake::client::generate_key; use uuid::Uuid; -use crate::upstream::fingerprint::Fingerprint; +use crate::upstream::fingerprint::{Fingerprint, RuntimeFingerprint}; use crate::upstream::models::backend_entry::BackendModelEntry; use crate::upstream::protocol::events::{extract_sse_usage, retry_after_seconds_from_body}; use crate::upstream::protocol::responses::{ @@ -333,14 +333,18 @@ pub struct CodexCompactResponse { pub struct CodexBackendClient { pub(super) client: Client, pub(super) base_url: String, - fingerprint: Fingerprint, + fingerprint: RuntimeFingerprint, websocket_pool: Option>, websocket_first_token_timeout: Option, } impl CodexBackendClient { /// 构造客户端。 - pub fn new(client: Client, base_url: impl Into, fingerprint: Fingerprint) -> Self { + pub fn new( + client: Client, + base_url: impl Into, + fingerprint: RuntimeFingerprint, + ) -> Self { Self { client, base_url: base_url.into().trim_end_matches('/').to_string(), @@ -798,17 +802,18 @@ impl CodexBackendClient { &self, context: CodexRequestContext<'_>, ) -> CodexClientResult> { + let fingerprint = self.fingerprint.current(); let endpoints = [ format!( "{}/codex/models?client_version={}", - self.base_url, self.fingerprint.app_version + self.base_url, fingerprint.app_version ), format!("{}/models", self.base_url), format!("{}/sentinel/chat-requirements", self.base_url), ]; for endpoint in endpoints { - let headers = self.auxiliary_request_headers(context)?; + let headers = self.auxiliary_request_headers(&fingerprint, context)?; let response = self.client.get(endpoint).headers(headers).send().await?; if !response.status().is_success() { continue; @@ -845,9 +850,10 @@ impl CodexBackendClient { } fn request_headers(&self, context: CodexRequestContext<'_>) -> CodexClientResult { + let fingerprint = self.fingerprint.current(); let request_id = context.session_id.unwrap_or(context.request_id); let ordered_headers = build_ordered_codex_base_headers( - &self.fingerprint, + &fingerprint, context.access_token, context.account_id, ); @@ -897,13 +903,11 @@ impl CodexBackendClient { fn auxiliary_request_headers( &self, + fingerprint: &Fingerprint, context: CodexRequestContext<'_>, ) -> CodexClientResult { - let ordered_headers = build_ordered_codex_base_headers( - &self.fingerprint, - context.access_token, - context.account_id, - ); + let ordered_headers = + build_ordered_codex_base_headers(fingerprint, context.access_token, context.account_id); let mut headers = HeaderMap::new(); insert_ordered_headers(&mut headers, &ordered_headers)?; @@ -924,10 +928,11 @@ impl CodexBackendClient { &self, context: CodexRequestContext<'_>, ) -> CodexClientResult { + let fingerprint = self.fingerprint.current(); let mut headers = HeaderMap::new(); headers.insert( USER_AGENT, - HeaderValue::from_str(&self.fingerprint.user_agent())?, + HeaderValue::from_str(&fingerprint.user_agent())?, ); headers.insert( AUTHORIZATION, @@ -935,7 +940,7 @@ impl CodexBackendClient { ); headers.insert( HeaderName::from_static("originator"), - HeaderValue::from_str(&self.fingerprint.originator)?, + HeaderValue::from_str(&fingerprint.originator)?, ); insert_optional_header(&mut headers, "chatgpt-account-id", context.account_id)?; insert_optional_header(&mut headers, "cookie", context.cookie_header)?; @@ -947,8 +952,9 @@ impl CodexBackendClient { &self, context: CodexRequestContext<'_>, ) -> CodexClientResult { + let fingerprint = self.fingerprint.current(); let ordered_headers = build_ordered_codex_base_headers( - &self.fingerprint, + &fingerprint, context.access_token, context.account_id, ); diff --git a/backend/tests/admin/accounts/importing.rs b/backend/tests/admin/accounts/importing.rs index 128fd27fd..3f105fd18 100644 --- a/backend/tests/admin/accounts/importing.rs +++ b/backend/tests/admin/accounts/importing.rs @@ -30,7 +30,11 @@ async fn admin_accounts_import_should_store_cpr_account_tokens() { usage_records: SqliteUsageRecordStore::new(pool.clone()), }; let fingerprint = crate::support::fingerprint::test_fingerprint(); - let services = Arc::new(Services::new(&config, stores, fingerprint)); + let services = Arc::new(Services::new( + &config, + stores, + runtime_fingerprint(fingerprint), + )); let state = AppState { services: (*services).clone(), }; @@ -639,7 +643,11 @@ async fn admin_accounts_import_should_fetch_usage_to_complete_missing_plan_and_q usage_records: SqliteUsageRecordStore::new(pool.clone()), }; let fingerprint = crate::support::fingerprint::test_fingerprint(); - let services = Arc::new(Services::new(&config, stores, fingerprint)); + let services = Arc::new(Services::new( + &config, + stores, + runtime_fingerprint(fingerprint), + )); let state = AppState { services: (*services).clone(), }; @@ -855,7 +863,11 @@ async fn admin_accounts_import_should_require_admin_session_cookie() { usage_records: SqliteUsageRecordStore::new(pool.clone()), }; let fingerprint = crate::support::fingerprint::test_fingerprint(); - let services = Arc::new(Services::new(&config, stores, fingerprint)); + let services = Arc::new(Services::new( + &config, + stores, + runtime_fingerprint(fingerprint), + )); let state = AppState { services: (*services).clone(), }; diff --git a/backend/tests/admin/accounts/lifecycle.rs b/backend/tests/admin/accounts/lifecycle.rs index e5e98ee12..43c2c8096 100644 --- a/backend/tests/admin/accounts/lifecycle.rs +++ b/backend/tests/admin/accounts/lifecycle.rs @@ -23,7 +23,11 @@ async fn admin_accounts_lifecycle_should_update_and_delete_accounts() { usage_records: SqliteUsageRecordStore::new(pool.clone()), }; let fingerprint = crate::support::fingerprint::test_fingerprint(); - let services = Arc::new(Services::new(&config, stores, fingerprint)); + let services = Arc::new(Services::new( + &config, + stores, + runtime_fingerprint(fingerprint), + )); let state = AppState { services: (*services).clone(), }; @@ -748,7 +752,11 @@ async fn admin_account_create_should_derive_claims_and_store_plain_tokens() { usage_records: SqliteUsageRecordStore::new(pool.clone()), }; let fingerprint = crate::support::fingerprint::test_fingerprint(); - let services = Arc::new(Services::new(&config, stores, fingerprint)); + let services = Arc::new(Services::new( + &config, + stores, + runtime_fingerprint(fingerprint), + )); let state = AppState { services: (*services).clone(), }; @@ -791,7 +799,11 @@ async fn admin_account_manual_create_should_reject_missing_invalid_expired_or_un usage_records: SqliteUsageRecordStore::new(pool.clone()), }; let fingerprint = crate::support::fingerprint::test_fingerprint(); - let services = Arc::new(Services::new(&config, stores, fingerprint)); + let services = Arc::new(Services::new( + &config, + stores, + runtime_fingerprint(fingerprint), + )); let state = AppState { services: (*services).clone(), }; diff --git a/backend/tests/admin/accounts/list.rs b/backend/tests/admin/accounts/list.rs index 5f7e77e52..a5333b324 100644 --- a/backend/tests/admin/accounts/list.rs +++ b/backend/tests/admin/accounts/list.rs @@ -27,7 +27,11 @@ async fn admin_accounts_list_should_not_expose_account_tokens() { usage_records: SqliteUsageRecordStore::new(pool.clone()), }; let fingerprint = crate::support::fingerprint::test_fingerprint(); - let services = Arc::new(Services::new(&config, stores, fingerprint)); + let services = Arc::new(Services::new( + &config, + stores, + runtime_fingerprint(fingerprint), + )); let state = AppState { services: (*services).clone(), }; diff --git a/backend/tests/admin/accounts/mod.rs b/backend/tests/admin/accounts/mod.rs index 062c6bd05..27259b274 100644 --- a/backend/tests/admin/accounts/mod.rs +++ b/backend/tests/admin/accounts/mod.rs @@ -26,7 +26,10 @@ use sqlx::SqlitePool; use tower::util::ServiceExt; use crate::support::jwt::unsigned_jwt; -use crate::support::{admin::seed_admin_session, config::test_config, http::response_json}; +use crate::support::{ + admin::seed_admin_session, config::test_config, fingerprint::runtime_fingerprint, + http::response_json, +}; mod exporting; mod importing; @@ -170,7 +173,11 @@ async fn admin_accounts_test_app_with_overrides( usage_records: SqliteUsageRecordStore::new(pool.clone()), }; let fingerprint = crate::support::fingerprint::test_fingerprint(); - let services = Arc::new(Services::new(&config, stores, fingerprint)); + let services = Arc::new(Services::new( + &config, + stores, + runtime_fingerprint(fingerprint), + )); let state = AppState { services: (*services).clone(), }; diff --git a/backend/tests/admin/auth/session.rs b/backend/tests/admin/auth/session.rs index f069a7f32..187d1e34b 100644 --- a/backend/tests/admin/auth/session.rs +++ b/backend/tests/admin/auth/session.rs @@ -7,7 +7,7 @@ use codex_proxy_rs::infra::{database::connect_sqlite, identity::hash_admin_passw use sqlx::SqlitePool; use tower::util::ServiceExt; -use crate::support::{config::test_config, http::response_json}; +use crate::support::{config::test_config, fingerprint::runtime_fingerprint, http::response_json}; #[tokio::test] async fn admin_login_should_issue_http_only_session_cookie() { @@ -202,7 +202,7 @@ async fn admin_login_test_app( let services = std::sync::Arc::new(codex_proxy_rs::runtime::services::Services::new( &config, stores, - fingerprint, + runtime_fingerprint(fingerprint), )); let state = codex_proxy_rs::runtime::state::AppState { services: (*services).clone(), diff --git a/backend/tests/admin/keys/mod.rs b/backend/tests/admin/keys/mod.rs index fb4cff736..bdca9c5dc 100644 --- a/backend/tests/admin/keys/mod.rs +++ b/backend/tests/admin/keys/mod.rs @@ -17,7 +17,10 @@ use codex_proxy_rs::{ use serde_json::json; use tower::util::ServiceExt; -use crate::support::{admin::seed_admin_session, config::test_config, http::response_json}; +use crate::support::{ + admin::seed_admin_session, config::test_config, fingerprint::runtime_fingerprint, + http::response_json, +}; mod authorization; mod lifecycle; @@ -41,7 +44,11 @@ async fn admin_client_key_test_app(db_name: &str) -> (axum::Router, tempfile::Te usage_records: SqliteUsageRecordStore::new(pool.clone()), }; let fingerprint = crate::support::fingerprint::test_fingerprint(); - let services = std::sync::Arc::new(Services::new(&config, stores, fingerprint)); + let services = std::sync::Arc::new(Services::new( + &config, + stores, + runtime_fingerprint(fingerprint), + )); let state = AppState { services: (*services).clone(), }; diff --git a/backend/tests/admin/monitoring/dashboard.rs b/backend/tests/admin/monitoring/dashboard.rs index 475cb3292..606183b84 100644 --- a/backend/tests/admin/monitoring/dashboard.rs +++ b/backend/tests/admin/monitoring/dashboard.rs @@ -32,7 +32,9 @@ use serde_json::{json, Value}; use sqlx::SqlitePool; use tower::util::ServiceExt; -use crate::support::{admin::seed_admin_session, http::response_json}; +use crate::support::{ + admin::seed_admin_session, fingerprint::runtime_fingerprint, http::response_json, +}; #[tokio::test] async fn dashboard_summary_should_render_fingerprint_updated_at_as_beijing_time() { @@ -654,7 +656,11 @@ async fn dashboard_test_app( client_keys: SqliteClientKeyStore::new(pool.clone()), usage_records: SqliteUsageRecordStore::new(pool.clone()), }; - let services = std::sync::Arc::new(Services::new(&config, stores, fingerprint)); + let services = std::sync::Arc::new(Services::new( + &config, + stores, + runtime_fingerprint(fingerprint), + )); let state = AppState { services: (*services).clone(), }; diff --git a/backend/tests/admin/monitoring/usage_record_routes.rs b/backend/tests/admin/monitoring/usage_record_routes.rs index b211e884a..199f76263 100644 --- a/backend/tests/admin/monitoring/usage_record_routes.rs +++ b/backend/tests/admin/monitoring/usage_record_routes.rs @@ -23,7 +23,10 @@ use serde_json::json; use sqlx::SqlitePool; use tower::util::ServiceExt; -use crate::support::{admin::seed_admin_session, config::test_config, http::response_json}; +use crate::support::{ + admin::seed_admin_session, config::test_config, fingerprint::runtime_fingerprint, + http::response_json, +}; #[tokio::test] async fn admin_usage_records_should_require_admin_session_cookie() { @@ -883,7 +886,11 @@ async fn admin_usage_records_test_app_with_config( usage_records: SqliteUsageRecordStore::new(pool.clone()), }; let fingerprint = crate::support::fingerprint::test_fingerprint(); - let services = std::sync::Arc::new(Services::new(&config, stores, fingerprint)); + let services = std::sync::Arc::new(Services::new( + &config, + stores, + runtime_fingerprint(fingerprint), + )); let state = AppState { services: (*services).clone(), }; diff --git a/backend/tests/admin/settings/routes.rs b/backend/tests/admin/settings/routes.rs index 4b80d51a6..34834bb5f 100644 --- a/backend/tests/admin/settings/routes.rs +++ b/backend/tests/admin/settings/routes.rs @@ -28,7 +28,8 @@ use sqlx::SqlitePool; use tower::util::ServiceExt; use crate::support::{ - admin::seed_admin_session, config::test_config as base_test_config, http::response_json, + admin::seed_admin_session, config::test_config as base_test_config, + fingerprint::runtime_fingerprint, http::response_json, }; #[tokio::test] @@ -277,7 +278,7 @@ async fn admin_settings_update_should_persist_runtime_settings_to_database() { usage_records: SqliteUsageRecordStore::new(pool.clone()), }; let fingerprint = crate::support::fingerprint::test_fingerprint(); - let services = Services::new(&config, stores, fingerprint); + let services = Services::new(&config, stores, runtime_fingerprint(fingerprint)); let services = std::sync::Arc::new(services); let state = AppState { services: (*services).clone(), @@ -382,7 +383,11 @@ async fn admin_settings_update_should_apply_runtime_services() { usage_records: SqliteUsageRecordStore::new(pool.clone()), }; let fingerprint = crate::support::fingerprint::test_fingerprint(); - let services = std::sync::Arc::new(Services::new(&config, stores, fingerprint)); + let services = std::sync::Arc::new(Services::new( + &config, + stores, + runtime_fingerprint(fingerprint), + )); services .account_pool .restore_from_repository() @@ -480,7 +485,11 @@ async fn admin_settings_update_should_reject_unsupported_or_invalid_fields() { usage_records: SqliteUsageRecordStore::new(pool.clone()), }; let fingerprint = crate::support::fingerprint::test_fingerprint(); - let services = std::sync::Arc::new(Services::new(&config, stores, fingerprint)); + let services = std::sync::Arc::new(Services::new( + &config, + stores, + runtime_fingerprint(fingerprint), + )); let state = AppState { services: (*services).clone(), }; @@ -522,7 +531,11 @@ async fn admin_settings_test_app(db_name: &str) -> (axum::Router, tempfile::Temp usage_records: SqliteUsageRecordStore::new(pool.clone()), }; let fingerprint = crate::support::fingerprint::test_fingerprint(); - let services = std::sync::Arc::new(Services::new(&config, stores, fingerprint)); + let services = std::sync::Arc::new(Services::new( + &config, + stores, + runtime_fingerprint(fingerprint), + )); let state = AppState { services: (*services).clone(), }; diff --git a/backend/tests/admin/system/mod.rs b/backend/tests/admin/system/mod.rs index 109ceb4f6..7c4bd01fc 100644 --- a/backend/tests/admin/system/mod.rs +++ b/backend/tests/admin/system/mod.rs @@ -35,7 +35,9 @@ use wiremock::{ }; use crate::support::{ - admin::seed_admin_session, config::test_config, fingerprint::test_fingerprint, + admin::seed_admin_session, + config::test_config, + fingerprint::{runtime_fingerprint, test_fingerprint}, http::response_json, }; @@ -1218,7 +1220,11 @@ async fn admin_system_test_app(db_name: &str) -> (axum::Router, tempfile::TempDi seed_admin_session(&pool, "session_1").await; let config = test_config(url); let stores = stores(pool); - let services = Arc::new(Services::new(&config, stores, test_fingerprint())); + let services = Arc::new(Services::new( + &config, + stores, + runtime_fingerprint(test_fingerprint()), + )); let state = AppState { services: (*services).clone(), }; diff --git a/backend/tests/http/web_assets/mod.rs b/backend/tests/http/web_assets/mod.rs index 206159519..bda99353b 100644 --- a/backend/tests/http/web_assets/mod.rs +++ b/backend/tests/http/web_assets/mod.rs @@ -10,7 +10,9 @@ use serde_json::Value; use sqlx::SqlitePool; use tower::ServiceExt; -use crate::support::{config::test_config as base_test_config, http::response_json}; +use crate::support::{ + config::test_config as base_test_config, fingerprint::runtime_fingerprint, http::response_json, +}; #[tokio::test] async fn server_router_should_serve_frontend_assets_without_shadowing_api_routes() { @@ -200,7 +202,7 @@ async fn router_with_dist_and_database(dist: &Path, database_url: &str) -> (Rout let services = std::sync::Arc::new(codex_proxy_rs::runtime::services::Services::new( &config, stores, - fingerprint, + runtime_fingerprint(fingerprint), )); let state = codex_proxy_rs::runtime::state::AppState { services: (*services).clone(), diff --git a/backend/tests/proxy/dispatch/chat_upstream/mod.rs b/backend/tests/proxy/dispatch/chat_upstream/mod.rs index 6f885dbb7..7c3f379a6 100644 --- a/backend/tests/proxy/dispatch/chat_upstream/mod.rs +++ b/backend/tests/proxy/dispatch/chat_upstream/mod.rs @@ -271,7 +271,7 @@ async fn test_app_state_with_pool_installation_id_and_usage_record_options( let services = Services::try_with_installation_id_and_usage_record_options( &config, stores, - crate::support::fingerprint::test_fingerprint(), + crate::support::fingerprint::runtime_test_fingerprint(), Some(installation_id), usage_record_options, ) diff --git a/backend/tests/proxy/openai/models_routes.rs b/backend/tests/proxy/openai/models_routes.rs index b3b99fb7f..0b3965ea6 100644 --- a/backend/tests/proxy/openai/models_routes.rs +++ b/backend/tests/proxy/openai/models_routes.rs @@ -20,7 +20,8 @@ use codex_proxy_rs::{ use tower::util::ServiceExt; use crate::support::{ - client_keys::insert_client_api_key, config::test_config, http::response_json, + client_keys::insert_client_api_key, config::test_config, fingerprint::runtime_fingerprint, + http::response_json, }; #[tokio::test] @@ -164,7 +165,11 @@ async fn build_test_app( usage_records: SqliteUsageRecordStore::new(pool.clone()), }; let fingerprint = crate::support::fingerprint::test_fingerprint(); - let services = std::sync::Arc::new(Services::new(&config, stores, fingerprint)); + let services = std::sync::Arc::new(Services::new( + &config, + stores, + runtime_fingerprint(fingerprint), + )); services .initialize_hot_path_state() .await diff --git a/backend/tests/proxy/openai/responses_routes.rs b/backend/tests/proxy/openai/responses_routes.rs index 6ce88a396..318f9ae18 100644 --- a/backend/tests/proxy/openai/responses_routes.rs +++ b/backend/tests/proxy/openai/responses_routes.rs @@ -18,7 +18,8 @@ use sqlx::SqlitePool; use tower::util::ServiceExt; use crate::support::{ - client_keys::insert_client_api_key, config::test_config, http::response_json, + client_keys::insert_client_api_key, config::test_config, fingerprint::runtime_fingerprint, + http::response_json, }; #[tokio::test] @@ -154,7 +155,11 @@ async fn test_app_with_client_api_key() -> (axum::Router, String, tempfile::Temp usage_records: SqliteUsageRecordStore::new(pool.clone()), }; let fingerprint = crate::support::fingerprint::test_fingerprint(); - let services = std::sync::Arc::new(Services::new(&config, stores, fingerprint)); + let services = std::sync::Arc::new(Services::new( + &config, + stores, + runtime_fingerprint(fingerprint), + )); services .initialize_hot_path_state() .await diff --git a/backend/tests/runtime/services.rs b/backend/tests/runtime/services.rs index 344c5282a..d5ba0bb40 100644 --- a/backend/tests/runtime/services.rs +++ b/backend/tests/runtime/services.rs @@ -49,7 +49,7 @@ async fn services_try_new_should_use_configured_tls_transport_builder() { let Err(error) = Services::try_new( &config, stores, - crate::support::fingerprint::test_fingerprint(), + crate::support::fingerprint::runtime_test_fingerprint(), ) else { panic!("invalid custom CA should fail service transport construction"); }; @@ -90,7 +90,7 @@ async fn services_try_new_should_expose_websocket_pool_when_enabled() { let services = Services::try_new( &config, stores(pool), - crate::support::fingerprint::test_fingerprint(), + crate::support::fingerprint::runtime_test_fingerprint(), ) .expect("services should build"); @@ -111,7 +111,7 @@ async fn services_try_new_should_not_expose_websocket_pool_when_disabled() { let services = Services::try_new( &config, stores(pool), - crate::support::fingerprint::test_fingerprint(), + crate::support::fingerprint::runtime_test_fingerprint(), ) .expect("services should build"); diff --git a/backend/tests/runtime/tasks/fingerprint.rs b/backend/tests/runtime/tasks/fingerprint.rs index 1c2ecce06..3f59066b4 100644 --- a/backend/tests/runtime/tasks/fingerprint.rs +++ b/backend/tests/runtime/tasks/fingerprint.rs @@ -11,9 +11,11 @@ async fn fingerprint_update_task_should_start_background_checker() { repo.ensure_current_seed(&crate::support::fingerprint::test_fingerprint()) .await .expect("seed current fingerprint"); + let runtime_fingerprint = runtime_fingerprint_from_repo(&repo).await; let handle = codex_proxy_rs::runtime::tasks::fingerprint_update::start_fingerprint_update_task( - Some(repo), + repo, + runtime_fingerprint, "https://example.invalid/appcast.xml".to_string(), dir.path().join("extracted-fingerprint.json"), "26.800.1".to_string(), @@ -49,6 +51,7 @@ async fn fingerprint_update_task_should_apply_initial_appcast_update_to_reposito repo.ensure_current_seed(&crate::support::fingerprint::test_fingerprint()) .await .expect("seed current fingerprint"); + let runtime_fingerprint = runtime_fingerprint_from_repo(&repo).await; let extracted_path = dir.path().join("extracted-fingerprint.json"); std::fs::write( &extracted_path, @@ -57,7 +60,8 @@ async fn fingerprint_update_task_should_apply_initial_appcast_update_to_reposito .expect("extracted fingerprint should be written"); let handle = codex_proxy_rs::runtime::tasks::fingerprint_update::start_fingerprint_update_task( - Some(repo.clone()), + repo.clone(), + runtime_fingerprint.clone(), format!("{}/appcast.xml", server.uri()), extracted_path, "26.800.1".to_string(), @@ -75,6 +79,15 @@ async fn fingerprint_update_task_should_apply_initial_appcast_update_to_reposito ), ("26.900.1", "7001", "147") ); + let runtime_snapshot = runtime_fingerprint.snapshot(); + assert_eq!( + ( + runtime_snapshot.app_version.as_str(), + runtime_snapshot.build_number.as_str(), + runtime_snapshot.chromium_version.as_str() + ), + ("26.900.1", "7001", "147") + ); } #[tokio::test] @@ -103,8 +116,10 @@ async fn fingerprint_update_task_should_not_persist_when_appcast_matches_current repo.ensure_current_seed(&crate::support::fingerprint::test_fingerprint()) .await .expect("seed current fingerprint"); + let runtime_fingerprint = runtime_fingerprint_from_repo(&repo).await; let handle = codex_proxy_rs::runtime::tasks::fingerprint_update::start_fingerprint_update_task( - Some(repo.clone()), + repo.clone(), + runtime_fingerprint, format!("{}/appcast.xml", server.uri()), dir.path().join("missing-extracted-fingerprint.json"), "26.900.1".to_string(), @@ -120,33 +135,3 @@ async fn fingerprint_update_task_should_not_persist_when_appcast_matches_current assert_eq!(history_count, 0); } - -#[tokio::test] -async fn fingerprint_update_task_should_check_appcast_without_repository() { - let server = MockServer::start().await; - mount_appcast( - &server, - r#" - - - - - - - - "#, - ) - .await; - - let dir = tempfile::tempdir().expect("temp dir"); - let handle = codex_proxy_rs::runtime::tasks::fingerprint_update::start_fingerprint_update_task( - None, - format!("{}/appcast.xml", server.uri()), - dir.path().join("missing-extracted-fingerprint.json"), - "26.800.1".to_string(), - "6001".to_string(), - ); - - wait_for_appcast_requests(&server, 1).await; - handle.shutdown().await; -} diff --git a/backend/tests/runtime/tasks/mod.rs b/backend/tests/runtime/tasks/mod.rs index c302d0824..d41cc3cee 100644 --- a/backend/tests/runtime/tasks/mod.rs +++ b/backend/tests/runtime/tasks/mod.rs @@ -10,7 +10,7 @@ use codex_proxy_rs::upstream::accounts::cookies::SqliteCookieStore; use codex_proxy_rs::upstream::accounts::model::{Account, AccountStatus}; use codex_proxy_rs::upstream::accounts::model::{AccountModelUsageDelta, AccountUsageDelta}; use codex_proxy_rs::upstream::accounts::store::{AccountStore, AccountStoreResult}; -use codex_proxy_rs::upstream::fingerprint::FingerprintRepository; +use codex_proxy_rs::upstream::fingerprint::{FingerprintRepository, RuntimeFingerprint}; use codex_proxy_rs::upstream::models::config::ModelConfig; use codex_proxy_rs::upstream::models::service::ModelService; use wiremock::{ @@ -137,6 +137,15 @@ async fn wait_for_current_fingerprint_version( } } +async fn runtime_fingerprint_from_repo(repo: &FingerprintRepository) -> RuntimeFingerprint { + RuntimeFingerprint::new( + repo.load_current() + .await + .expect("current fingerprint should load") + .expect("current fingerprint should exist"), + ) +} + async fn wait_for_appcast_requests(server: &MockServer, expected_count: usize) { let deadline = tokio::time::Instant::now() + StdDuration::from_secs(2); loop { diff --git a/backend/tests/support/fingerprint.rs b/backend/tests/support/fingerprint.rs index 3dc19f25e..038da36a1 100644 --- a/backend/tests/support/fingerprint.rs +++ b/backend/tests/support/fingerprint.rs @@ -1,4 +1,7 @@ -use codex_proxy_rs::{config::schema::FingerprintConfig, upstream::fingerprint::Fingerprint}; +use codex_proxy_rs::{ + config::schema::FingerprintConfig, + upstream::fingerprint::{Fingerprint, RuntimeFingerprint}, +}; pub(crate) fn test_fingerprint() -> Fingerprint { Fingerprint::from_config(&FingerprintConfig::default()) @@ -9,3 +12,11 @@ pub(crate) fn test_fingerprint_with_updated_at(updated_at: Option<&str>) -> Fing fingerprint.updated_at = updated_at.map(ToString::to_string); fingerprint } + +pub(crate) fn runtime_test_fingerprint() -> RuntimeFingerprint { + RuntimeFingerprint::new(test_fingerprint()) +} + +pub(crate) fn runtime_fingerprint(fingerprint: Fingerprint) -> RuntimeFingerprint { + RuntimeFingerprint::new(fingerprint) +} diff --git a/backend/tests/upstream/accounts/quota_refresh.rs b/backend/tests/upstream/accounts/quota_refresh.rs index f663ae4e3..a224b8f67 100644 --- a/backend/tests/upstream/accounts/quota_refresh.rs +++ b/backend/tests/upstream/accounts/quota_refresh.rs @@ -312,7 +312,7 @@ async fn quota_refresh_service_should_send_usage_cookie_when_cookie_store_is_con let codex = CodexBackendClient::new( reqwest::Client::new(), server.uri(), - crate::support::fingerprint::test_fingerprint(), + crate::support::fingerprint::runtime_test_fingerprint(), ); let service = RuntimeQuotaRefreshService::new(store, Arc::new(codex)).with_cookie_store(cookies); @@ -393,7 +393,7 @@ async fn quota_refresh_service_should_fetch_usage_for_quota_locked_accounts_and_ let codex = CodexBackendClient::new( reqwest::Client::new(), server.uri(), - crate::support::fingerprint::test_fingerprint(), + crate::support::fingerprint::runtime_test_fingerprint(), ); let service = RuntimeQuotaRefreshService::new(store.clone(), Arc::new(codex)); @@ -468,7 +468,7 @@ async fn quota_refresh_service_should_refresh_quota_exhausted_accounts() { let codex = CodexBackendClient::new( reqwest::Client::new(), server.uri(), - crate::support::fingerprint::test_fingerprint(), + crate::support::fingerprint::runtime_test_fingerprint(), ); let service = RuntimeQuotaRefreshService::new(store, Arc::new(codex)); @@ -505,7 +505,7 @@ async fn quota_refresh_service_should_skip_recent_locked_account_before_cooldown let codex = CodexBackendClient::new( reqwest::Client::new(), server.uri(), - crate::support::fingerprint::test_fingerprint(), + crate::support::fingerprint::runtime_test_fingerprint(), ); let service = RuntimeQuotaRefreshService::with_min_refresh_interval_secs(store, Arc::new(codex), 1_800); @@ -546,7 +546,7 @@ async fn quota_refresh_service_should_skip_recent_locked_account_inside_cooldown let codex = CodexBackendClient::new( reqwest::Client::new(), server.uri(), - crate::support::fingerprint::test_fingerprint(), + crate::support::fingerprint::runtime_test_fingerprint(), ); let service = RuntimeQuotaRefreshService::with_min_refresh_interval_secs(store, Arc::new(codex), 1_800); @@ -604,7 +604,7 @@ async fn quota_refresh_service_should_bypass_recent_skip_after_cooldown_grace() let codex = CodexBackendClient::new( reqwest::Client::new(), server.uri(), - crate::support::fingerprint::test_fingerprint(), + crate::support::fingerprint::runtime_test_fingerprint(), ); let service = RuntimeQuotaRefreshService::with_min_refresh_interval_secs(store, Arc::new(codex), 1_800); @@ -658,7 +658,7 @@ async fn quota_refresh_service_should_stagger_multiple_locked_account_requests() let codex = CodexBackendClient::new( reqwest::Client::new(), server.uri(), - crate::support::fingerprint::test_fingerprint(), + crate::support::fingerprint::runtime_test_fingerprint(), ); let service = RuntimeQuotaRefreshService::new(store, Arc::new(codex)) .with_request_spacing(StdDuration::from_millis(200)); diff --git a/backend/tests/upstream/fingerprint/mod.rs b/backend/tests/upstream/fingerprint/mod.rs index 18b336555..f9bf94d67 100644 --- a/backend/tests/upstream/fingerprint/mod.rs +++ b/backend/tests/upstream/fingerprint/mod.rs @@ -57,14 +57,21 @@ async fn update_checker_should_report_available_update_from_appcast() { .mount(&server) .await; + let dir = tempfile::tempdir().expect("temp dir"); + let db = dir.path().join("fingerprints-check.sqlite"); + let pool = connect_sqlite(&format!("sqlite://{}", db.display())) + .await + .expect("sqlite pool"); + let repo = FingerprintRepository::new(pool); + repo.ensure_current_seed(&crate::support::fingerprint::test_fingerprint()) + .await + .expect("seed current fingerprint"); + let checker = UpdateChecker::with_client( - None, + repo, reqwest::Client::new(), format!("{}/appcast.xml", server.uri()), - tempfile::tempdir() - .expect("temp dir") - .path() - .join("extracted-fingerprint.json"), + dir.path().join("extracted-fingerprint.json"), "26.800.1", "6001", ); @@ -113,7 +120,7 @@ async fn update_checker_should_apply_available_update_to_repository() { .expect("write extracted fingerprint"); let checker = UpdateChecker::with_client( - Some(repo.clone()), + repo.clone(), reqwest::Client::new(), format!("{}/appcast.xml", server.uri()), extracted_path, @@ -121,17 +128,20 @@ async fn update_checker_should_apply_available_update_to_repository() { "6001", ); - let applied = checker + let updated = checker .check_and_apply_update() .await - .expect("apply update"); + .expect("apply update") + .expect("available update should return current fingerprint"); let stored = repo .load_current() .await .expect("load current") .expect("stored fingerprint"); - assert!(applied); + assert_eq!(updated.app_version, "26.900.1"); + assert_eq!(updated.build_number, "7001"); + assert_eq!(updated.chromium_version, "147"); assert_eq!(stored.app_version, "26.900.1"); assert_eq!(stored.build_number, "7001"); assert_eq!(stored.chromium_version, "147"); diff --git a/backend/tests/upstream/transport/client.rs b/backend/tests/upstream/transport/client.rs index 258e9dccf..f3001ce4f 100644 --- a/backend/tests/upstream/transport/client.rs +++ b/backend/tests/upstream/transport/client.rs @@ -59,7 +59,7 @@ async fn codex_backend_client_should_apply_configured_websocket_pool() { let backend = CodexBackendClient::new( reqwest::Client::new(), format!("http://{addr}"), - crate::support::fingerprint::test_fingerprint(), + crate::support::fingerprint::runtime_test_fingerprint(), ) .with_websocket_pool(Arc::new(pool)); let mut request = CodexResponsesRequest::new_http_sse("gpt-5.5", "be brief", Vec::new()); diff --git a/backend/tests/upstream/transport/headers.rs b/backend/tests/upstream/transport/headers.rs index 811fa3ca8..fe032d7c1 100644 --- a/backend/tests/upstream/transport/headers.rs +++ b/backend/tests/upstream/transport/headers.rs @@ -1,4 +1,5 @@ use super::*; +use codex_proxy_rs::upstream::fingerprint::RuntimeFingerprint; use codex_proxy_rs::upstream::transport::build_ordered_codex_headers; use codex_proxy_rs::upstream::transport::websocket::CodexWebSocketConnection; use serde_json::Value; @@ -279,7 +280,7 @@ async fn codex_backend_client_websocket_should_forward_security_chain_headers_an let backend = CodexBackendClient::new( reqwest::Client::builder().no_proxy().build().unwrap(), format!("http://{addr}"), - crate::support::fingerprint::test_fingerprint(), + crate::support::fingerprint::runtime_test_fingerprint(), ) .with_websocket_pool(pool); @@ -420,7 +421,7 @@ async fn codex_backend_client_should_send_desktop_headers_and_capture_response_m let client = CodexBackendClient::new( reqwest::Client::builder().no_proxy().build().unwrap(), server.uri(), - crate::support::fingerprint::test_fingerprint(), + crate::support::fingerprint::runtime_test_fingerprint(), ); let mut request = codex_proxy_rs::upstream::protocol::responses::CodexResponsesRequest::new_http_sse( @@ -471,6 +472,91 @@ async fn codex_backend_client_should_send_desktop_headers_and_capture_response_m ); } +#[tokio::test] +async fn codex_backend_client_should_use_latest_runtime_fingerprint_for_new_requests() { + let server = wiremock::MockServer::start().await; + let sse_body = include_str!("../../fixtures/responses/http_sse/completed_usage_basic.sse"); + wiremock::Mock::given(wiremock::matchers::method("POST")) + .and(wiremock::matchers::path("/codex/responses")) + .respond_with( + wiremock::ResponseTemplate::new(200) + .insert_header("content-type", "text/event-stream") + .set_body_string(sse_body), + ) + .mount(&server) + .await; + + let mut initial = crate::support::fingerprint::test_fingerprint(); + initial.app_version = "26.800.1".to_string(); + let runtime_fingerprint = RuntimeFingerprint::new(initial); + let client = CodexBackendClient::new( + reqwest::Client::builder().no_proxy().build().unwrap(), + server.uri(), + runtime_fingerprint.clone(), + ); + let mut request = + codex_proxy_rs::upstream::protocol::responses::CodexResponsesRequest::new_http_sse( + "gpt-5.5", + "", + Vec::new(), + ); + request.force_http_sse = true; + let request_context = |request_id| CodexRequestContext { + access_token: "access-token", + account_id: Some("chatgpt-account"), + request_id, + turn_state: None, + turn_metadata: None, + beta_features: None, + include_timing_metrics: None, + version: None, + codex_window_id: None, + parent_thread_id: None, + cookie_header: None, + installation_id: None, + session_id: None, + }; + + client + .create_response(&request, request_context("req_fingerprint_1")) + .await + .expect("first response should succeed"); + + let mut updated = runtime_fingerprint.snapshot(); + updated.app_version = "26.900.1".to_string(); + updated.build_number = "7001".to_string(); + runtime_fingerprint.replace(updated); + + client + .create_response(&request, request_context("req_fingerprint_2")) + .await + .expect("second response should succeed"); + + let user_agents = server + .received_requests() + .await + .expect("received requests should load") + .iter() + .map(|request| { + request + .headers + .get("user-agent") + .expect("user-agent should be sent") + .to_str() + .expect("user-agent should be valid") + .to_string() + }) + .collect::>(); + + assert_eq!( + user_agents, + vec![ + "Codex Desktop/26.800.1 (darwin; arm64)".to_string(), + "Codex Desktop/26.900.1 (darwin; arm64)".to_string(), + ] + ); +} + #[tokio::test] async fn codex_backend_client_usage_should_use_wham_usage_headers() { let server = wiremock::MockServer::start().await; @@ -486,7 +572,7 @@ async fn codex_backend_client_usage_should_use_wham_usage_headers() { let client = CodexBackendClient::new( reqwest::Client::builder().no_proxy().build().unwrap(), server.uri(), - crate::support::fingerprint::test_fingerprint(), + crate::support::fingerprint::runtime_test_fingerprint(), ); let usage = client @@ -571,7 +657,7 @@ async fn codex_backend_client_models_should_use_original_auxiliary_headers() { let client = CodexBackendClient::new( reqwest::Client::builder().no_proxy().build().unwrap(), server.uri(), - crate::support::fingerprint::test_fingerprint(), + crate::support::fingerprint::runtime_test_fingerprint(), ); let models = client @@ -638,7 +724,7 @@ async fn codex_backend_client_should_send_http_sse_headers_in_fingerprint_order( let client = CodexBackendClient::new( reqwest::Client::builder().no_proxy().build().unwrap(), format!("http://{addr}"), - crate::support::fingerprint::test_fingerprint(), + crate::support::fingerprint::runtime_test_fingerprint(), ); client @@ -713,7 +799,7 @@ async fn codex_backend_client_should_send_compact_headers_in_fingerprint_order() let client = CodexBackendClient::new( reqwest::Client::builder().no_proxy().build().unwrap(), format!("http://{addr}"), - crate::support::fingerprint::test_fingerprint(), + crate::support::fingerprint::runtime_test_fingerprint(), ); client diff --git a/backend/tests/upstream/transport/http_client.rs b/backend/tests/upstream/transport/http_client.rs index 923c7ff83..40bceba03 100644 --- a/backend/tests/upstream/transport/http_client.rs +++ b/backend/tests/upstream/transport/http_client.rs @@ -107,7 +107,7 @@ async fn codex_backend_client_should_cap_non_success_error_body_at_one_mib() { let client = CodexBackendClient::new( reqwest::Client::builder().no_proxy().build().unwrap(), server.uri(), - crate::support::fingerprint::test_fingerprint(), + crate::support::fingerprint::runtime_test_fingerprint(), ); let mut request = codex_proxy_rs::upstream::protocol::responses::CodexResponsesRequest::new_http_sse( @@ -161,7 +161,7 @@ async fn codex_backend_client_should_parse_retry_after_from_rate_limit_error_bod let client = CodexBackendClient::new( reqwest::Client::builder().no_proxy().build().unwrap(), server.uri(), - crate::support::fingerprint::test_fingerprint(), + crate::support::fingerprint::runtime_test_fingerprint(), ); let mut request = codex_proxy_rs::upstream::protocol::responses::CodexResponsesRequest::new_http_sse( diff --git a/backend/tests/upstream/transport/websocket.rs b/backend/tests/upstream/transport/websocket.rs index 294032005..5a98eb7e1 100644 --- a/backend/tests/upstream/transport/websocket.rs +++ b/backend/tests/upstream/transport/websocket.rs @@ -107,7 +107,7 @@ async fn codex_backend_client_should_decode_permessage_deflate_context_takeover_ let backend = CodexBackendClient::new( reqwest::Client::builder().no_proxy().build().unwrap(), format!("http://{addr}"), - crate::support::fingerprint::test_fingerprint(), + crate::support::fingerprint::runtime_test_fingerprint(), ) .with_websocket_pool(pool); let mut request = @@ -1000,7 +1000,7 @@ async fn codex_backend_client_stream_should_reject_binary_websocket_event() { let backend = CodexBackendClient::new( reqwest::Client::builder().no_proxy().build().unwrap(), format!("http://{addr}"), - crate::support::fingerprint::test_fingerprint(), + crate::support::fingerprint::runtime_test_fingerprint(), ); let Err(error) = backend @@ -1073,7 +1073,7 @@ async fn codex_backend_client_stream_should_retry_without_pool_when_first_conten let backend = CodexBackendClient::new( reqwest::Client::builder().no_proxy().build().unwrap(), format!("http://{addr}"), - crate::support::fingerprint::test_fingerprint(), + crate::support::fingerprint::runtime_test_fingerprint(), ) .with_websocket_first_token_timeout(Some(Duration::from_millis(30))); let request = pooled_websocket_request("conversation-first-token-no-pool-timeout"); @@ -1133,7 +1133,7 @@ async fn codex_backend_client_stream_should_error_when_websocket_closes_before_t let backend = CodexBackendClient::new( reqwest::Client::builder().no_proxy().build().unwrap(), format!("http://{addr}"), - crate::support::fingerprint::test_fingerprint(), + crate::support::fingerprint::runtime_test_fingerprint(), ); let mut response = backend @@ -1203,7 +1203,7 @@ async fn codex_backend_client_stream_should_wait_for_terminal_after_active_webso let backend = CodexBackendClient::new( reqwest::Client::builder().no_proxy().build().unwrap(), format!("http://{addr}"), - crate::support::fingerprint::test_fingerprint(), + crate::support::fingerprint::runtime_test_fingerprint(), ); let mut response = backend @@ -1267,7 +1267,7 @@ async fn codex_backend_client_stream_should_timeout_when_active_websocket_stalls let backend = CodexBackendClient::new( reqwest::Client::builder().no_proxy().build().unwrap(), format!("http://{addr}"), - crate::support::fingerprint::test_fingerprint(), + crate::support::fingerprint::runtime_test_fingerprint(), ); let mut response = backend @@ -1363,7 +1363,7 @@ async fn codex_backend_client_should_use_websocket_when_previous_response_id_is_ let backend = CodexBackendClient::new( reqwest::Client::builder().no_proxy().build().unwrap(), format!("http://{addr}"), - crate::support::fingerprint::test_fingerprint(), + crate::support::fingerprint::runtime_test_fingerprint(), ) .with_websocket_pool(pool); diff --git a/backend/tests/upstream/transport/websocket_pool.rs b/backend/tests/upstream/transport/websocket_pool.rs index c7fc7d372..a700765ff 100644 --- a/backend/tests/upstream/transport/websocket_pool.rs +++ b/backend/tests/upstream/transport/websocket_pool.rs @@ -39,7 +39,7 @@ async fn codex_backend_client_should_reuse_pooled_websocket_for_same_account_and let backend = CodexBackendClient::new( reqwest::Client::builder().no_proxy().build().unwrap(), format!("http://{addr}"), - crate::support::fingerprint::test_fingerprint(), + crate::support::fingerprint::runtime_test_fingerprint(), ) .with_websocket_pool(Arc::clone(&pool)); let mut request = @@ -117,7 +117,7 @@ async fn codex_backend_client_should_open_fresh_socket_when_idle_pooled_websocke let backend = CodexBackendClient::new( reqwest::Client::builder().no_proxy().build().unwrap(), format!("http://{addr}"), - crate::support::fingerprint::test_fingerprint(), + crate::support::fingerprint::runtime_test_fingerprint(), ) .with_websocket_pool(Arc::clone(&pool)); let mut request = @@ -212,7 +212,7 @@ async fn codex_backend_client_stream_should_retry_fresh_socket_when_first_conten let backend = CodexBackendClient::new( reqwest::Client::builder().no_proxy().build().unwrap(), format!("http://{addr}"), - crate::support::fingerprint::test_fingerprint(), + crate::support::fingerprint::runtime_test_fingerprint(), ) .with_websocket_pool(Arc::clone(&pool)); let request = pooled_websocket_request("conversation-first-token-fresh-timeout"); @@ -305,7 +305,7 @@ async fn codex_backend_client_stream_should_retry_reused_socket_when_first_conte let backend = CodexBackendClient::new( reqwest::Client::builder().no_proxy().build().unwrap(), format!("http://{addr}"), - crate::support::fingerprint::test_fingerprint(), + crate::support::fingerprint::runtime_test_fingerprint(), ) .with_websocket_pool(Arc::clone(&pool)); let request = pooled_websocket_request("conversation-first-token-reuse-timeout"); @@ -399,7 +399,7 @@ async fn codex_backend_client_stream_should_retry_bypass_socket_when_first_conte let backend = CodexBackendClient::new( reqwest::Client::builder().no_proxy().build().unwrap(), format!("http://{addr}"), - crate::support::fingerprint::test_fingerprint(), + crate::support::fingerprint::runtime_test_fingerprint(), ) .with_websocket_pool(Arc::clone(&pool)); let request = pooled_websocket_request("conversation-first-token-bypass-timeout"); @@ -454,7 +454,7 @@ async fn codex_backend_client_should_not_reuse_pooled_websocket_across_local_acc let backend = CodexBackendClient::new( reqwest::Client::builder().no_proxy().build().unwrap(), format!("http://{addr}"), - crate::support::fingerprint::test_fingerprint(), + crate::support::fingerprint::runtime_test_fingerprint(), ) .with_websocket_pool(Arc::clone(&pool)); let mut request = @@ -547,7 +547,7 @@ async fn websocket_pool_should_bypass_busy_key_with_one_shot_connections() { let backend = CodexBackendClient::new( reqwest::Client::builder().no_proxy().build().unwrap(), format!("http://{addr}"), - crate::support::fingerprint::test_fingerprint(), + crate::support::fingerprint::runtime_test_fingerprint(), ) .with_websocket_pool(pool); let request = pooled_websocket_request("conversation-busy"); @@ -640,7 +640,7 @@ async fn websocket_pool_should_release_slot_when_client_drops_stream() { let backend = CodexBackendClient::new( reqwest::Client::builder().no_proxy().build().unwrap(), format!("http://{addr}"), - crate::support::fingerprint::test_fingerprint(), + crate::support::fingerprint::runtime_test_fingerprint(), ) .with_websocket_pool(pool); let request = pooled_websocket_request("conversation-drop"); @@ -714,7 +714,7 @@ async fn websocket_pool_should_bypass_new_keys_after_account_cap() { let backend = CodexBackendClient::new( reqwest::Client::builder().no_proxy().build().unwrap(), format!("http://{addr}"), - crate::support::fingerprint::test_fingerprint(), + crate::support::fingerprint::runtime_test_fingerprint(), ) .with_websocket_pool(pool); let first_request = pooled_websocket_request("conversation-cap-one"); @@ -800,7 +800,7 @@ async fn websocket_pool_should_evict_idle_connection_when_liveness_lapses_despit let backend = CodexBackendClient::new( reqwest::Client::builder().no_proxy().build().unwrap(), format!("http://{addr}"), - crate::support::fingerprint::test_fingerprint(), + crate::support::fingerprint::runtime_test_fingerprint(), ) .with_websocket_pool(Arc::clone(&pool)); let request = pooled_websocket_request("conversation-no-pong"); @@ -874,7 +874,7 @@ async fn websocket_pool_should_gc_expired_idle_connections() { let backend = CodexBackendClient::new( reqwest::Client::builder().no_proxy().build().unwrap(), format!("http://{addr}"), - crate::support::fingerprint::test_fingerprint(), + crate::support::fingerprint::runtime_test_fingerprint(), ) .with_websocket_pool(Arc::clone(&pool)); let request = pooled_websocket_request("conversation-gc"); @@ -978,7 +978,7 @@ async fn codex_backend_client_should_keep_idle_pooled_websocket_alive_across_rep let backend = CodexBackendClient::new( reqwest::Client::builder().no_proxy().build().unwrap(), format!("http://{addr}"), - crate::support::fingerprint::test_fingerprint(), + crate::support::fingerprint::runtime_test_fingerprint(), ) .with_websocket_pool(Arc::clone(&pool)); let mut request = @@ -1084,7 +1084,7 @@ async fn codex_backend_client_should_close_idle_pooled_websocket_when_account_is let backend = CodexBackendClient::new( reqwest::Client::builder().no_proxy().build().unwrap(), format!("http://{addr}"), - crate::support::fingerprint::test_fingerprint(), + crate::support::fingerprint::runtime_test_fingerprint(), ) .with_websocket_pool(Arc::clone(&pool)); let mut request = @@ -1188,7 +1188,7 @@ async fn codex_backend_client_should_stop_reusing_pooled_websockets_after_shutdo let backend = CodexBackendClient::new( reqwest::Client::builder().no_proxy().build().unwrap(), format!("http://{addr}"), - crate::support::fingerprint::test_fingerprint(), + crate::support::fingerprint::runtime_test_fingerprint(), ) .with_websocket_pool(Arc::clone(&pool)); let mut request = @@ -1292,7 +1292,7 @@ async fn codex_backend_client_should_close_idle_pooled_websocket_after_liveness_ let backend = CodexBackendClient::new( reqwest::Client::builder().no_proxy().build().unwrap(), format!("http://{addr}"), - crate::support::fingerprint::test_fingerprint(), + crate::support::fingerprint::runtime_test_fingerprint(), ) .with_websocket_pool(Arc::clone(&pool)); let mut request = @@ -1387,7 +1387,7 @@ async fn codex_backend_client_should_discard_pooled_websocket_after_upstream_err let backend = CodexBackendClient::new( reqwest::Client::builder().no_proxy().build().unwrap(), format!("http://{addr}"), - crate::support::fingerprint::test_fingerprint(), + crate::support::fingerprint::runtime_test_fingerprint(), ) .with_websocket_pool(pool); let mut request = @@ -1467,7 +1467,7 @@ async fn codex_backend_client_should_reuse_pooled_websocket_after_unmapped_respo let backend = CodexBackendClient::new( reqwest::Client::builder().no_proxy().build().unwrap(), format!("http://{addr}"), - crate::support::fingerprint::test_fingerprint(), + crate::support::fingerprint::runtime_test_fingerprint(), ) .with_websocket_pool(pool); let request = pooled_websocket_request("conversation-pool-unmapped-failed");