diff --git a/backend/Cargo.lock b/backend/Cargo.lock index 891f929be..4440c8d51 100644 --- a/backend/Cargo.lock +++ b/backend/Cargo.lock @@ -371,6 +371,7 @@ dependencies = [ "rand_core 0.6.4", "redis", "reqwest", + "roxmltree", "rustls", "rustls-native-certs", "rustls-pki-types", @@ -1905,6 +1906,15 @@ dependencies = [ "windows-sys 0.52.0", ] +[[package]] +name = "roxmltree" +version = "0.21.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1964b10c76125c36f8afe190065a4bf9a87bf324842c05701330bba9f1cacbb" +dependencies = [ + "memchr", +] + [[package]] name = "rustc-hash" version = "2.1.3" diff --git a/backend/Cargo.toml b/backend/Cargo.toml index 23622fec1..f79c34cc1 100644 --- a/backend/Cargo.toml +++ b/backend/Cargo.toml @@ -25,6 +25,7 @@ rand = "0.10.1" # Enables password_hash::rand_core::OsRng for argon2 0.5. rand_core = { version = "0.6.4", features = ["getrandom"] } redis = { version = "1.3.0", default-features = false, features = ["tokio-comp", "connection-manager", "script"] } +roxmltree = "0.21.1" # Pinned to the Codex Desktop 26.707.41301 HTTP stack; re-audit a newer Desktop before upgrading. reqwest = { version = "=0.12.28", default-features = false, features = ["json", "stream", "cookies", "rustls-tls-native-roots", "gzip", "brotli", "zstd", "deflate", "http2"] } rustls = { version = "=0.23.36", default-features = false, features = ["ring", "std", "tls12"] } diff --git a/backend/src/api/admin/dashboard_routes.rs b/backend/src/api/admin/dashboard_routes.rs index f95482a7a..18d5615ca 100644 --- a/backend/src/api/admin/dashboard_routes.rs +++ b/backend/src/api/admin/dashboard_routes.rs @@ -28,10 +28,7 @@ use crate::{ }, infra::{ format::{format_duration_ms, format_tokens}, - time::{ - china_datetime, china_datetime_rfc3339_str, china_day_start, china_quarter_hour_start, - china_relative_time, - }, + time::{china_datetime, china_day_start, china_quarter_hour_start, china_relative_time}, }, telemetry::{ account_usage::query::AccountUsageTimeBucket, @@ -45,7 +42,6 @@ use crate::{ types::{UsageRecord, metadata_string}, }, }, - upstream::openai::fingerprint::Fingerprint, }; const DASHBOARD_ACCOUNT_USAGE_LIMIT: u32 = 4; @@ -69,7 +65,7 @@ struct DashboardSummaryData { trend: DashboardTrendData, health_timeline: DashboardHealthTimelineData, account_usage: Vec, - service_statuses: Vec, + wire_profile: DashboardWireProfileData, usage_records: Vec, pool_summary: AccountPoolDiagnostics, capacity_info: AccountCapacityDiagnostics, @@ -114,11 +110,50 @@ struct DashboardUsageRecordData { #[derive(Debug, Serialize)] #[serde(rename_all = "camelCase")] -struct DashboardServiceStatusData { - label: String, - value: String, - detail: String, - tone: String, +struct DashboardWireProfileData { + originator: String, + codex_version: String, + desktop_version: String, + desktop_build: String, + target: DashboardWireTargetData, + user_agent: String, + verified_at: DateTime, + release: DashboardDesktopReleaseData, +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +struct DashboardWireTargetData { + os_type: String, + os_version: String, + arch: String, + terminal: String, +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +struct DashboardDesktopReleaseData { + status: &'static str, + #[serde(skip_serializing_if = "Option::is_none")] + checked_at: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + latest_version: Option, + #[serde(skip_serializing_if = "Option::is_none")] + latest_build: Option, + #[serde(skip_serializing_if = "Option::is_none")] + published_at: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + minimum_system_version: Option, + #[serde(skip_serializing_if = "Option::is_none")] + hardware_requirements: Option, + #[serde(skip_serializing_if = "Option::is_none")] + download_url: Option, + #[serde(skip_serializing_if = "Option::is_none")] + download_size: Option, + #[serde(skip_serializing_if = "Option::is_none")] + signature_present: Option, + #[serde(skip_serializing_if = "Option::is_none")] + error: Option, } #[derive(Debug, Default, Serialize)] @@ -142,35 +177,6 @@ struct AccountCapacityDiagnostics { available_slots: usize, } -#[derive(Debug, Serialize)] -#[serde(rename_all = "camelCase")] -struct FingerprintDiagnostics { - source: &'static str, - originator: String, - app_version: String, - build_number: String, - platform: String, - arch: String, - chromium_version: String, - user_agent: String, - #[serde(skip_serializing_if = "Option::is_none")] - updated_at: Option, -} - -fn fingerprint_diagnostics(fingerprint: &Fingerprint) -> FingerprintDiagnostics { - FingerprintDiagnostics { - source: "runtime", - originator: fingerprint.originator.clone(), - app_version: fingerprint.app_version.clone(), - build_number: fingerprint.build_number.clone(), - platform: fingerprint.platform.clone(), - arch: fingerprint.arch.clone(), - chromium_version: fingerprint.chromium_version.clone(), - user_agent: fingerprint.user_agent(), - updated_at: fingerprint.updated_at.clone(), - } -} - impl From for AccountCapacityDiagnostics { fn from(summary: AccountCapacitySummary) -> Self { Self { @@ -266,7 +272,7 @@ pub(crate) async fn dashboard_summary( "a_used_by_account, now, ), - service_statuses: service_status_data(&state), + wire_profile: wire_profile_data(&state), usage_records: dashboard_usage_records, pool_summary, capacity_info: AccountCapacityDiagnostics::from(capacity), @@ -554,66 +560,53 @@ fn dashboard_usage_record_data( } } -fn service_status_data(state: &AppState) -> Vec { - let current_fingerprint = state.services.fingerprint.snapshot(); - let fingerprint = fingerprint_diagnostics(¤t_fingerprint); - vec![ - DashboardServiceStatusData { - label: "客户端版本".to_string(), - value: empty_dash(fingerprint.app_version), - detail: if fingerprint.build_number.trim().is_empty() { - "-".to_string() - } else { - format!("Build {}", fingerprint.build_number) - }, - tone: "info".to_string(), - }, - DashboardServiceStatusData { - label: "平台架构".to_string(), - value: empty_dash(fingerprint.platform), - detail: empty_dash(fingerprint.arch), - tone: "info".to_string(), - }, - DashboardServiceStatusData { - label: "Chromium".to_string(), - value: if fingerprint.chromium_version.trim().is_empty() { - "-".to_string() - } else { - format!("v{}", fingerprint.chromium_version) - }, - detail: empty_dash(fingerprint.originator), - tone: "normal".to_string(), - }, - DashboardServiceStatusData { - label: "更新时间".to_string(), - value: format_fingerprint_updated_at(fingerprint.updated_at), - detail: String::new(), - tone: "normal".to_string(), - }, - DashboardServiceStatusData { - label: "User Agent".to_string(), - value: fingerprint.user_agent, - detail: String::new(), - tone: "normal".to_string(), - }, - ] -} - -fn empty_dash(value: String) -> String { - if value.trim().is_empty() { - "-".to_string() +fn wire_profile_data(state: &AppState) -> DashboardWireProfileData { + let profile = &state.services.wire_profile; + let release_snapshot = state.services.desktop_release.snapshot(); + let release_status = if release_snapshot.last_error.is_some() { + "check_failed" + } else if let Some(latest) = &release_snapshot.latest { + if latest.version == profile.desktop_version && latest.build == profile.desktop_build { + "aligned" + } else { + "review_required" + } } else { - value - } -} - -fn format_fingerprint_updated_at(value: Option) -> String { - let Some(value) = value else { - return "-".to_string(); + "unchecked" }; - let value = value.trim(); - if value.is_empty() { - return "-".to_string(); + let latest = release_snapshot.latest; + + DashboardWireProfileData { + originator: profile.originator.clone(), + codex_version: profile.codex_version.clone(), + desktop_version: profile.desktop_version.clone(), + desktop_build: profile.desktop_build.clone(), + target: DashboardWireTargetData { + os_type: profile.os_type.clone(), + os_version: profile.os_version.clone(), + arch: profile.arch.clone(), + terminal: profile.terminal.clone(), + }, + user_agent: profile.user_agent(), + verified_at: profile.verified_at, + release: DashboardDesktopReleaseData { + status: release_status, + checked_at: release_snapshot.checked_at, + latest_version: latest.as_ref().map(|release| release.version.clone()), + latest_build: latest.as_ref().map(|release| release.build.clone()), + published_at: latest.as_ref().and_then(|release| release.published_at), + minimum_system_version: latest + .as_ref() + .and_then(|release| release.minimum_system_version.clone()), + hardware_requirements: latest + .as_ref() + .and_then(|release| release.hardware_requirements.clone()), + download_url: latest + .as_ref() + .and_then(|release| release.download_url.clone()), + download_size: latest.as_ref().and_then(|release| release.download_size), + signature_present: latest.as_ref().map(|release| release.signature_present), + error: release_snapshot.last_error, + }, } - china_datetime_rfc3339_str(value) } diff --git a/backend/src/api/router.rs b/backend/src/api/router.rs index 5181ca5e3..34708a581 100644 --- a/backend/src/api/router.rs +++ b/backend/src/api/router.rs @@ -27,7 +27,10 @@ use crate::{ usage::query::UsageQueryService, }, update::service::SystemUpdateService, - upstream::openai::{fingerprint::RuntimeFingerprint, token_client::OpenAiTokenClient}, + upstream::openai::{ + desktop_release::DesktopReleaseStatus, profile::CodexWireProfile, + token_client::OpenAiTokenClient, + }, }; /// HTTP API 所需的领域服务集合。 @@ -48,7 +51,8 @@ pub struct ApiServices { pub token_refresh: Arc>, pub responses: Arc, pub session_affinity: Arc, - pub fingerprint: RuntimeFingerprint, + pub wire_profile: Arc, + pub desktop_release: DesktopReleaseStatus, pub process_control: Arc, pub system_update: Arc, pub connection_drain: crate::api::middleware::connection_drain::ConnectionDrain, diff --git a/backend/src/bootstrap/config.rs b/backend/src/bootstrap/config.rs index 395b9b103..b4f652972 100644 --- a/backend/src/bootstrap/config.rs +++ b/backend/src/bootstrap/config.rs @@ -1,16 +1,16 @@ use std::{ - collections::{BTreeMap, HashSet}, + collections::BTreeMap, env, path::{Path, PathBuf}, }; -use axum::http::HeaderName; +use chrono::{DateTime, Utc}; use secrecy::{ExposeSecret, SecretBox}; use serde::{Deserialize, de::IgnoredAny}; use tracing_subscriber::EnvFilter; use url::Url; -const CONFIG_SCHEMA_VERSION: u32 = 1; +const CONFIG_SCHEMA_VERSION: u32 = 2; const CONFIG_RELATIVE_PATH: &str = "deploy/config.yaml"; const SERVER_HOST_ENV: &str = "CPR_SERVER_HOST"; const SERVER_PORT_ENV: &str = "CPR_SERVER_PORT"; @@ -106,7 +106,7 @@ impl BootstrapConfig { runtime, quota, ws_pool, - fingerprint, + wire_profile, admin, logging, telemetry, @@ -122,7 +122,7 @@ impl BootstrapConfig { redis: RedisConfig { url: redis.url }, runtime, ws_pool, - fingerprint, + wire_profile, admin: AdminConfig { session_ttl_minutes: admin.session_ttl_minutes, default_username: admin.default_username, @@ -241,8 +241,8 @@ pub struct AppConfig { pub runtime: RuntimePathsConfig, /// WebSocket 连接池启动设置。 pub ws_pool: WebSocketPoolSettings, - /// 上游请求指纹默认配置。 - pub fingerprint: FingerprintConfig, + /// 经审计固定的 Codex Desktop 上游请求画像。 + pub wire_profile: WireProfileConfig, /// 管理员初始化配置,不包含密码。 pub admin: AdminConfig, /// 日志配置。 @@ -354,38 +354,28 @@ pub struct WebSocketPoolSettings { pub initial_event_timeout_ms: u64, } -/// 上游请求指纹默认配置。 +/// 经审计固定的 Codex Desktop 上游请求画像配置。 #[derive(Debug, Clone, Deserialize, PartialEq, Eq)] #[serde(deny_unknown_fields)] -pub struct FingerprintConfig { - /// 客户端来源名。 +pub struct WireProfileConfig { + /// `originator` 请求头及 User-Agent 产品名。 pub originator: String, - /// 应用版本。 - pub app_version: String, - /// 构建号。 - pub build_number: String, - /// 平台名。 - pub platform: String, - /// 架构名。 + /// bundled Codex Core 版本。 + pub codex_version: String, + /// Desktop 应用版本。 + pub desktop_version: String, + /// Desktop 制品构建号。 + pub desktop_build: String, + /// Codex Core UA 中的目标操作系统类型。 + pub os_type: String, + /// Codex Core UA 中的目标操作系统版本。 + pub os_version: String, + /// Codex Core UA 中的目标架构。 pub arch: String, - /// Chromium 主版本。 - pub chromium_version: String, - /// User-Agent 模板。 - pub user_agent_template: String, - /// 默认请求头。 - pub default_headers: Vec, - /// 请求头排序优先级。 - pub header_order: Vec, -} - -/// 指纹默认请求头配置项。 -#[derive(Debug, Clone, Deserialize, PartialEq, Eq)] -#[serde(deny_unknown_fields)] -pub struct FingerprintHeaderConfig { - /// 请求头名称。 - pub name: String, - /// 请求头值。 - pub value: String, + /// Codex Core UA 中的终端标记。 + pub terminal: String, + /// 此画像最后一次经制品与源码核验的时间。 + pub verified_at: DateTime, } /// 管理员初始化与会话配置,不包含初始化密码。 @@ -453,7 +443,7 @@ struct FileAppConfig { runtime: RuntimePathsConfig, quota: QuotaConfig, ws_pool: WebSocketPoolSettings, - fingerprint: FingerprintConfig, + wire_profile: WireProfileConfig, admin: FileAdminConfig, logging: LoggingConfig, telemetry: TelemetryConfig, @@ -549,7 +539,7 @@ fn validate_file_config(config: &FileAppConfig) -> Result<(), ConfigError> { if config.ws_pool.max_per_account == 0 { return Err(ConfigError::InvalidField("ws_pool.max_per_account")); } - validate_fingerprint(&config.fingerprint)?; + validate_wire_profile(&config.wire_profile)?; validate_logging(&config.logging)?; Ok(()) } @@ -594,54 +584,48 @@ fn validate_admin_password(password: &str) -> Result<(), ConfigError> { Ok(()) } -fn validate_fingerprint(config: &FingerprintConfig) -> Result<(), ConfigError> { +fn validate_wire_profile(config: &WireProfileConfig) -> Result<(), ConfigError> { for (field, value) in [ - ("fingerprint.originator", config.originator.as_str()), - ("fingerprint.app_version", config.app_version.as_str()), - ("fingerprint.build_number", config.build_number.as_str()), - ("fingerprint.platform", config.platform.as_str()), - ("fingerprint.arch", config.arch.as_str()), + ("wire_profile.originator", config.originator.as_str()), + ("wire_profile.codex_version", config.codex_version.as_str()), ( - "fingerprint.chromium_version", - config.chromium_version.as_str(), - ), - ( - "fingerprint.user_agent_template", - config.user_agent_template.as_str(), + "wire_profile.desktop_version", + config.desktop_version.as_str(), ), + ("wire_profile.desktop_build", config.desktop_build.as_str()), + ("wire_profile.os_type", config.os_type.as_str()), + ("wire_profile.os_version", config.os_version.as_str()), + ("wire_profile.arch", config.arch.as_str()), + ("wire_profile.terminal", config.terminal.as_str()), ] { validate_nonempty(field, value)?; } - if config.default_headers.is_empty() || config.header_order.is_empty() { - return Err(ConfigError::InvalidField("fingerprint")); + if semver::Version::parse(&config.codex_version).is_err() { + return Err(ConfigError::InvalidField("wire_profile.codex_version")); } - - let mut header_names = HashSet::new(); - for header in &config.default_headers { - let name = header.name.trim().to_ascii_lowercase(); - if name.is_empty() - || header.value.is_empty() - || HeaderName::from_bytes(name.as_bytes()).is_err() - || !header_names.insert(name) - { - return Err(ConfigError::InvalidField("fingerprint.default_headers")); - } + if !is_numeric_dotted_version(&config.desktop_version) { + return Err(ConfigError::InvalidField("wire_profile.desktop_version")); } - - let mut ordered_headers = HashSet::new(); - for header in &config.header_order { - let header = header.trim().to_ascii_lowercase(); - if header.is_empty() - || HeaderName::from_bytes(header.as_bytes()).is_err() - || !ordered_headers.insert(header) - { - return Err(ConfigError::InvalidField("fingerprint.header_order")); - } + if !config + .desktop_build + .bytes() + .all(|byte| byte.is_ascii_digit()) + { + return Err(ConfigError::InvalidField("wire_profile.desktop_build")); } Ok(()) } +fn is_numeric_dotted_version(value: &str) -> bool { + let mut parts = value.split('.'); + let valid_parts = parts + .by_ref() + .filter(|part| !part.is_empty() && part.bytes().all(|byte| byte.is_ascii_digit())) + .count(); + valid_parts >= 2 && valid_parts == value.split('.').count() +} + fn validate_logging(config: &LoggingConfig) -> Result<(), ConfigError> { EnvFilter::try_new(&config.level).map_err(|_| ConfigError::InvalidField("logging.level"))?; if !config.stdout && !config.file.enabled { @@ -699,7 +683,7 @@ pub enum ConfigError { path: PathBuf, }, /// 配置格式版本不受支持。 - #[error("x-cpr.schema_version must be 1")] + #[error("x-cpr.schema_version must be 2")] UnsupportedSchemaVersion, /// 字段值非法。 #[error("configuration field `{0}` is invalid")] diff --git a/backend/src/bootstrap/services.rs b/backend/src/bootstrap/services.rs index 713482fb1..3a6617264 100644 --- a/backend/src/bootstrap/services.rs +++ b/backend/src/bootstrap/services.rs @@ -50,7 +50,8 @@ use crate::{ }, update::service::SystemUpdateService, upstream::openai::{ - fingerprint::{Fingerprint, PgFingerprintStore, RuntimeFingerprint}, + desktop_release::DesktopReleaseStatus, + profile::CodexWireProfile, token_client::{OpenAiTokenClient, TokenClientConfig, default_openai_token_client}, transport::{ CodexBackendClient, CodexModelCatalogClient, CodexWebSocketPool, @@ -80,7 +81,6 @@ pub struct BackgroundTaskStores { pub admin_users: PgAdminUserStore, pub admin_sessions: RedisAdminSessionStore, pub cookies: PgCookieStore, - pub fingerprints: PgFingerprintStore, pub session_affinity: RedisSessionAffinityStore, pub refresh_leases: RedisRefreshLeaseStore, pub model_snapshots: RedisModelSnapshotStore, @@ -117,7 +117,8 @@ pub struct Services { pub session_affinity: Arc, pub codex: Arc, pub websocket_pool: Option>, - pub fingerprint: RuntimeFingerprint, + pub wire_profile: Arc, + pub desktop_release: DesktopReleaseStatus, pub account_pseudonymizer: Arc, pub system_update: Arc, pub connection_drain: crate::api::middleware::connection_drain::ConnectionDrain, @@ -145,21 +146,21 @@ impl Services { pub fn new( config: &AppConfig, stores: BackgroundTaskStores, - fingerprint: RuntimeFingerprint, + wire_profile: Arc, ) -> Self { - Self::try_new(config, stores, fingerprint) + Self::try_new(config, stores, wire_profile) .expect("failed to build runtime services with configured TLS transport") } pub fn try_new( config: &AppConfig, stores: BackgroundTaskStores, - fingerprint: RuntimeFingerprint, + wire_profile: Arc, ) -> Result { Self::try_with_usage_record_options( config, stores, - fingerprint, + wire_profile, UsageRecordOptions::from_config(config), ) } @@ -167,7 +168,7 @@ impl Services { pub fn try_with_usage_record_options( config: &AppConfig, stores: BackgroundTaskStores, - fingerprint: RuntimeFingerprint, + wire_profile: Arc, usage_record_options: UsageRecordOptions, ) -> Result { let mut identity_secret = [0u8; 32]; @@ -175,7 +176,7 @@ impl Services { Self::try_with_identity_secret_and_usage_record_options( config, stores, - fingerprint, + wire_profile, identity_secret, usage_record_options, ) @@ -184,7 +185,7 @@ impl Services { fn try_with_identity_secret_and_usage_record_options( config: &AppConfig, stores: BackgroundTaskStores, - fingerprint: RuntimeFingerprint, + wire_profile: Arc, identity_secret: [u8; 32], usage_record_options: UsageRecordOptions, ) -> Result { @@ -214,7 +215,7 @@ impl Services { let client = CodexBackendClient::new( build_reqwest_client()?, config.api.base_url.clone(), - fingerprint.clone(), + wire_profile.clone(), ) .with_websocket_initial_event_timeout(websocket_initial_event_timeout); if let Some(pool) = &websocket_pool { @@ -313,6 +314,8 @@ impl Services { })); let system_update = Arc::new(SystemUpdateService::from_env()); + let desktop_release = DesktopReleaseStatus::default(); + Ok(Self { database, redis, @@ -333,7 +336,8 @@ impl Services { session_affinity, codex, websocket_pool, - fingerprint, + wire_profile, + desktop_release, account_pseudonymizer, system_update, connection_drain, @@ -370,7 +374,8 @@ impl From<&Services> for crate::api::router::ApiServices { token_refresh: services.token_refresh.clone(), responses: services.responses.clone(), session_affinity: services.session_affinity.clone(), - fingerprint: services.fingerprint.clone(), + wire_profile: services.wire_profile.clone(), + desktop_release: services.desktop_release.clone(), process_control: Arc::new(crate::bootstrap::shutdown::RuntimeProcessControl), system_update: services.system_update.clone(), connection_drain: services.connection_drain.clone(), @@ -419,24 +424,19 @@ pub fn apply_settings_to_config(config: &mut AppConfig, settings: &SettingsSnaps config.auth.rotation_strategy = settings.rotation_strategy.clone(); } -pub fn fingerprint_from_config( - config: &crate::bootstrap::config::FingerprintConfig, -) -> Fingerprint { - Fingerprint { +pub fn wire_profile_from_config( + config: &crate::bootstrap::config::WireProfileConfig, +) -> CodexWireProfile { + CodexWireProfile { originator: config.originator.clone(), - app_version: config.app_version.clone(), - build_number: config.build_number.clone(), - platform: config.platform.clone(), + codex_version: config.codex_version.clone(), + desktop_version: config.desktop_version.clone(), + desktop_build: config.desktop_build.clone(), + os_type: config.os_type.clone(), + os_version: config.os_version.clone(), arch: config.arch.clone(), - chromium_version: config.chromium_version.clone(), - user_agent_template: config.user_agent_template.clone(), - default_headers: config - .default_headers - .iter() - .map(|header| (header.name.clone(), header.value.clone())) - .collect(), - header_order: config.header_order.clone(), - updated_at: None, + terminal: config.terminal.clone(), + verified_at: config.verified_at, } } @@ -560,14 +560,12 @@ async fn build_router( SettingsService::load_or_initialize(settings_snapshot_from_config(&config), &pool).await?; apply_settings_to_config(&mut config, &settings); - let fingerprint_store = PgFingerprintStore::new(pool.clone()); let stores = BackgroundTaskStores { redis: redis.clone(), accounts: PgAccountStore::new(pool.clone()), admin_users: PgAdminUserStore::new(pool.clone()), admin_sessions: RedisAdminSessionStore::new(redis.clone()), cookies: PgCookieStore::new(pool.clone()), - fingerprints: fingerprint_store.clone(), session_affinity: RedisSessionAffinityStore::new(redis.clone()), refresh_leases: RedisRefreshLeaseStore::new(redis.clone()), model_snapshots: RedisModelSnapshotStore::new(redis.clone()), @@ -580,16 +578,12 @@ async fn build_router( let data_dir = ensure_data_dir(&config.runtime.data_directory)?; let identity_secret = load_or_create_identity_secret(&data_dir)?; - let default_fingerprint = fingerprint_from_config(&config.fingerprint); - let runtime_fingerprint = fingerprint_store - .ensure_current_seed(&default_fingerprint) - .await?; - let runtime_fingerprint = RuntimeFingerprint::new(runtime_fingerprint); + let wire_profile = Arc::new(wire_profile_from_config(&config.wire_profile)); let runtime_config = crate::bootstrap::state::RuntimeConfig::from(&config); let services = Services::try_with_identity_secret_and_usage_record_options( &config, stores, - runtime_fingerprint, + wire_profile, identity_secret, UsageRecordOptions::from_config(&config), )?; diff --git a/backend/src/bootstrap/tasks/coordinator.rs b/backend/src/bootstrap/tasks/coordinator.rs index 021b9155f..834b06709 100644 --- a/backend/src/bootstrap/tasks/coordinator.rs +++ b/backend/src/bootstrap/tasks/coordinator.rs @@ -1,19 +1,13 @@ //! 后台任务协调器。 -use std::{future::Future, path::PathBuf, sync::Arc, time::Duration}; +use std::{future::Future, sync::Arc, time::Duration}; use tokio::task::JoinHandle; use super::{ - cookie_cleanup::CookieCleanupTask, - fingerprint_update::{ - CODEX_DESKTOP_APPCAST_URL, DEFAULT_EXTRACTED_FINGERPRINT_PATH, - start_fingerprint_update_task, - }, - model_refresh::ModelRefreshTask, - quota_refresh::QuotaRefreshTask, - retention_trim::RetentionTrimTask, - token_refresh::TokenRefreshTask, + cookie_cleanup::CookieCleanupTask, desktop_release_update::start_desktop_release_update_task, + model_refresh::ModelRefreshTask, quota_refresh::QuotaRefreshTask, + retention_trim::RetentionTrimTask, token_refresh::TokenRefreshTask, }; /// 后台任务协调器。 @@ -46,7 +40,6 @@ impl TaskCoordinator { stores.usage_records.clone(), stores.ops_errors.clone(), stores.request_buckets.clone(), - stores.fingerprints.clone(), ) .start(), ); @@ -94,17 +87,13 @@ impl TaskCoordinator { .with_cookie_store(stores.cookies.clone()) .start(), ); - coordinator.push("fingerprint_update", { - let fingerprint = services.fingerprint.snapshot(); - start_fingerprint_update_task( - stores.fingerprints.clone(), - services.fingerprint.clone(), - CODEX_DESKTOP_APPCAST_URL.to_string(), - PathBuf::from(DEFAULT_EXTRACTED_FINGERPRINT_PATH), - fingerprint.app_version, - fingerprint.build_number, - ) - }); + coordinator.push( + "desktop_release_update", + start_desktop_release_update_task( + services.desktop_release.clone(), + crate::upstream::openai::desktop_release::CODEX_DESKTOP_APPCAST_URL.to_string(), + ), + ); if let Some(pool) = &services.websocket_pool { coordinator.push( "websocket_pool", diff --git a/backend/src/bootstrap/tasks/desktop_release_update.rs b/backend/src/bootstrap/tasks/desktop_release_update.rs new file mode 100644 index 000000000..8ba82b468 --- /dev/null +++ b/backend/src/bootstrap/tasks/desktop_release_update.rs @@ -0,0 +1,57 @@ +//! Codex Desktop 官方发布观测任务接线。 + +use tracing::{info, warn}; + +use crate::upstream::openai::desktop_release::{ + APPCAST_POLL_INTERVAL, DesktopReleaseChecker, DesktopReleaseStatus, +}; + +use super::{ + coordinator::SchedulerHandle, + periodic::{PeriodicTaskConfig, PeriodicTaskRunner, spawn_periodic_task}, +}; + +/// 启动 Desktop 官方发布检查后台任务。 +pub fn start_desktop_release_update_task( + status: DesktopReleaseStatus, + appcast_url: String, +) -> SchedulerHandle { + let checker = DesktopReleaseChecker::with_client(reqwest::Client::new(), appcast_url, status); + spawn_periodic_task( + DesktopReleaseUpdateTask { + checker, + first_tick: true, + }, + PeriodicTaskConfig::new( + APPCAST_POLL_INTERVAL.as_secs(), + "Codex Desktop 发布检查器已启动", + "Codex Desktop 发布检查器已关闭", + ), + ) +} + +struct DesktopReleaseUpdateTask { + checker: DesktopReleaseChecker, + first_tick: bool, +} + +impl PeriodicTaskRunner for DesktopReleaseUpdateTask { + fn tick(&mut self) -> super::periodic::TaskFuture<'_, ()> { + Box::pin(async move { + let first_tick = std::mem::take(&mut self.first_tick); + match self.checker.check_and_record().await { + Ok(release) => info!( + version = %release.version, + build = %release.build, + "Codex Desktop 最新发布信息已刷新" + ), + Err(error) if first_tick => { + warn!(error = %error, "Codex Desktop 首次发布检查失败"); + } + Err(error) => { + warn!(error = %error, "Codex Desktop 定期发布检查失败"); + } + } + }) + } +} diff --git a/backend/src/bootstrap/tasks/fingerprint_update.rs b/backend/src/bootstrap/tasks/fingerprint_update.rs deleted file mode 100644 index 5e6227a2a..000000000 --- a/backend/src/bootstrap/tasks/fingerprint_update.rs +++ /dev/null @@ -1,85 +0,0 @@ -//! 指纹更新任务接线。 - -use std::path::PathBuf; - -use tracing::{info, warn}; - -use crate::upstream::openai::fingerprint::{ - APPCAST_POLL_INTERVAL, PgFingerprintStore, RuntimeFingerprint, UpdateChecker, -}; - -use super::{ - coordinator::SchedulerHandle, - periodic::{PeriodicTaskConfig, PeriodicTaskRunner, spawn_periodic_task}, -}; - -/// Codex Desktop 官方 appcast 地址。 -pub const CODEX_DESKTOP_APPCAST_URL: &str = - "https://persistent.oaistatic.com/codex-app-prod/appcast.xml"; -/// 本地可选完整指纹提取文件路径。 -pub const DEFAULT_EXTRACTED_FINGERPRINT_PATH: &str = "data/extracted-fingerprint.json"; - -/// 启动指纹自动更新后台任务。 -pub fn start_fingerprint_update_task( - store: PgFingerprintStore, - runtime_fingerprint: RuntimeFingerprint, - appcast_url: String, - extracted_fingerprint_path: PathBuf, - current_version: String, - current_build: String, -) -> SchedulerHandle { - let update_checker = UpdateChecker::with_client( - store, - reqwest::Client::new(), - appcast_url, - extracted_fingerprint_path, - current_version, - current_build, - ); - spawn_periodic_task( - FingerprintUpdateTask { - checker: update_checker, - runtime_fingerprint, - first_tick: true, - }, - PeriodicTaskConfig::new( - APPCAST_POLL_INTERVAL.as_secs(), - "Fingerprint 后台版本检查器已启动", - "Fingerprint 后台版本检查器已关闭", - ), - ) -} - -struct FingerprintUpdateTask { - checker: UpdateChecker, - runtime_fingerprint: RuntimeFingerprint, - first_tick: bool, -} - -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); - 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/bootstrap/tasks/mod.rs b/backend/src/bootstrap/tasks/mod.rs index 190a7567e..17ec0994f 100644 --- a/backend/src/bootstrap/tasks/mod.rs +++ b/backend/src/bootstrap/tasks/mod.rs @@ -3,7 +3,7 @@ mod cleanup; pub mod cookie_cleanup; pub mod coordinator; -pub mod fingerprint_update; +pub mod desktop_release_update; pub mod model_refresh; mod periodic; pub mod quota_refresh; diff --git a/backend/src/bootstrap/tasks/retention_trim.rs b/backend/src/bootstrap/tasks/retention_trim.rs index 8999a7818..b68f57bd7 100644 --- a/backend/src/bootstrap/tasks/retention_trim.rs +++ b/backend/src/bootstrap/tasks/retention_trim.rs @@ -3,12 +3,9 @@ use chrono::Utc; use tracing::{info, warn}; -use crate::{ - telemetry::{ - buckets::store::PgRequestBucketStore, ops::store::PgOpsErrorLogStore, - usage::store::PgUsageRecordStore, - }, - upstream::openai::fingerprint::PgFingerprintStore, +use crate::telemetry::{ + buckets::store::PgRequestBucketStore, ops::store::PgOpsErrorLogStore, + usage::store::PgUsageRecordStore, }; use super::{ @@ -17,13 +14,11 @@ use super::{ }; const RETENTION_TRIM_INTERVAL_SECS: u64 = 60 * 60; -const FINGERPRINT_HISTORY_LIMIT: std::num::NonZeroU32 = std::num::NonZeroU32::new(100).unwrap(); pub struct RetentionTrimTask { usage_records: PgUsageRecordStore, ops_errors: PgOpsErrorLogStore, buckets: PgRequestBucketStore, - fingerprints: PgFingerprintStore, } impl RetentionTrimTask { @@ -31,13 +26,11 @@ impl RetentionTrimTask { usage_records: PgUsageRecordStore, ops_errors: PgOpsErrorLogStore, buckets: PgRequestBucketStore, - fingerprints: PgFingerprintStore, ) -> Self { Self { usage_records, ops_errors, buckets, - fingerprints, } } @@ -70,17 +63,6 @@ impl RetentionTrimTask { Ok(_) => {} Err(error) => warn!(error = %error, "清理请求时间桶失败"), } - match self - .fingerprints - .trim_update_history_to_limit(FINGERPRINT_HISTORY_LIMIT) - .await - { - Ok(deleted) if deleted > 0 => { - info!(deleted, "已清理超限指纹更新历史") - } - Ok(_) => {} - Err(error) => warn!(error = %error, "清理超限指纹更新历史失败"), - } } } diff --git a/backend/src/infra/migrations/0001_initial.sql b/backend/src/infra/migrations/0001_initial.sql index 8cbf6ae7e..2f8ade2bc 100644 --- a/backend/src/infra/migrations/0001_initial.sql +++ b/backend/src/infra/migrations/0001_initial.sql @@ -231,35 +231,3 @@ create index idx_account_cookies_account_domain on account_cookies(account_id, domain); create index idx_account_cookies_expires on account_cookies(expires_at) where expires_at is not null; - -create table fingerprints ( - id text primary key, - originator text not null, - app_version text not null, - build_number text not null, - platform text not null, - arch text not null, - chromium_version text not null, - user_agent_template text not null, - default_headers_json jsonb not null, - header_order_json jsonb not null, - source text not null, - created_at timestamptz not null, - updated_at timestamptz not null -); - -create table fingerprint_update_history ( - id text primary key, - current_fingerprint_id text not null references fingerprints(id) on delete cascade, - app_version text not null, - build_number text not null, - chromium_version text, - source text not null, - manifest_json jsonb, - created_at timestamptz not null -); - -create index idx_fingerprint_update_history_created_id - on fingerprint_update_history(created_at desc, id desc); -create index idx_fingerprint_update_history_fingerprint - on fingerprint_update_history(current_fingerprint_id); diff --git a/backend/src/upstream/openai/desktop_release.rs b/backend/src/upstream/openai/desktop_release.rs new file mode 100644 index 000000000..c777e23ff --- /dev/null +++ b/backend/src/upstream/openai/desktop_release.rs @@ -0,0 +1,204 @@ +//! Codex Desktop 官方发布制品观测。 + +use std::{ + sync::{Arc, RwLock}, + time::Duration, +}; + +use chrono::{DateTime, Utc}; +use roxmltree::{Document, Node}; + +const APPCAST_TIMEOUT: Duration = Duration::from_secs(30); + +/// Codex Desktop 官方 appcast 地址。 +pub const CODEX_DESKTOP_APPCAST_URL: &str = + "https://persistent.oaistatic.com/codex-app-prod/appcast.xml"; +/// 官方发布检查周期。 +pub const APPCAST_POLL_INTERVAL: Duration = Duration::from_hours(24); + +/// appcast 中的最新 Desktop 完整制品信息。 +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct DesktopRelease { + pub version: String, + pub build: String, + pub published_at: Option>, + pub minimum_system_version: Option, + pub hardware_requirements: Option, + pub download_url: Option, + pub download_size: Option, + pub signature_present: bool, +} + +/// 最近一次 Desktop 发布检查的内存快照。 +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct DesktopReleaseSnapshot { + pub checked_at: Option>, + pub latest: Option, + pub last_error: Option, +} + +/// Dashboard 与后台检查器共享的 Desktop 发布观测状态。 +#[derive(Debug, Clone, Default)] +pub struct DesktopReleaseStatus { + snapshot: Arc>, +} + +impl DesktopReleaseStatus { + pub fn snapshot(&self) -> DesktopReleaseSnapshot { + self.snapshot + .read() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .clone() + } + + pub fn record_success(&self, checked_at: DateTime, latest: DesktopRelease) { + *self + .snapshot + .write() + .unwrap_or_else(std::sync::PoisonError::into_inner) = DesktopReleaseSnapshot { + checked_at: Some(checked_at), + latest: Some(latest), + last_error: None, + }; + } + + pub fn record_failure(&self, checked_at: DateTime, error: String) { + let mut snapshot = self + .snapshot + .write() + .unwrap_or_else(std::sync::PoisonError::into_inner); + snapshot.checked_at = Some(checked_at); + snapshot.last_error = Some(error); + } +} + +/// 读取官方 appcast 并更新发布观测状态。 +#[derive(Clone)] +pub struct DesktopReleaseChecker { + client: reqwest::Client, + appcast_url: String, + status: DesktopReleaseStatus, +} + +impl DesktopReleaseChecker { + pub fn with_client( + client: reqwest::Client, + appcast_url: impl Into, + status: DesktopReleaseStatus, + ) -> Self { + Self { + client, + appcast_url: appcast_url.into(), + status, + } + } + + pub async fn check_and_record(&self) -> Result { + let checked_at = Utc::now(); + let result = self.fetch_latest().await; + match result { + Ok(release) => { + self.status.record_success(checked_at, release.clone()); + Ok(release) + } + Err(error) => { + self.status.record_failure(checked_at, error.to_string()); + Err(error) + } + } + } + + async fn fetch_latest(&self) -> Result { + let response = self + .client + .get(&self.appcast_url) + .timeout(APPCAST_TIMEOUT) + .send() + .await?; + if !response.status().is_success() { + return Err(DesktopReleaseError::AppcastFetch( + response.status().as_u16(), + )); + } + parse_latest_desktop_release(&response.text().await?) + } +} + +/// 解析 appcast 中按顺序出现的首个完整发布项。 +pub fn parse_latest_desktop_release(xml: &str) -> Result { + let document = Document::parse(xml)?; + let item = document + .descendants() + .find(|node| node.is_element() && node.tag_name().name() == "item") + .ok_or(DesktopReleaseError::MissingItem)?; + let enclosure = item + .children() + .find(|node| node.is_element() && node.tag_name().name() == "enclosure"); + + let version = child_text(item, "shortVersionString") + .or_else(|| local_attribute(enclosure, "shortVersionString")) + .ok_or(DesktopReleaseError::MissingField("shortVersionString"))?; + let build = child_text(item, "version") + .or_else(|| local_attribute(enclosure, "version")) + .ok_or(DesktopReleaseError::MissingField("version"))?; + let published_at = child_text(item, "pubDate") + .map(|value| { + DateTime::parse_from_rfc2822(&value) + .map(|value| value.to_utc()) + .map_err(|_| DesktopReleaseError::InvalidField("pubDate")) + }) + .transpose()?; + let download_size = local_attribute(enclosure, "length") + .map(|value| { + value + .parse::() + .map_err(|_| DesktopReleaseError::InvalidField("enclosure.length")) + }) + .transpose()?; + + Ok(DesktopRelease { + version, + build, + published_at, + minimum_system_version: child_text(item, "minimumSystemVersion"), + hardware_requirements: child_text(item, "hardwareRequirements"), + download_url: local_attribute(enclosure, "url"), + download_size, + signature_present: local_attribute(enclosure, "edSignature").is_some(), + }) +} + +fn child_text(parent: Node<'_, '_>, local_name: &str) -> Option { + parent + .children() + .find(|node| node.is_element() && node.tag_name().name() == local_name) + .and_then(|node| node.text()) + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(ToString::to_string) +} + +fn local_attribute(node: Option>, local_name: &str) -> Option { + node? + .attributes() + .find(|attribute| attribute.name() == local_name) + .map(|attribute| attribute.value().trim()) + .filter(|value| !value.is_empty()) + .map(ToString::to_string) +} + +#[derive(Debug, thiserror::Error)] +pub enum DesktopReleaseError { + #[error("Codex Desktop appcast request failed: {0}")] + Http(#[from] reqwest::Error), + #[error("Codex Desktop appcast returned HTTP {0}")] + AppcastFetch(u16), + #[error("Codex Desktop appcast XML is invalid: {0}")] + Xml(#[from] roxmltree::Error), + #[error("Codex Desktop appcast contains no release item")] + MissingItem, + #[error("Codex Desktop appcast release is missing `{0}`")] + MissingField(&'static str), + #[error("Codex Desktop appcast field `{0}` is invalid")] + InvalidField(&'static str), +} diff --git a/backend/src/upstream/openai/fingerprint/mod.rs b/backend/src/upstream/openai/fingerprint/mod.rs deleted file mode 100644 index 67cabb3de..000000000 --- a/backend/src/upstream/openai/fingerprint/mod.rs +++ /dev/null @@ -1,11 +0,0 @@ -//! OpenAI 客户端指纹。 - -mod runtime; -mod store; -mod types; -mod updater; - -pub use runtime::*; -pub use store::*; -pub use types::*; -pub use updater::*; diff --git a/backend/src/upstream/openai/fingerprint/runtime.rs b/backend/src/upstream/openai/fingerprint/runtime.rs deleted file mode 100644 index 5ffc442c7..000000000 --- a/backend/src/upstream/openai/fingerprint/runtime.rs +++ /dev/null @@ -1,40 +0,0 @@ -//! OpenAI 客户端运行时指纹快照。 - -use std::sync::{Arc, RwLock}; - -use super::types::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); - } -} - -// --------------------------------------------------------------------------- -// 指纹历史记录与仓储 -// --------------------------------------------------------------------------- diff --git a/backend/src/upstream/openai/fingerprint/store.rs b/backend/src/upstream/openai/fingerprint/store.rs deleted file mode 100644 index 12aa64079..000000000 --- a/backend/src/upstream/openai/fingerprint/store.rs +++ /dev/null @@ -1,245 +0,0 @@ -//! OpenAI 客户端指纹 PostgreSQL 存储。 - -use std::num::NonZeroU32; - -use chrono::{DateTime, Utc}; -use indexmap::IndexMap; -use serde::{Deserialize, Serialize}; -use sqlx::{PgPool, Row}; -use uuid::Uuid; - -use super::types::Fingerprint; - -pub const CURRENT_FINGERPRINT_ID: &str = "current"; -const AUTO_UPDATE_SOURCE: &str = "auto_update"; -const CONFIG_SEED_SOURCE: &str = "config_seed"; - -#[derive(Debug, Clone, Serialize, Deserialize)] -struct StoredHeader { - name: String, - value: String, -} - -/// 指纹自动更新状态。 - -#[derive(Clone)] -pub struct PgFingerprintStore { - pool: PgPool, -} - -impl PgFingerprintStore { - /// 使用给定连接池构造仓储。 - pub fn new(pool: PgPool) -> Self { - Self { pool } - } - - /// 写入当前指纹默认值;如果当前槽位已存在,直接读取数据库值。 - pub async fn ensure_current_seed( - &self, - default_fingerprint: &Fingerprint, - ) -> Result { - if let Some(fingerprint) = self.load_current().await? { - return Ok(fingerprint); - } - - let now = Utc::now(); - sqlx::query( - r" - insert into fingerprints ( - id, - originator, - app_version, - build_number, - platform, - arch, - chromium_version, - user_agent_template, - default_headers_json, - header_order_json, - source, - created_at, - updated_at - ) values ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $12) - on conflict (id) do nothing - ", - ) - .bind(CURRENT_FINGERPRINT_ID) - .bind(&default_fingerprint.originator) - .bind(&default_fingerprint.app_version) - .bind(&default_fingerprint.build_number) - .bind(&default_fingerprint.platform) - .bind(&default_fingerprint.arch) - .bind(&default_fingerprint.chromium_version) - .bind(&default_fingerprint.user_agent_template) - .bind(sqlx::types::Json(encode_default_headers( - &default_fingerprint.default_headers, - ))) - .bind(sqlx::types::Json(&default_fingerprint.header_order)) - .bind(CONFIG_SEED_SOURCE) - .bind(now) - .execute(&self.pool) - .await?; - - self.load_current().await?.ok_or(sqlx::Error::RowNotFound) - } - - /// 更新当前指纹中的自动更新版本字段。 - pub async fn update_current_version( - &self, - app_version: &str, - build_number: &str, - chromium_version: Option<&str>, - ) -> Result<(), sqlx::Error> { - let resolved_chromium_version = match chromium_version { - Some(version) => version.to_string(), - None => { - sqlx::query_scalar::<_, String>( - "select chromium_version from fingerprints where id = $1", - ) - .bind(CURRENT_FINGERPRINT_ID) - .fetch_one(&self.pool) - .await? - } - }; - - let result = sqlx::query( - "update fingerprints set app_version = $1, build_number = $2, chromium_version = $3, source = $4, updated_at = $5 where id = $6", - ) - .bind(app_version) - .bind(build_number) - .bind(resolved_chromium_version) - .bind(AUTO_UPDATE_SOURCE) - .bind(Utc::now()) - .bind(CURRENT_FINGERPRINT_ID) - .execute(&self.pool) - .await?; - if result.rows_affected() == 0 { - return Err(sqlx::Error::RowNotFound); - } - Ok(()) - } - - /// 读取当前运行时指纹。 - pub async fn load_current(&self) -> Result, sqlx::Error> { - let row = sqlx::query( - r" - select - originator, - app_version, - build_number, - platform, - arch, - chromium_version, - user_agent_template, - default_headers_json, - header_order_json, - updated_at - from fingerprints - where id = $1 - ", - ) - .bind(CURRENT_FINGERPRINT_ID) - .fetch_optional(&self.pool) - .await?; - - row.map(|row| fingerprint_from_row(&row)).transpose() - } - - /// 记录自动更新历史。 - pub async fn insert_update_history( - &self, - app_version: &str, - build_number: &str, - chromium_version: Option<&str>, - manifest_json: Option<&str>, - ) -> Result<(), sqlx::Error> { - let manifest = manifest_json - .map(serde_json::from_str::) - .transpose() - .map_err(json_error)?; - sqlx::query( - "insert into fingerprint_update_history (id, current_fingerprint_id, app_version, build_number, chromium_version, source, manifest_json, created_at) values ($1, $2, $3, $4, $5, $6, $7, $8)", - ) - .bind(Uuid::new_v4().to_string()) - .bind(CURRENT_FINGERPRINT_ID) - .bind(app_version) - .bind(build_number) - .bind(chromium_version) - .bind(AUTO_UPDATE_SOURCE) - .bind(manifest.map(sqlx::types::Json)) - .bind(Utc::now()) - .execute(&self.pool) - .await?; - Ok(()) - } - - /// 将指纹更新历史裁剪为最新的指定条数。 - pub async fn trim_update_history_to_limit( - &self, - max_entries: NonZeroU32, - ) -> Result { - let result = sqlx::query( - r" - delete from fingerprint_update_history as target - using ( - select id - from ( - select - id, - row_number() over (order by created_at desc, id desc) as position - from fingerprint_update_history - ) as ranked - where position > $1 - ) as expired - where target.id = expired.id - ", - ) - .bind(i64::from(max_entries.get())) - .execute(&self.pool) - .await?; - Ok(result.rows_affected()) - } -} - -fn fingerprint_from_row(row: &sqlx::postgres::PgRow) -> Result { - let default_headers = row - .try_get::>, _>("default_headers_json")? - .0; - let header_order = row - .try_get::>, _>("header_order_json")? - .0; - let updated_at = row.try_get::, _>("updated_at")?; - Ok(Fingerprint { - originator: row.try_get("originator")?, - app_version: row.try_get("app_version")?, - build_number: row.try_get("build_number")?, - platform: row.try_get("platform")?, - arch: row.try_get("arch")?, - chromium_version: row.try_get("chromium_version")?, - user_agent_template: row.try_get("user_agent_template")?, - default_headers: decode_default_headers(default_headers), - header_order, - updated_at: Some(updated_at.to_rfc3339()), - }) -} - -fn encode_default_headers(headers: &IndexMap) -> Vec { - headers - .iter() - .map(|(name, value)| StoredHeader { - name: name.clone(), - value: value.clone(), - }) - .collect() -} - -fn decode_default_headers(headers: Vec) -> IndexMap { - headers - .into_iter() - .map(|header| (header.name, header.value)) - .collect() -} - -fn json_error(error: serde_json::Error) -> sqlx::Error { - sqlx::Error::Decode(Box::new(error)) -} diff --git a/backend/src/upstream/openai/fingerprint/types.rs b/backend/src/upstream/openai/fingerprint/types.rs deleted file mode 100644 index acece5e4a..000000000 --- a/backend/src/upstream/openai/fingerprint/types.rs +++ /dev/null @@ -1,92 +0,0 @@ -//! OpenAI 客户端指纹领域类型。 - -use chrono::{DateTime, Utc}; -use indexmap::IndexMap; -use serde::{Deserialize, Serialize}; -use thiserror::Error; - -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] -pub struct Fingerprint { - /// 客户端来源名。 - pub originator: String, - /// 应用版本。 - pub app_version: String, - /// 构建号。 - pub build_number: String, - /// 平台名。 - pub platform: String, - /// 架构名。 - pub arch: String, - /// Chromium 主版本。 - pub chromium_version: String, - /// User-Agent 模板。 - pub user_agent_template: String, - /// 默认请求头。 - pub default_headers: IndexMap, - /// 请求头顺序。 - pub header_order: Vec, - /// DB 最后更新时间。 - #[serde(skip_serializing_if = "Option::is_none")] - pub updated_at: Option, -} - -impl Fingerprint { - /// 根据模板展开最终 User-Agent。 - pub fn user_agent(&self) -> String { - self.user_agent_template - .replace("{version}", &self.app_version) - .replace("{platform}", &self.platform) - .replace("{arch}", &self.arch) - } - - /// 生成 `sec-ch-ua` 头值。 - pub fn sec_ch_ua(&self) -> String { - format!( - "\"Chromium\";v=\"{}\", \"Not:A-Brand\";v=\"24\"", - self.chromium_version - ) - } -} - -/// 运行时共享指纹快照。 - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct UpdateState { - /// 最近检查时间。 - pub last_check: DateTime, - /// 最新版本。 - pub latest_version: Option, - /// 最新构建号。 - pub latest_build: Option, - /// 下载地址。 - pub download_url: Option, - /// 是否有可用更新。 - pub update_available: bool, - /// 当前版本。 - pub current_version: String, - /// 当前构建号。 - pub current_build: String, -} - -/// Appcast 检查错误。 -#[derive(Debug, Error)] -pub enum UpdateError { - /// HTTP 请求失败。 - #[error("HTTP 请求失败: {0}")] - Http(#[from] reqwest::Error), - /// 获取 appcast 失败。 - #[error("获取 appcast 失败,状态码: {0}")] - AppcastFetch(u16), - /// 解析 appcast 失败。 - #[error("解析 appcast 失败")] - AppcastParse, - /// JSON 序列化失败。 - #[error("JSON 序列化失败: {0}")] - Json(#[from] serde_json::Error), - /// 文件操作失败。 - #[error("文件操作失败: {0}")] - Io(#[from] std::io::Error), - /// 数据库存储失败。 - #[error("数据库操作失败: {0}")] - Database(#[from] sqlx::Error), -} diff --git a/backend/src/upstream/openai/fingerprint/updater.rs b/backend/src/upstream/openai/fingerprint/updater.rs deleted file mode 100644 index d05ba261e..000000000 --- a/backend/src/upstream/openai/fingerprint/updater.rs +++ /dev/null @@ -1,215 +0,0 @@ -//! OpenAI 客户端指纹更新检查器。 - -use std::{path::PathBuf, sync::Arc, time::Duration}; - -use chrono::Utc; -use tokio::sync::Mutex; -use tracing::info; - -use super::{ - store::PgFingerprintStore, - types::{Fingerprint, UpdateError, UpdateState}, -}; - -const APPCAST_TIMEOUT: Duration = Duration::from_secs(30); -pub const APPCAST_POLL_INTERVAL: Duration = Duration::from_hours(24); -type AppcastFields = (Option, Option, Option); - -/// Appcast 指纹更新检查器。 -#[derive(Clone)] -pub struct UpdateChecker { - store: PgFingerprintStore, - client: reqwest::Client, - appcast_url: String, - extracted_fingerprint_path: PathBuf, - state: Arc>, -} - -struct InternalState { - current_version: String, - current_build: String, -} - -impl UpdateChecker { - /// 使用自定义 client 和路径构造检查器。 - pub fn with_client( - store: PgFingerprintStore, - client: reqwest::Client, - appcast_url: impl Into, - extracted_fingerprint_path: PathBuf, - current_version: impl Into, - current_build: impl Into, - ) -> Self { - Self { - store, - client, - appcast_url: appcast_url.into(), - extracted_fingerprint_path, - state: Arc::new(Mutex::new(InternalState { - current_version: current_version.into(), - current_build: current_build.into(), - })), - } - } - - /// 检查 appcast 是否存在更新。 - pub async fn check_for_update(&self) -> Result { - let state = self.state.lock().await; - let current_version = state.current_version.clone(); - let current_build = state.current_build.clone(); - drop(state); - - let xml = self.fetch_appcast().await?; - let (version, build, download_url) = parse_appcast(&xml)?; - - let update_available = version - .as_ref() - .is_some_and(|value| value != ¤t_version) - || build.as_ref().is_some_and(|value| value != ¤t_build); - - Ok(UpdateState { - last_check: Utc::now(), - latest_version: version, - latest_build: build, - download_url, - update_available, - current_version, - current_build, - }) - } - - /// 检查并在需要时应用更新。 - 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(None); - } - - let (Some(version), Some(build)) = ( - update_state.latest_version.as_deref(), - update_state.latest_build.as_deref(), - ) else { - return Ok(None); - }; - - let state = self.state.lock().await; - let current_version = state.current_version.clone(); - let current_build = state.current_build.clone(); - drop(state); - - info!( - version = %version, - build = %build, - current_version = %current_version, - current_build = %current_build, - "发现新的 fingerprint 版本" - ); - - 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(Some(updated_fingerprint)) - } - - async fn fetch_appcast(&self) -> Result { - let response = self - .client - .get(&self.appcast_url) - .timeout(APPCAST_TIMEOUT) - .send() - .await?; - - if !response.status().is_success() { - return Err(UpdateError::AppcastFetch(response.status().as_u16())); - } - - response.text().await.map_err(UpdateError::from) - } - - async fn apply_version_update( - &self, - version: &str, - build: &str, - ) -> Result { - let chromium_version = - load_matching_chromium_version(&self.extracted_fingerprint_path, version, build); - - self.store - .update_current_version(version, build, chromium_version.as_deref()) - .await?; - self.store - .insert_update_history(version, build, chromium_version.as_deref(), None) - .await?; - let updated = self - .store - .load_current() - .await? - .ok_or(sqlx::Error::RowNotFound)?; - Ok(updated) - } -} - -fn parse_appcast(xml: &str) -> Result { - let item_start = xml.find("").ok_or(UpdateError::AppcastParse)?; - let item_end = xml.find("").ok_or(UpdateError::AppcastParse)?; - let item = &xml[item_start..item_end]; - - let version = extract_sparkle_field(item, "shortVersionString"); - let build = extract_sparkle_field(item, "version"); - let download_url = item.find("url=\"").and_then(|position| { - let start = position + 5; - item[start..] - .find('"') - .map(|end| item[start..start + end].to_string()) - }); - - Ok((version, build, download_url)) -} - -fn extract_sparkle_field(item: &str, field: &str) -> Option { - let attr_pattern = format!("sparkle:{}=\"", field); - if let Some(position) = item.find(&attr_pattern) { - let start = position + attr_pattern.len(); - if let Some(end) = item[start..].find('"') { - return Some(item[start..start + end].to_string()); - } - } - - let elem_start = format!("", field); - let elem_end = format!("", field); - if let Some(start_position) = item.find(&elem_start) { - let content_start = start_position + elem_start.len(); - if let Some(end_position) = item[content_start..].find(&elem_end) { - return Some(item[content_start..content_start + end_position].to_string()); - } - } - - None -} - -fn load_matching_chromium_version( - extracted_fingerprint_path: &PathBuf, - version: &str, - build: &str, -) -> Option { - let content = std::fs::read_to_string(extracted_fingerprint_path).ok()?; - let parsed: serde_json::Value = serde_json::from_str(&content).ok()?; - - let extracted_version = parsed.get("app_version")?.as_str()?; - let extracted_build = parsed.get("build_number")?.as_str()?; - - if extracted_version == version && extracted_build == build { - parsed - .get("chromium_version")? - .as_str() - .map(ToString::to_string) - } else { - None - } -} diff --git a/backend/src/upstream/openai/mod.rs b/backend/src/upstream/openai/mod.rs index 2739aec09..dad7ff331 100644 --- a/backend/src/upstream/openai/mod.rs +++ b/backend/src/upstream/openai/mod.rs @@ -1,7 +1,8 @@ //! OpenAI/Codex 上游客户端。 +pub mod desktop_release; pub mod failure; -pub mod fingerprint; +pub mod profile; pub mod protocol; pub mod token_client; pub mod transport; diff --git a/backend/src/upstream/openai/profile.rs b/backend/src/upstream/openai/profile.rs new file mode 100644 index 000000000..5601001d0 --- /dev/null +++ b/backend/src/upstream/openai/profile.rs @@ -0,0 +1,46 @@ +//! Codex Desktop 上游请求画像。 + +use chrono::{DateTime, Utc}; + +/// 经制品与 Codex 源码核验后固定的上游请求身份。 +/// +/// 该画像是启动配置,不会被在线发布检查自动改写。Desktop 发布变化后应先重新审计 +/// bundled Codex Core,再整体更新此画像。 +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct CodexWireProfile { + /// `originator` 请求头及 User-Agent 产品名。 + pub originator: String, + /// bundled Codex Core 版本;同时用于 `/codex/models?client_version=`。 + pub codex_version: String, + /// Desktop 应用版本,用于 app-server `clientInfo.version` 对应的 UA 后缀。 + pub desktop_version: String, + /// Desktop 制品构建号,仅用于发布对齐诊断。 + pub desktop_build: String, + /// Codex Core UA 中的目标操作系统类型。 + pub os_type: String, + /// Codex Core UA 中的目标操作系统版本。 + pub os_version: String, + /// Codex Core UA 中的目标架构。 + pub arch: String, + /// Codex Core UA 中的终端标记。 + pub terminal: String, + /// 此画像最后一次经制品与源码核验的时间。 + pub verified_at: DateTime, +} + +impl CodexWireProfile { + /// 按 Codex Core 的官方格式生成最终 User-Agent。 + pub fn user_agent(&self) -> String { + format!( + "{}/{} ({} {}; {}) {} ({}; {})", + self.originator, + self.codex_version, + self.os_type, + self.os_version, + self.arch, + self.terminal, + self.originator, + self.desktop_version, + ) + } +} diff --git a/backend/src/upstream/openai/transport/client.rs b/backend/src/upstream/openai/transport/client.rs index 4056de3a1..4c0540aeb 100644 --- a/backend/src/upstream/openai/transport/client.rs +++ b/backend/src/upstream/openai/transport/client.rs @@ -13,15 +13,15 @@ use futures::{Stream, StreamExt, TryStreamExt}; use reqwest::{ Client, Response as ReqwestResponse, StatusCode, header::{ - ACCEPT, ACCEPT_ENCODING, AUTHORIZATION, CONTENT_TYPE, COOKIE, HeaderMap, HeaderName, - HeaderValue, RETRY_AFTER, USER_AGENT, + ACCEPT, AUTHORIZATION, CONTENT_TYPE, COOKIE, HeaderMap, HeaderName, HeaderValue, + RETRY_AFTER, USER_AGENT, }, }; use serde_json::{Value, map::Map}; use thiserror::Error; use tokio_tungstenite::tungstenite::handshake::client::generate_key; -use crate::upstream::openai::fingerprint::{Fingerprint, RuntimeFingerprint}; +use crate::upstream::openai::profile::CodexWireProfile; use crate::upstream::openai::protocol::events::{extract_sse_usage, retry_after_seconds_from_body}; use crate::upstream::openai::protocol::responses::{ CodexResponsesRequest, TransportRequirement, transport_requirement, @@ -33,10 +33,7 @@ use crate::upstream::openai::protocol::websocket::{ use super::diagnostics::CodexUpstreamDiagnostics; use super::endpoints::{CODEX_RESPONSES_PATH, endpoint_url}; -use super::headers::{ - build_ordered_codex_base_headers, insert_optional_header, insert_ordered_headers, - websocket_header_pairs, -}; +use super::headers::{build_codex_base_headers, insert_optional_header, websocket_header_pairs}; use super::response_meta; use super::tls::{CustomCaError, build_reqwest_client_with_custom_ca, custom_ca_env_cache_key}; use super::websocket::{ @@ -385,7 +382,7 @@ pub struct CodexBackendStreamingResponse { pub struct CodexBackendClient { pub(super) client: Client, pub(super) base_url: String, - fingerprint: RuntimeFingerprint, + profile: Arc, websocket_pool: Option>, websocket_initial_event_timeout: Option, websocket_fast_path_budget: Duration, diff --git a/backend/src/upstream/openai/transport/client_sse.rs b/backend/src/upstream/openai/transport/client_sse.rs index 7ddd81a0b..4e00659e8 100644 --- a/backend/src/upstream/openai/transport/client_sse.rs +++ b/backend/src/upstream/openai/transport/client_sse.rs @@ -5,14 +5,14 @@ impl CodexBackendClient { pub fn new( client: Client, base_url: impl Into, - fingerprint: RuntimeFingerprint, + profile: Arc, ) -> Self { let base_url = base_url.into().trim_end_matches('/').to_string(); Self { client, websocket_origin_key: websocket_origin_key(&base_url), base_url, - fingerprint, + profile, websocket_pool: None, websocket_initial_event_timeout: Some(DEFAULT_INITIAL_EVENT_TIMEOUT), websocket_fast_path_budget: WEBSOCKET_FAST_PATH_BUDGET, @@ -500,13 +500,12 @@ impl CodexBackendClient { &self, context: CodexRequestContext<'_>, ) -> CodexClientResult> { - let fingerprint = self.fingerprint.current(); let endpoint = endpoint_url(&self.base_url, "codex/models"); - let headers = self.auxiliary_request_headers(&fingerprint, context)?; + let headers = self.auxiliary_request_headers(context)?; let response = self .client .get(endpoint) - .query(&[("client_version", fingerprint.app_version.as_str())]) + .query(&[("client_version", self.profile.codex_version.as_str())]) .headers(headers) .send() .await?; @@ -571,16 +570,9 @@ impl CodexBackendClient { } fn request_headers(&self, context: CodexRequestContext<'_>) -> CodexClientResult { - let fingerprint = self.fingerprint.current(); let request_id = context.client_request_id.unwrap_or(context.request_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)?; + let mut headers = + build_codex_base_headers(&self.profile, context.access_token, context.account_id)?; headers.insert(CONTENT_TYPE, HeaderValue::from_static("application/json")); insert_optional_header(&mut headers, "cookie", context.cookie_header)?; headers.insert(ACCEPT, HeaderValue::from_static("text/event-stream")); @@ -625,19 +617,14 @@ impl CodexBackendClient { fn auxiliary_request_headers( &self, - fingerprint: &Fingerprint, context: CodexRequestContext<'_>, ) -> CodexClientResult { - 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)?; + let mut headers = + build_codex_base_headers(&self.profile, context.access_token, context.account_id)?; if let Some(cookie_header) = context.cookie_header { headers.insert(COOKIE, HeaderValue::from_str(cookie_header)?); } headers.insert(ACCEPT, HeaderValue::from_static("application/json")); - headers.insert(ACCEPT_ENCODING, HeaderValue::from_static("gzip, deflate")); insert_optional_header( &mut headers, "x-codex-installation-id", @@ -650,11 +637,10 @@ impl CodexBackendClient { &self, context: CodexRequestContext<'_>, ) -> CodexClientResult { - let fingerprint = self.fingerprint.current(); let mut headers = HeaderMap::new(); headers.insert( USER_AGENT, - HeaderValue::from_str(&fingerprint.user_agent())?, + HeaderValue::from_str(&self.profile.user_agent())?, ); headers.insert( AUTHORIZATION, @@ -662,7 +648,7 @@ impl CodexBackendClient { ); headers.insert( HeaderName::from_static("originator"), - HeaderValue::from_str(&fingerprint.originator)?, + HeaderValue::from_str(&self.profile.originator)?, ); insert_optional_header(&mut headers, "chatgpt-account-id", context.account_id)?; insert_optional_header(&mut headers, "cookie", context.cookie_header)?; diff --git a/backend/src/upstream/openai/transport/headers.rs b/backend/src/upstream/openai/transport/headers.rs index 3faf42a17..cea0d330a 100644 --- a/backend/src/upstream/openai/transport/headers.rs +++ b/backend/src/upstream/openai/transport/headers.rs @@ -1,102 +1,49 @@ -use indexmap::IndexMap; -use reqwest::header::{HeaderMap, HeaderName, HeaderValue}; +use reqwest::header::{ACCEPT, AUTHORIZATION, HeaderMap, HeaderName, HeaderValue, USER_AGENT}; -use crate::upstream::openai::fingerprint::Fingerprint; +use crate::upstream::openai::profile::CodexWireProfile; use super::client::CodexClientResult; -/// 构造标准 Codex 请求头。 +/// 构造 Codex Core 为模型请求设置的稳定身份请求头。 pub fn build_codex_base_headers( - fingerprint: &Fingerprint, + profile: &CodexWireProfile, access_token: &str, account_id: Option<&str>, -) -> IndexMap { - let mut headers = IndexMap::new(); +) -> CodexClientResult { + let mut headers = HeaderMap::new(); headers.insert( - "authorization".to_string(), - format!("Bearer {access_token}"), + AUTHORIZATION, + HeaderValue::from_str(&format!("Bearer {access_token}"))?, ); - if let Some(account_id) = account_id { - headers.insert("chatgpt-account-id".to_string(), account_id.to_string()); - } - headers.insert("originator".to_string(), fingerprint.originator.clone()); - headers.insert("user-agent".to_string(), fingerprint.user_agent()); - headers.insert("sec-ch-ua".to_string(), fingerprint.sec_ch_ua()); - for (key, value) in &fingerprint.default_headers { - let key_lower = key.to_ascii_lowercase(); - if !headers.contains_key(&key_lower) { - headers.insert(key_lower, value.clone()); - } - } - headers + insert_optional_header(&mut headers, "chatgpt-account-id", account_id)?; + headers.insert( + HeaderName::from_static("originator"), + HeaderValue::from_str(&profile.originator)?, + ); + headers.insert(USER_AGENT, HeaderValue::from_str(&profile.user_agent())?); + Ok(headers) } -/// 构造包含标准请求头和额外请求头值的完整请求头集合。 +/// 构造含通用请求上下文的 Codex 模型请求头。 pub fn build_codex_headers( - fingerprint: &Fingerprint, + profile: &CodexWireProfile, access_token: &str, account_id: Option<&str>, turn_state: Option<&str>, request_id: &str, -) -> IndexMap { - let mut headers = build_codex_base_headers(fingerprint, access_token, account_id); +) -> CodexClientResult { + let mut headers = build_codex_base_headers(profile, access_token, account_id)?; headers.insert( - "x-openai-internal-codex-residency".to_string(), - "us".to_string(), + HeaderName::from_static("x-openai-internal-codex-residency"), + HeaderValue::from_static("us"), ); - headers.insert("x-client-request-id".to_string(), request_id.to_string()); - if let Some(turn_state) = turn_state { - headers.insert("x-codex-turn-state".to_string(), turn_state.to_string()); - } - headers.insert("accept".to_string(), "text/event-stream".to_string()); - headers -} - -/// 按给定顺序重排请求头。 -pub fn order_headers( - headers: IndexMap, - order: &[String], -) -> IndexMap { - let mut ordered = IndexMap::new(); - for key in order { - if let Some(value) = headers.get(key) { - ordered.insert(key.clone(), value.clone()); - } - } - for (key, value) in headers { - if !ordered.contains_key(&key) { - ordered.insert(key, value); - } - } - ordered -} - -/// 构造并按指纹顺序重排完整请求头。 -pub fn build_ordered_codex_headers( - fingerprint: &Fingerprint, - access_token: &str, - account_id: Option<&str>, - turn_state: Option<&str>, - request_id: &str, -) -> IndexMap { - let headers = build_codex_headers( - fingerprint, - access_token, - account_id, - turn_state, - request_id, + headers.insert( + HeaderName::from_static("x-client-request-id"), + HeaderValue::from_str(request_id)?, ); - order_headers(headers, &fingerprint.header_order) -} - -/// 构造并按指纹顺序重排基础请求头(不含请求 ID 和 turn state)。 -pub fn build_ordered_codex_base_headers( - fingerprint: &Fingerprint, - access_token: &str, - account_id: Option<&str>, -) -> IndexMap { - let headers = build_codex_base_headers(fingerprint, access_token, account_id); - order_headers(headers, &fingerprint.header_order) + insert_optional_header(&mut headers, "x-codex-turn-state", turn_state)?; + headers.insert(ACCEPT, HeaderValue::from_static("text/event-stream")); + Ok(headers) } pub(super) fn insert_optional_header( @@ -111,19 +58,6 @@ pub(super) fn insert_optional_header( Ok(()) } -pub(super) fn insert_ordered_headers( - headers: &mut HeaderMap, - ordered_headers: &IndexMap, -) -> CodexClientResult<()> { - for (name, value) in ordered_headers { - headers.insert( - HeaderName::from_bytes(name.as_bytes())?, - HeaderValue::from_str(value)?, - ); - } - Ok(()) -} - fn header_pairs(headers: &HeaderMap) -> Vec<(String, String)> { headers .iter() diff --git a/backend/src/upstream/openai/transport/mod.rs b/backend/src/upstream/openai/transport/mod.rs index 17d0946ef..0415219b9 100644 --- a/backend/src/upstream/openai/transport/mod.rs +++ b/backend/src/upstream/openai/transport/mod.rs @@ -26,10 +26,7 @@ pub use self::{ CODEX_RESPONSES_PATH, CODEX_USAGE_API_PATH, CODEX_USAGE_PATH, WHAM_USAGE_PATH, endpoint_request_path, endpoint_url, usage_endpoint_urls, }, - headers::{ - build_codex_base_headers, build_codex_headers, build_ordered_codex_base_headers, - build_ordered_codex_headers, order_headers, - }, + headers::{build_codex_base_headers, build_codex_headers}, websocket_pool::{ CodexWebSocketPool, CodexWebSocketPoolConfig, CodexWebSocketPoolKey, WebSocketPoolDecision, }, diff --git a/backend/tests/api/admin/accounts_routes/import_routes.rs b/backend/tests/api/admin/accounts_routes/import_routes.rs index fb6dccd62..471d3437e 100644 --- a/backend/tests/api/admin/accounts_routes/import_routes.rs +++ b/backend/tests/api/admin/accounts_routes/import_routes.rs @@ -18,12 +18,8 @@ async fn admin_accounts_import_should_store_cpr_account_tokens() { ); let config = test_config(test_database_url()); let stores = background_task_stores(pool.clone(), redis); - let fingerprint = crate::support::fingerprint::test_fingerprint(); - let services = Arc::new(Services::new( - &config, - stores, - runtime_fingerprint(fingerprint), - )); + let profile = crate::support::wire_profile::test_wire_profile_value(); + let services = Arc::new(Services::new(&config, stores, wire_profile(profile))); let state = AppState::from(services.as_ref()); let app = codex_proxy_rs::api::router::router().with_state(state); @@ -618,12 +614,8 @@ async fn admin_accounts_import_should_fetch_usage_to_complete_missing_plan_and_q let mut config = test_config(test_database_url()); config.api.base_url = format!("{}/backend-api", server.uri()); let stores = background_task_stores(pool.clone(), redis); - let fingerprint = crate::support::fingerprint::test_fingerprint(); - let services = Arc::new(Services::new( - &config, - stores, - runtime_fingerprint(fingerprint), - )); + let profile = crate::support::wire_profile::test_wire_profile_value(); + let services = Arc::new(Services::new(&config, stores, wire_profile(profile))); let state = AppState::from(services.as_ref()); let app = codex_proxy_rs::api::router::router().with_state(state); let access_token = test_jwt_with_exp( @@ -830,12 +822,8 @@ async fn admin_accounts_import_should_require_admin_session_cookie() { let redis = create_test_redis("admin-accounts-import-auth").await; let config = test_config(test_database_url()); let stores = background_task_stores(pool.clone(), redis); - let fingerprint = crate::support::fingerprint::test_fingerprint(); - let services = Arc::new(Services::new( - &config, - stores, - runtime_fingerprint(fingerprint), - )); + let profile = crate::support::wire_profile::test_wire_profile_value(); + let services = Arc::new(Services::new(&config, stores, wire_profile(profile))); let state = AppState::from(services.as_ref()); let app = codex_proxy_rs::api::router::router().with_state(state); diff --git a/backend/tests/api/admin/accounts_routes/lifecycle_routes.rs b/backend/tests/api/admin/accounts_routes/lifecycle_routes.rs index dfc5e53a9..ad4bfe5a0 100644 --- a/backend/tests/api/admin/accounts_routes/lifecycle_routes.rs +++ b/backend/tests/api/admin/accounts_routes/lifecycle_routes.rs @@ -11,12 +11,8 @@ async fn admin_accounts_lifecycle_should_update_and_delete_accounts() { seed_account_related_rows(&pool, "acct_lifecycle").await; let config = test_config(test_database_url()); let stores = background_task_stores(pool.clone(), redis.clone()); - let fingerprint = crate::support::fingerprint::test_fingerprint(); - let services = Arc::new(Services::new( - &config, - stores, - runtime_fingerprint(fingerprint), - )); + let profile = crate::support::wire_profile::test_wire_profile_value(); + let services = Arc::new(Services::new(&config, stores, wire_profile(profile))); let state = AppState::from(services.as_ref()); let session_affinity = codex_proxy_rs::dispatch::affinity::RedisSessionAffinityStore::new(redis); @@ -873,12 +869,8 @@ async fn admin_account_create_should_derive_claims_and_store_plain_tokens() { ); let config = test_config(test_database_url()); let stores = background_task_stores(pool.clone(), redis); - let fingerprint = crate::support::fingerprint::test_fingerprint(); - let services = Arc::new(Services::new( - &config, - stores, - runtime_fingerprint(fingerprint), - )); + let profile = crate::support::wire_profile::test_wire_profile_value(); + let services = Arc::new(Services::new(&config, stores, wire_profile(profile))); let state = AppState::from(services.as_ref()); let app = codex_proxy_rs::api::router::router().with_state(state.clone()); @@ -907,12 +899,8 @@ async fn admin_account_manual_create_should_reject_missing_invalid_expired_or_un seed_admin_session(&pool, &redis, "session_1").await; let config = test_config(test_database_url()); let stores = background_task_stores(pool.clone(), redis); - let fingerprint = crate::support::fingerprint::test_fingerprint(); - let services = Arc::new(Services::new( - &config, - stores, - runtime_fingerprint(fingerprint), - )); + let profile = crate::support::wire_profile::test_wire_profile_value(); + let services = Arc::new(Services::new(&config, stores, wire_profile(profile))); let state = AppState::from(services.as_ref()); let app = codex_proxy_rs::api::router::router().with_state(state); diff --git a/backend/tests/api/admin/accounts_routes/list.rs b/backend/tests/api/admin/accounts_routes/list.rs index 3bf844312..66607c374 100644 --- a/backend/tests/api/admin/accounts_routes/list.rs +++ b/backend/tests/api/admin/accounts_routes/list.rs @@ -15,12 +15,8 @@ async fn admin_accounts_list_should_not_expose_account_tokens() { .execute(&pool).await.unwrap(); let config = test_config(test_database_url()); let stores = background_task_stores(pool.clone(), redis); - let fingerprint = crate::support::fingerprint::test_fingerprint(); - let services = Arc::new(Services::new( - &config, - stores, - runtime_fingerprint(fingerprint), - )); + let profile = crate::support::wire_profile::test_wire_profile_value(); + let services = Arc::new(Services::new(&config, stores, wire_profile(profile))); let state = AppState::from(services.as_ref()); let app = codex_proxy_rs::api::router::router().with_state(state); diff --git a/backend/tests/api/admin/accounts_routes/mod.rs b/backend/tests/api/admin/accounts_routes/mod.rs index be95ff3ba..0462807c6 100644 --- a/backend/tests/api/admin/accounts_routes/mod.rs +++ b/backend/tests/api/admin/accounts_routes/mod.rs @@ -23,9 +23,9 @@ use crate::support::jwt::unsigned_jwt; use crate::support::{ admin::seed_admin_session, config::test_config, - fingerprint::runtime_fingerprint, http::response_json, storage::{background_task_stores, create_test_redis, init_test_db, test_database_url}, + wire_profile::wire_profile, }; mod export_routes; @@ -196,12 +196,8 @@ async fn admin_accounts_test_app_with_overrides( config.auth.oauth_token_endpoint = oauth_token_endpoint; } let stores = background_task_stores(pool.clone(), redis.clone()); - let fingerprint = crate::support::fingerprint::test_fingerprint(); - let services = Arc::new(Services::new( - &config, - stores, - runtime_fingerprint(fingerprint), - )); + let profile = crate::support::wire_profile::test_wire_profile_value(); + let services = Arc::new(Services::new(&config, stores, wire_profile(profile))); let state = AppState::from(services.as_ref()); let app = codex_proxy_rs::api::router::router().with_state(state.clone()); ( diff --git a/backend/tests/api/admin/auth_routes/session.rs b/backend/tests/api/admin/auth_routes/session.rs index cf43d1a97..d36629bcd 100644 --- a/backend/tests/api/admin/auth_routes/session.rs +++ b/backend/tests/api/admin/auth_routes/session.rs @@ -9,9 +9,9 @@ use tower::util::ServiceExt; use crate::support::{ config::test_config, - fingerprint::runtime_fingerprint, http::response_json, storage::{background_task_stores, create_test_redis, init_test_db, test_database_url}, + wire_profile::wire_profile, }; #[tokio::test] @@ -237,11 +237,11 @@ async fn admin_login_test_app( seed_admin_user(&pool, password).await; let config = test_config(test_database_url()); let stores = background_task_stores(pool.clone(), redis); - let fingerprint = crate::support::fingerprint::test_fingerprint(); + let profile = crate::support::wire_profile::test_wire_profile_value(); let services = std::sync::Arc::new(codex_proxy_rs::bootstrap::services::Services::new( &config, stores, - runtime_fingerprint(fingerprint), + wire_profile(profile), )); let state = codex_proxy_rs::api::AppState::from(services.as_ref()); ( diff --git a/backend/tests/api/admin/dashboard_routes.rs b/backend/tests/api/admin/dashboard_routes.rs index e1f6f8034..e41538326 100644 --- a/backend/tests/api/admin/dashboard_routes.rs +++ b/backend/tests/api/admin/dashboard_routes.rs @@ -8,7 +8,7 @@ use codex_proxy_rs::{ bootstrap::services::Services, infra::time::{china_datetime, china_day_start, china_quarter_hour_start}, telemetry::{usage::store::PgUsageRecordStore, usage::types::UsageRecord}, - upstream::openai::fingerprint::Fingerprint, + upstream::openai::profile::CodexWireProfile, }; use serde_json::{Value, json}; use sqlx::PgPool; @@ -16,49 +16,54 @@ use tower::util::ServiceExt; use crate::support::{ admin::seed_admin_session, - fingerprint::runtime_fingerprint, http::response_json, storage::{background_task_stores, create_test_redis, init_test_db, test_database_url}, + wire_profile::test_wire_profile, }; #[tokio::test] -async fn dashboard_summary_should_render_fingerprint_updated_at_as_beijing_time() { - let (app, _store, _pool, _dir) = dashboard_test_app( - "dashboard-fingerprint-updated", - crate::support::fingerprint::test_fingerprint_with_updated_at(Some( - "2026-06-23T11:36:46.965574590+00:00", - )), - ) - .await; +async fn dashboard_summary_should_expose_effective_wire_profile() { + let (app, _store, _pool, _dir) = + dashboard_test_app("dashboard-wire-profile", test_wire_profile()).await; let body = dashboard_summary(app).await; assert_eq!( - service_status_value(&body, "更新时间"), - "2026-06-23 19:36:46" + body["data"]["wireProfile"]["userAgent"], + "Codex Desktop/0.144.2 (Mac OS 15.7.1; arm64) unknown (Codex Desktop; 26.707.72221)" + ); + assert_eq!(body["data"]["wireProfile"]["codexVersion"], "0.144.2"); + assert_eq!( + body["data"]["wireProfile"]["desktopVersion"], + "26.707.72221" + ); + assert_eq!(body["data"]["wireProfile"]["desktopBuild"], "5307"); + assert_eq!(body["data"]["wireProfile"]["target"]["osType"], "Mac OS"); + assert_eq!(body["data"]["wireProfile"]["target"]["arch"], "arm64"); + assert_eq!( + body["data"]["wireProfile"]["verifiedAt"], + "2026-07-14T18:25:50Z" ); } #[tokio::test] -async fn dashboard_summary_should_render_dash_when_fingerprint_updated_at_is_missing() { - let (app, _store, _pool, _dir) = dashboard_test_app( - "dashboard-fingerprint-missing", - crate::support::fingerprint::test_fingerprint(), - ) - .await; +async fn dashboard_summary_should_keep_release_observation_separate_from_wire_profile() { + let (app, _store, _pool, _dir) = + dashboard_test_app("dashboard-release-unchecked", test_wire_profile()).await; let body = dashboard_summary(app).await; - assert_eq!(service_status_value(&body, "更新时间"), "-"); + assert_eq!( + body["data"]["wireProfile"]["release"]["status"], + "unchecked" + ); + assert!(body["data"].get("serviceStatuses").is_none()); } #[tokio::test] async fn dashboard_summary_should_report_database_failure() { - let (app, _store, pool, _dir) = dashboard_test_app( - "dashboard-summary-database-failure", - crate::support::fingerprint::test_fingerprint(), - ) - .await; + let (app, _store, pool, _dir) = + dashboard_test_app("dashboard-summary-database-failure", test_wire_profile()).await; pool.close().await; let response = dashboard_response(app, "/api/admin/dashboard/summary").await; @@ -72,11 +77,8 @@ async fn dashboard_summary_should_report_database_failure() { #[tokio::test] async fn dashboard_trend_should_report_database_failure() { - let (app, _store, pool, _dir) = dashboard_test_app( - "dashboard-trend-database-failure", - crate::support::fingerprint::test_fingerprint(), - ) - .await; + let (app, _store, pool, _dir) = + dashboard_test_app("dashboard-trend-database-failure", test_wire_profile()).await; pool.close().await; let response = dashboard_response(app, "/api/admin/dashboard/trend?kind=usage").await; @@ -91,11 +93,8 @@ async fn dashboard_trend_should_report_database_failure() { #[tokio::test] async fn dashboard_summary_should_calculate_today_traffic_and_average_first_token_latency_from_time_buckets() { - let (app, store, pool, _dir) = dashboard_test_app( - "dashboard-traffic-latency", - crate::support::fingerprint::test_fingerprint(), - ) - .await; + let (app, store, pool, _dir) = + dashboard_test_app("dashboard-traffic-latency", test_wire_profile()).await; let now = Utc::now(); let today_start = china_day_start(now); let today_record_at = if now - today_start > Duration::minutes(1) { @@ -151,7 +150,7 @@ async fn dashboard_summary_should_calculate_today_traffic_and_average_first_toke async fn dashboard_summary_should_keep_trend_after_usage_records_are_cleared() { let (app, store, _pool, _dir) = dashboard_test_app( "dashboard-time-buckets-survive-usage-record-clear", - crate::support::fingerprint::test_fingerprint(), + test_wire_profile(), ) .await; let mut record = usage_record_with_tokens(Utc::now(), 12); @@ -177,11 +176,8 @@ async fn dashboard_summary_should_keep_trend_after_usage_records_are_cleared() { #[tokio::test] async fn dashboard_trend_should_bucket_usage_by_china_quarter_hour() { - let (app, store, _pool, _dir) = dashboard_test_app( - "dashboard-trend-china-quarter-hour", - crate::support::fingerprint::test_fingerprint(), - ) - .await; + let (app, store, _pool, _dir) = + dashboard_test_app("dashboard-trend-china-quarter-hour", test_wire_profile()).await; let record_at = Utc::now(); store .append(&usage_record_with_token_parts(record_at, 40, 60, 10)) @@ -205,11 +201,8 @@ async fn dashboard_trend_should_bucket_usage_by_china_quarter_hour() { #[tokio::test] async fn dashboard_trend_should_only_include_china_today() { - let (app, store, _pool, _dir) = dashboard_test_app( - "dashboard-trend-china-today", - crate::support::fingerprint::test_fingerprint(), - ) - .await; + let (app, store, _pool, _dir) = + dashboard_test_app("dashboard-trend-china-today", test_wire_profile()).await; let now = Utc::now(); let today_start = china_day_start(now); store @@ -237,11 +230,8 @@ async fn dashboard_trend_should_only_include_china_today() { #[tokio::test] async fn dashboard_latency_trend_should_use_first_token_latency() { - let (app, store, _pool, _dir) = dashboard_test_app( - "dashboard-trend-first-token-latency", - crate::support::fingerprint::test_fingerprint(), - ) - .await; + let (app, store, _pool, _dir) = + dashboard_test_app("dashboard-trend-first-token-latency", test_wire_profile()).await; let now = Utc::now(); let mut slow_completion = usage_record_with_tokens(now, 10); slow_completion.latency_ms = Some(10_000); @@ -277,11 +267,8 @@ async fn dashboard_latency_trend_should_use_first_token_latency() { #[tokio::test] async fn dashboard_latency_trend_should_serialize_missing_samples_as_null() { - let (app, _store, _pool, _dir) = dashboard_test_app( - "dashboard-trend-empty-latency", - crate::support::fingerprint::test_fingerprint(), - ) - .await; + let (app, _store, _pool, _dir) = + dashboard_test_app("dashboard-trend-empty-latency", test_wire_profile()).await; let body = dashboard_trend(app, "latency").await; let point = &body["data"]["points"][0]; @@ -312,11 +299,8 @@ async fn dashboard_latency_trend_should_serialize_missing_samples_as_null() { #[tokio::test] async fn dashboard_error_trend_should_serialize_missing_success_rate_as_null() { - let (app, _store, _pool, _dir) = dashboard_test_app( - "dashboard-trend-empty-success-rate", - crate::support::fingerprint::test_fingerprint(), - ) - .await; + let (app, _store, _pool, _dir) = + dashboard_test_app("dashboard-trend-empty-success-rate", test_wire_profile()).await; let body = dashboard_trend(app, "errors").await; let point = &body["data"]["points"][0]; @@ -346,7 +330,7 @@ async fn dashboard_error_trend_should_serialize_missing_success_rate_as_null() { async fn dashboard_summary_should_return_requested_trend_kind() { let (app, store, _pool, _dir) = dashboard_test_app( "dashboard-summary-requested-trend-kind", - crate::support::fingerprint::test_fingerprint(), + test_wire_profile(), ) .await; let now = Utc::now(); @@ -443,11 +427,8 @@ async fn dashboard_summary_should_match_versioned_model_names_for_billing() { #[tokio::test] async fn dashboard_summary_should_return_backend_formatted_time_fields() { - let (app, store, _pool, _dir) = dashboard_test_app( - "dashboard-backend-formatted-time", - crate::support::fingerprint::test_fingerprint(), - ) - .await; + let (app, store, _pool, _dir) = + dashboard_test_app("dashboard-backend-formatted-time", test_wire_profile()).await; let record_at = Utc::now() - Duration::seconds(1); let mut record = usage_record_with_tokens(record_at, 10); record.metadata = json!({ @@ -470,11 +451,8 @@ async fn dashboard_summary_should_return_backend_formatted_time_fields() { #[tokio::test] async fn dashboard_summary_should_only_return_today_account_usage_and_usage_records() { - let (app, store, pool, _dir) = dashboard_test_app( - "dashboard-summary-today-records", - crate::support::fingerprint::test_fingerprint(), - ) - .await; + let (app, store, pool, _dir) = + dashboard_test_app("dashboard-summary-today-records", test_wire_profile()).await; seed_dashboard_account(&pool, "acct_today", "today@example.com").await; seed_dashboard_account(&pool, "acct_yesterday", "yesterday@example.com").await; let now = Utc::now(); @@ -519,11 +497,8 @@ async fn dashboard_summary_should_only_return_today_account_usage_and_usage_reco #[tokio::test] async fn dashboard_summary_should_order_account_usage_by_recent_usage() { - let (app, store, pool, _dir) = dashboard_test_app( - "dashboard-account-usage-recent-order", - crate::support::fingerprint::test_fingerprint(), - ) - .await; + let (app, store, pool, _dir) = + dashboard_test_app("dashboard-account-usage-recent-order", test_wire_profile()).await; seed_dashboard_account(&pool, "acct_old_heavy", "old-heavy@example.com").await; seed_dashboard_account(&pool, "acct_recent_light", "recent-light@example.com").await; let now = Utc::now(); @@ -563,7 +538,7 @@ async fn dashboard_summary_should_order_account_usage_by_recent_usage() { async fn dashboard_summary_should_use_five_hour_quota_window_for_account_usage() { let (app, store, pool, _dir) = dashboard_test_app( "dashboard-account-usage-five-hour-quota", - crate::support::fingerprint::test_fingerprint(), + test_wire_profile(), ) .await; seed_dashboard_account(&pool, "acct_quota_five_hour", "quota-five-hour@example.com").await; @@ -612,11 +587,8 @@ async fn dashboard_summary_should_use_five_hour_quota_window_for_account_usage() #[tokio::test] async fn dashboard_summary_should_fall_back_to_weekly_quota_window_when_five_hour_missing() { - let (app, store, pool, _dir) = dashboard_test_app( - "dashboard-account-usage-weekly-quota", - crate::support::fingerprint::test_fingerprint(), - ) - .await; + let (app, store, pool, _dir) = + dashboard_test_app("dashboard-account-usage-weekly-quota", test_wire_profile()).await; seed_dashboard_account(&pool, "acct_quota_weekly", "quota-weekly@example.com").await; store .append(&usage_record_with_account_tokens( @@ -663,11 +635,8 @@ async fn dashboard_summary_should_fall_back_to_weekly_quota_window_when_five_hou #[tokio::test] async fn dashboard_summary_should_return_today_health_timeline() { - let (app, store, _pool, _dir) = dashboard_test_app( - "dashboard-health-timeline", - crate::support::fingerprint::test_fingerprint(), - ) - .await; + let (app, store, _pool, _dir) = + dashboard_test_app("dashboard-health-timeline", test_wire_profile()).await; store .append(&usage_record_with_tokens(Utc::now(), 10)) .await @@ -693,7 +662,7 @@ async fn dashboard_summary_should_return_today_health_timeline() { async fn dashboard_summary_should_include_retained_history_in_total_billing() { let (app, store, _pool, _dir) = dashboard_test_app( "dashboard-total-billing-retained-history", - crate::support::fingerprint::test_fingerprint(), + test_wire_profile(), ) .await; let metadata = json!({ @@ -731,8 +700,7 @@ async fn dashboard_summary_total_billing_for_usage_record( model: &str, metadata: Value, ) -> String { - let (app, store, _pool, _dir) = - dashboard_test_app(db_name, crate::support::fingerprint::test_fingerprint()).await; + let (app, store, _pool, _dir) = dashboard_test_app(db_name, test_wire_profile()).await; let mut record = usage_record(Utc::now(), model, metadata.clone()); record.input_tokens = metadata .pointer("/usage/inputTokens") @@ -761,7 +729,7 @@ async fn dashboard_summary_total_billing_for_usage_record( async fn dashboard_test_app( db_name: &str, - fingerprint: Fingerprint, + wire_profile: std::sync::Arc, ) -> ( axum::Router, PgUsageRecordStore, @@ -773,11 +741,7 @@ async fn dashboard_test_app( seed_admin_session(&pool, &redis, "session_1").await; let config = crate::support::config::test_config(test_database_url()); let stores = background_task_stores(pool.clone(), redis); - let services = std::sync::Arc::new(Services::new( - &config, - stores, - runtime_fingerprint(fingerprint), - )); + let services = std::sync::Arc::new(Services::new(&config, stores, wire_profile)); let state = AppState::from(services.as_ref()); ( codex_proxy_rs::api::router::router().with_state(state), @@ -888,16 +852,6 @@ async fn set_account_quota_json(pool: &PgPool, account_id: &str, quota_json: Val .unwrap(); } -fn service_status_value<'a>(body: &'a Value, label: &str) -> &'a str { - body["data"]["serviceStatuses"] - .as_array() - .unwrap() - .iter() - .find(|status| status["label"] == label) - .and_then(|status| status["value"].as_str()) - .unwrap() -} - fn usage_record_with_tokens(created_at: chrono::DateTime, total_tokens: u64) -> UsageRecord { usage_record_with_token_parts(created_at, total_tokens, 0, 0) } diff --git a/backend/tests/api/admin/keys_routes/mod.rs b/backend/tests/api/admin/keys_routes/mod.rs index 8b8b67c35..082a65737 100644 --- a/backend/tests/api/admin/keys_routes/mod.rs +++ b/backend/tests/api/admin/keys_routes/mod.rs @@ -10,9 +10,9 @@ use tower::util::ServiceExt; use crate::support::{ admin::seed_admin_session, config::test_config, - fingerprint::runtime_fingerprint, http::response_json, storage::{background_task_stores, create_test_redis, init_test_db, test_database_url}, + wire_profile::wire_profile, }; mod authorization; @@ -30,12 +30,8 @@ async fn admin_client_key_test_app( seed_admin_session(&pool, &redis, "session_1").await; let config = test_config(test_database_url()); let stores = background_task_stores(pool.clone(), redis); - let fingerprint = crate::support::fingerprint::test_fingerprint(); - let services = std::sync::Arc::new(Services::new( - &config, - stores, - runtime_fingerprint(fingerprint), - )); + let profile = crate::support::wire_profile::test_wire_profile_value(); + let services = std::sync::Arc::new(Services::new(&config, stores, wire_profile(profile))); let state = AppState::from(services.as_ref()); ( codex_proxy_rs::api::router::router().with_state(state), diff --git a/backend/tests/api/admin/settings_routes/routes.rs b/backend/tests/api/admin/settings_routes/routes.rs index beeb1073e..1ec3e1d84 100644 --- a/backend/tests/api/admin/settings_routes/routes.rs +++ b/backend/tests/api/admin/settings_routes/routes.rs @@ -25,9 +25,9 @@ use tower::util::ServiceExt; use crate::support::{ admin::seed_admin_session, config::test_config as base_test_config, - fingerprint::runtime_fingerprint, http::response_json, storage::{background_task_stores, create_test_redis, init_test_db, test_database_url}, + wire_profile::wire_profile, }; #[tokio::test] @@ -261,8 +261,8 @@ async fn admin_settings_update_should_persist_runtime_settings_to_database() { let config = test_config(test_database_url()); seed_admin_session(&pool, &redis, "session_1").await; let stores = background_task_stores(pool.clone(), redis); - let fingerprint = crate::support::fingerprint::test_fingerprint(); - let services = Services::new(&config, stores, runtime_fingerprint(fingerprint)); + let profile = crate::support::wire_profile::test_wire_profile_value(); + let services = Services::new(&config, stores, wire_profile(profile)); let services = std::sync::Arc::new(services); let state = AppState::from(services.as_ref()); let app = codex_proxy_rs::api::router::router().with_state(state); @@ -356,12 +356,8 @@ async fn admin_settings_update_should_apply_runtime_services() { seed_schedulable_account(&pool, "acct_runtime_a").await; seed_schedulable_account(&pool, "acct_runtime_b").await; let stores = background_task_stores(pool.clone(), redis); - let fingerprint = crate::support::fingerprint::test_fingerprint(); - let services = std::sync::Arc::new(Services::new( - &config, - stores, - runtime_fingerprint(fingerprint), - )); + let profile = crate::support::wire_profile::test_wire_profile_value(); + let services = std::sync::Arc::new(Services::new(&config, stores, wire_profile(profile))); let settings_tasks = TaskCoordinator::start_settings_subscriptions(&services); services.account_pool.restore_from_store().await.unwrap(); let state = AppState::from(services.as_ref()); @@ -466,12 +462,8 @@ async fn admin_settings_update_should_reject_unsupported_or_invalid_fields() { let config = test_config(test_database_url()); seed_admin_session(&pool, &redis, "session_1").await; let stores = background_task_stores(pool.clone(), redis); - let fingerprint = crate::support::fingerprint::test_fingerprint(); - let services = std::sync::Arc::new(Services::new( - &config, - stores, - runtime_fingerprint(fingerprint), - )); + let profile = crate::support::wire_profile::test_wire_profile_value(); + let services = std::sync::Arc::new(Services::new(&config, stores, wire_profile(profile))); let state = AppState::from(services.as_ref()); let app = codex_proxy_rs::api::router::router().with_state(state); @@ -501,12 +493,8 @@ async fn admin_settings_test_app( seed_admin_session(&pool, &redis, "session_1").await; let config = test_config(test_database_url()); let stores = background_task_stores(pool.clone(), redis); - let fingerprint = crate::support::fingerprint::test_fingerprint(); - let services = std::sync::Arc::new(Services::new( - &config, - stores, - runtime_fingerprint(fingerprint), - )); + let profile = crate::support::wire_profile::test_wire_profile_value(); + let services = std::sync::Arc::new(Services::new(&config, stores, wire_profile(profile))); let state = AppState::from(services.as_ref()); (codex_proxy_rs::api::router::router().with_state(state), dir) } diff --git a/backend/tests/api/admin/system_routes/mod.rs b/backend/tests/api/admin/system_routes/mod.rs index a7bbaf1ea..81afade9b 100644 --- a/backend/tests/api/admin/system_routes/mod.rs +++ b/backend/tests/api/admin/system_routes/mod.rs @@ -28,8 +28,8 @@ use wiremock::{ use crate::support::{ admin::seed_admin_session, config::test_config, - fingerprint::{runtime_fingerprint, test_fingerprint}, http::response_json, + wire_profile::{test_wire_profile_value, wire_profile}, }; const VERSION_OLD: &str = "0.1.0"; @@ -1269,7 +1269,7 @@ async fn admin_system_test_app_with_process_control( let services = Arc::new(Services::new( &config, stores, - runtime_fingerprint(test_fingerprint()), + wire_profile(test_wire_profile_value()), )); let mut state = AppState::from(services.as_ref()); state.services.system_update = Arc::new(SystemUpdateService::new(update_config)); diff --git a/backend/tests/api/admin/usage_routes.rs b/backend/tests/api/admin/usage_routes.rs index f4b43da41..ace97890f 100644 --- a/backend/tests/api/admin/usage_routes.rs +++ b/backend/tests/api/admin/usage_routes.rs @@ -16,9 +16,9 @@ use tower::util::ServiceExt; use crate::support::{ admin::seed_admin_session, config::test_config, - fingerprint::runtime_fingerprint, http::response_json, storage::{background_task_stores, create_test_redis, init_test_db, test_database_url}, + wire_profile::wire_profile, }; #[tokio::test] @@ -365,7 +365,7 @@ async fn monitoring_test_app( let services = Services::new( &config, stores, - runtime_fingerprint(crate::support::fingerprint::test_fingerprint()), + wire_profile(crate::support::wire_profile::test_wire_profile_value()), ); let app = codex_proxy_rs::api::router::router().with_state(AppState::from(&services)); ( diff --git a/backend/tests/api/assets.rs b/backend/tests/api/assets.rs index 556824232..f3f6c6335 100644 --- a/backend/tests/api/assets.rs +++ b/backend/tests/api/assets.rs @@ -12,12 +12,12 @@ use tower::ServiceExt; use crate::support::{ config::test_config as base_test_config, - fingerprint::runtime_fingerprint, http::response_json, storage::{ TestDatabaseGuard, background_task_stores, create_test_redis, init_test_db, test_database_url, }, + wire_profile::wire_profile, }; #[tokio::test] @@ -185,11 +185,11 @@ async fn router_with_dist_and_database( let redis = create_test_redis(label).await; let config = test_config(test_database_url()); let stores = background_task_stores(pool.clone(), redis); - let fingerprint = crate::support::fingerprint::test_fingerprint(); + let profile = crate::support::wire_profile::test_wire_profile_value(); let services = std::sync::Arc::new(codex_proxy_rs::bootstrap::services::Services::new( &config, stores, - runtime_fingerprint(fingerprint), + wire_profile(profile), )); let state = codex_proxy_rs::api::AppState::from(services.as_ref()); ( diff --git a/backend/tests/api/client/models.rs b/backend/tests/api/client/models.rs index cdf6ab4b0..9bc916546 100644 --- a/backend/tests/api/client/models.rs +++ b/backend/tests/api/client/models.rs @@ -11,9 +11,9 @@ use tower::util::ServiceExt; use crate::support::{ client_keys::insert_client_api_key, config::test_config, - fingerprint::runtime_fingerprint, http::response_json, storage::{background_task_stores, create_test_redis, init_test_db, test_database_url}, + wire_profile::wire_profile, }; #[tokio::test] @@ -154,12 +154,8 @@ async fn build_test_app( let redis = create_test_redis(label).await; seed_model_snapshot(&redis).await; let stores = background_task_stores(pool.clone(), redis); - let fingerprint = crate::support::fingerprint::test_fingerprint(); - let services = std::sync::Arc::new(Services::new( - &config, - stores, - runtime_fingerprint(fingerprint), - )); + let profile = crate::support::wire_profile::test_wire_profile_value(); + let services = std::sync::Arc::new(Services::new(&config, stores, wire_profile(profile))); services .initialize_hot_path_state() .await diff --git a/backend/tests/api/client/responses/mod.rs b/backend/tests/api/client/responses/mod.rs index 18cc2a576..b3029e14c 100644 --- a/backend/tests/api/client/responses/mod.rs +++ b/backend/tests/api/client/responses/mod.rs @@ -8,9 +8,9 @@ use tower::util::ServiceExt; use crate::support::{ client_keys::insert_client_api_key, config::test_config, - fingerprint::runtime_fingerprint, http::response_json, storage::{background_task_stores, create_test_redis, init_test_db, test_database_url}, + wire_profile::wire_profile, }; mod websocket; @@ -165,12 +165,8 @@ pub(super) async fn test_app_with_client_api_key() -> ( insert_model_snapshot(&redis).await; let config = test_config(test_database_url()); let stores = background_task_stores(pool.clone(), redis); - let fingerprint = crate::support::fingerprint::test_fingerprint(); - let services = std::sync::Arc::new(Services::new( - &config, - stores, - runtime_fingerprint(fingerprint), - )); + let profile = crate::support::wire_profile::test_wire_profile_value(); + let services = std::sync::Arc::new(Services::new(&config, stores, wire_profile(profile))); services .initialize_hot_path_state() .await diff --git a/backend/tests/bootstrap/config/mod.rs b/backend/tests/bootstrap/config/mod.rs index 9d6d631bd..da95efd41 100644 --- a/backend/tests/bootstrap/config/mod.rs +++ b/backend/tests/bootstrap/config/mod.rs @@ -22,8 +22,9 @@ fn config_loader_should_load_complete_example() { config.server.host.as_str(), config.server.port, config.api.base_url.as_str(), - config.fingerprint.app_version.as_str(), - config.fingerprint.build_number.as_str(), + config.wire_profile.codex_version.as_str(), + config.wire_profile.desktop_version.as_str(), + config.wire_profile.desktop_build.as_str(), config.quota.refresh_interval_minutes, config.ws_pool.initial_event_timeout_ms, config.admin.default_username.as_str(), @@ -33,8 +34,9 @@ fn config_loader_should_load_complete_example() { "127.0.0.1", 8080, "https://chatgpt.com/backend-api", - "26.707.51957", - "5175", + "0.144.2", + "26.707.72221", + "5307", 5, 20_000, "admin@cpr.local", @@ -158,7 +160,7 @@ fn config_loader_should_reject_missing_explicit_fields() { #[test] fn config_loader_should_reject_unsupported_schema_version() { - let yaml = complete_config().replace("schema_version: 1", "schema_version: 2"); + let yaml = complete_config().replace("schema_version: 2", "schema_version: 1"); let directory = write_config(&yaml); let error = BootstrapConfig::load_from_path(directory.path().join("config.yaml")).unwrap_err(); @@ -223,18 +225,25 @@ fn config_loader_should_reject_admin_password_with_compose_interpolation() { } #[test] -fn config_loader_should_reject_duplicate_fingerprint_headers() { - let yaml = complete_config().replace( - " - name: 'Accept-Language'", - " - name: 'Accept-Encoding'", - ); +fn config_loader_should_reject_legacy_fingerprint_section() { + let yaml = complete_config().replace(" wire_profile:", " fingerprint:"); + let directory = write_config(&yaml); + + let error = BootstrapConfig::load_from_path(directory.path().join("config.yaml")).unwrap_err(); + + assert!(matches!(error, ConfigError::InvalidDocument { .. })); +} + +#[test] +fn config_loader_should_reject_invalid_codex_core_version() { + let yaml = complete_config().replace("codex_version: '0.144.2'", "codex_version: 'Desktop'"); let directory = write_config(&yaml); let error = BootstrapConfig::load_from_path(directory.path().join("config.yaml")).unwrap_err(); assert_eq!( error, - ConfigError::InvalidField("fingerprint.default_headers") + ConfigError::InvalidField("wire_profile.codex_version") ); } diff --git a/backend/tests/bootstrap/services.rs b/backend/tests/bootstrap/services.rs index dce3dc58d..805cc526d 100644 --- a/backend/tests/bootstrap/services.rs +++ b/backend/tests/bootstrap/services.rs @@ -21,7 +21,7 @@ async fn services_try_new_should_use_configured_tls_transport_builder() { let Err(error) = Services::try_new( &config, stores, - crate::support::fingerprint::runtime_test_fingerprint(), + crate::support::wire_profile::test_wire_profile(), ) else { panic!("invalid custom CA should fail service transport construction"); }; @@ -59,7 +59,7 @@ async fn services_try_new_should_expose_websocket_pool_when_enabled() { let services = Services::try_new( &config, background_task_stores(pool, redis), - crate::support::fingerprint::runtime_test_fingerprint(), + crate::support::wire_profile::test_wire_profile(), ) .expect("services should build"); @@ -75,7 +75,7 @@ async fn services_try_new_should_not_expose_websocket_pool_when_disabled() { let services = Services::try_new( &config, background_task_stores(pool, redis), - crate::support::fingerprint::runtime_test_fingerprint(), + crate::support::wire_profile::test_wire_profile(), ) .expect("services should build"); diff --git a/backend/tests/bootstrap/tasks/desktop_release_update.rs b/backend/tests/bootstrap/tasks/desktop_release_update.rs new file mode 100644 index 000000000..8dd1f07e0 --- /dev/null +++ b/backend/tests/bootstrap/tasks/desktop_release_update.rs @@ -0,0 +1,86 @@ +use super::*; + +#[tokio::test] +async fn desktop_release_update_task_should_start_background_checker() { + let status = DesktopReleaseStatus::default(); + let handle = + codex_proxy_rs::bootstrap::tasks::desktop_release_update::start_desktop_release_update_task( + status, + "https://example.invalid/appcast.xml".to_string(), + ); + + handle.shutdown().await; +} + +#[tokio::test] +async fn desktop_release_update_task_should_record_initial_appcast_observation() { + let server = MockServer::start().await; + mount_appcast( + &server, + r#" + + + + Tue, 14 Jul 2026 07:56:40 +0000 + 26.900.1 + 7001 + 12.0 + arm64 + + + + + "#, + ) + .await; + + let status = DesktopReleaseStatus::default(); + let handle = + codex_proxy_rs::bootstrap::tasks::desktop_release_update::start_desktop_release_update_task( + status.clone(), + format!("{}/appcast.xml", server.uri()), + ); + + let snapshot = wait_for_latest_desktop_release(&status, "26.900.1").await; + handle.shutdown().await; + + let release = snapshot.latest.expect("latest release"); + assert_eq!(release.build, "7001"); + assert_eq!(release.minimum_system_version.as_deref(), Some("12.0")); + assert_eq!(release.hardware_requirements.as_deref(), Some("arm64")); + assert_eq!(release.download_size, Some(1234)); + assert!(release.signature_present); + assert!(snapshot.checked_at.is_some()); + assert!(snapshot.last_error.is_none()); +} + +#[tokio::test] +async fn desktop_release_update_task_should_record_fetch_failure_without_mutating_wire_profile() { + let server = MockServer::start().await; + Mock::given(method("GET")) + .and(path("/appcast.xml")) + .respond_with(ResponseTemplate::new(503)) + .mount(&server) + .await; + + let profile = crate::support::wire_profile::test_wire_profile(); + let original_user_agent = profile.user_agent(); + let status = DesktopReleaseStatus::default(); + let handle = + codex_proxy_rs::bootstrap::tasks::desktop_release_update::start_desktop_release_update_task( + status.clone(), + format!("{}/appcast.xml", server.uri()), + ); + + wait_for_appcast_requests(&server, 1).await; + let deadline = tokio::time::Instant::now() + StdDuration::from_secs(2); + while status.snapshot().last_error.is_none() { + assert!(tokio::time::Instant::now() < deadline); + tokio::time::sleep(StdDuration::from_millis(25)).await; + } + handle.shutdown().await; + + assert_eq!(profile.user_agent(), original_user_agent); + assert!(status.snapshot().latest.is_none()); +} diff --git a/backend/tests/bootstrap/tasks/fingerprint_update.rs b/backend/tests/bootstrap/tasks/fingerprint_update.rs deleted file mode 100644 index d7b84c136..000000000 --- a/backend/tests/bootstrap/tasks/fingerprint_update.rs +++ /dev/null @@ -1,131 +0,0 @@ -use super::*; - -#[tokio::test] -async fn fingerprint_update_task_should_start_background_checker() { - let dir = tempfile::tempdir().expect("temp dir"); - let (pool, _guard) = init_test_db("fingerprint-task-start").await; - let repo = PgFingerprintStore::new(pool.clone()); - 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::bootstrap::tasks::fingerprint_update::start_fingerprint_update_task( - repo, - runtime_fingerprint, - "https://example.invalid/appcast.xml".to_string(), - dir.path().join("extracted-fingerprint.json"), - "26.800.1".to_string(), - "6001".to_string(), - ); - - handle.shutdown().await; -} - -#[tokio::test] -async fn fingerprint_update_task_should_apply_initial_appcast_update_to_store() { - let server = MockServer::start().await; - mount_appcast( - &server, - r#" - - - - - - - - "#, - ) - .await; - - let dir = tempfile::tempdir().expect("temp dir"); - let (pool, _guard) = init_test_db("fingerprint-task-update").await; - let repo = PgFingerprintStore::new(pool.clone()); - 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, - r#"{"app_version":"26.900.1","build_number":"7001","chromium_version":"147"}"#, - ) - .expect("extracted fingerprint should be written"); - - let handle = - codex_proxy_rs::bootstrap::tasks::fingerprint_update::start_fingerprint_update_task( - repo.clone(), - runtime_fingerprint.clone(), - format!("{}/appcast.xml", server.uri()), - extracted_path, - "26.800.1".to_string(), - "6001".to_string(), - ); - - let stored = wait_for_current_fingerprint_version(&repo).await; - handle.shutdown().await; - - assert_eq!( - ( - stored.app_version.as_str(), - stored.build_number.as_str(), - stored.chromium_version.as_str() - ), - ("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] -async fn fingerprint_update_task_should_not_persist_when_appcast_matches_current_fingerprint() { - let server = MockServer::start().await; - mount_appcast( - &server, - r#" - - - - - - - - "#, - ) - .await; - - let dir = tempfile::tempdir().expect("temp dir"); - let (pool, _guard) = init_test_db("fingerprint-task-no-update").await; - let repo = PgFingerprintStore::new(pool.clone()); - 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::bootstrap::tasks::fingerprint_update::start_fingerprint_update_task( - repo.clone(), - runtime_fingerprint, - format!("{}/appcast.xml", server.uri()), - dir.path().join("missing-extracted-fingerprint.json"), - "26.900.1".to_string(), - "7001".to_string(), - ); - - wait_for_appcast_requests(&server, 1).await; - handle.shutdown().await; - let history_count: i64 = sqlx::query_scalar("select count(*) from fingerprint_update_history") - .fetch_one(&pool) - .await - .expect("fingerprint history count should load"); - - assert_eq!(history_count, 0); -} diff --git a/backend/tests/bootstrap/tasks/mod.rs b/backend/tests/bootstrap/tasks/mod.rs index f7406cb5a..b1f777a66 100644 --- a/backend/tests/bootstrap/tasks/mod.rs +++ b/backend/tests/bootstrap/tasks/mod.rs @@ -11,7 +11,9 @@ use codex_proxy_rs::telemetry::account_usage::store::{ AccountUsageDelta, AccountUsageSnapshot, AccountUsageStore, AccountUsageStoreError, AccountUsageWindow, }; -use codex_proxy_rs::upstream::openai::fingerprint::{PgFingerprintStore, RuntimeFingerprint}; +use codex_proxy_rs::upstream::openai::desktop_release::{ + DesktopReleaseSnapshot, DesktopReleaseStatus, +}; use wiremock::{ Mock, MockServer, ResponseTemplate, matchers::{method, path}, @@ -21,7 +23,7 @@ use crate::support::storage::init_test_db; mod cleanup; mod coordinator; -mod fingerprint_update; +mod desktop_release_update; mod model_refresh; mod retention_trim; @@ -124,37 +126,29 @@ async fn mount_appcast(server: &MockServer, body: &str) { .await; } -async fn wait_for_current_fingerprint_version( - repo: &PgFingerprintStore, -) -> codex_proxy_rs::upstream::openai::fingerprint::Fingerprint { +async fn wait_for_latest_desktop_release( + status: &DesktopReleaseStatus, + expected_version: &str, +) -> DesktopReleaseSnapshot { let deadline = tokio::time::Instant::now() + StdDuration::from_secs(2); loop { - if let Some(stored) = repo - .load_current() - .await - .expect("current fingerprint should load") - .filter(|stored| stored.app_version == "26.900.1") + let snapshot = status.snapshot(); + if snapshot + .latest + .as_ref() + .is_some_and(|release| release.version == expected_version) { - return stored; + return snapshot; } assert!( tokio::time::Instant::now() < deadline, - "fingerprint update task did not persist auto update before timeout" + "Desktop release task did not refresh appcast state before timeout" ); tokio::time::sleep(StdDuration::from_millis(25)).await; } } -async fn runtime_fingerprint_from_repo(repo: &PgFingerprintStore) -> 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 { @@ -171,7 +165,7 @@ async fn wait_for_appcast_requests(server: &MockServer, expected_count: usize) { assert!( tokio::time::Instant::now() < deadline, - "fingerprint update task did not request appcast before timeout" + "Desktop release task did not request appcast before timeout" ); tokio::time::sleep(StdDuration::from_millis(25)).await; } diff --git a/backend/tests/bootstrap/tasks/retention_trim.rs b/backend/tests/bootstrap/tasks/retention_trim.rs index 55ce0e048..b958d1c76 100644 --- a/backend/tests/bootstrap/tasks/retention_trim.rs +++ b/backend/tests/bootstrap/tasks/retention_trim.rs @@ -6,7 +6,6 @@ use codex_proxy_rs::{ ops::{store::PgOpsErrorLogStore, types::OpsErrorLog}, usage::{store::PgUsageRecordStore, types::UsageRecord}, }, - upstream::openai::fingerprint::PgFingerprintStore, }; use crate::support::storage::init_test_db; @@ -28,11 +27,6 @@ async fn retention_trim_task_should_trim_all_growth_tables_in_one_run() { let usage_store = PgUsageRecordStore::new(pool.clone()); let ops_store = PgOpsErrorLogStore::new(pool.clone()); let bucket_store = PgRequestBucketStore::new(pool.clone()); - let fingerprint_store = PgFingerprintStore::new(pool.clone()); - fingerprint_store - .ensure_current_seed(&crate::support::fingerprint::test_fingerprint()) - .await - .unwrap(); let now = Utc::now(); let mut old_usage = UsageRecord::new("request", "old", "acct", "gpt-old", 200); @@ -55,19 +49,7 @@ async fn retention_trim_task_should_trim_all_growth_tables_in_one_run() { current_error.created_at = now; ops_store.append(¤t_error).await.unwrap(); - sqlx::query( - "insert into fingerprint_update_history ( - id, current_fingerprint_id, app_version, build_number, source, created_at - ) - select 'history_' || value, 'current', '1.0.' || value, value::text, 'test', - now() + value * interval '1 microsecond' - from generate_series(1, 101) as value", - ) - .execute(&pool) - .await - .unwrap(); - - RetentionTrimTask::new(usage_store, ops_store, bucket_store, fingerprint_store) + RetentionTrimTask::new(usage_store, ops_store, bucket_store) .run_once() .await; @@ -85,13 +67,7 @@ async fn retention_trim_task_should_trim_all_growth_tables_in_one_run() { .fetch_one(&pool) .await .unwrap(); - let history_count: i64 = sqlx::query_scalar("select count(*) from fingerprint_update_history") - .fetch_one(&pool) - .await - .unwrap(); - assert_eq!(usage_ids, vec!["usage_current".to_string()]); assert_eq!(ops_ids, vec!["ops_current".to_string()]); assert_eq!(old_buckets, 0); - assert_eq!(history_count, 100); } diff --git a/backend/tests/dispatch/service.rs b/backend/tests/dispatch/service.rs index 5eb3ea907..b2e883423 100644 --- a/backend/tests/dispatch/service.rs +++ b/backend/tests/dispatch/service.rs @@ -243,7 +243,7 @@ async fn test_app_state_with_storage( let services = Services::try_with_usage_record_options( &config, stores, - crate::support::fingerprint::runtime_test_fingerprint(), + crate::support::wire_profile::test_wire_profile(), usage_record_options, ) .expect("failed to build runtime services with configured TLS transport"); diff --git a/backend/tests/fleet/quota.rs b/backend/tests/fleet/quota.rs index bcf7ddec0..4be8b7899 100644 --- a/backend/tests/fleet/quota.rs +++ b/backend/tests/fleet/quota.rs @@ -327,7 +327,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::runtime_test_fingerprint(), + crate::support::wire_profile::test_wire_profile(), ); let service = QuotaRefreshService::new( store, @@ -410,7 +410,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::runtime_test_fingerprint(), + crate::support::wire_profile::test_wire_profile(), ); let service = QuotaRefreshService::new( store.clone(), @@ -468,7 +468,7 @@ async fn quota_refresh_service_should_ban_deactivated_workspace() { let codex = Arc::new(CodexBackendClient::new( reqwest::Client::new(), server.uri(), - crate::support::fingerprint::runtime_test_fingerprint(), + crate::support::wire_profile::test_wire_profile(), )); let service = QuotaRefreshService::new( store, @@ -530,7 +530,7 @@ async fn quota_refresh_service_should_refresh_quota_exhausted_accounts() { let codex = CodexBackendClient::new( reqwest::Client::new(), server.uri(), - crate::support::fingerprint::runtime_test_fingerprint(), + crate::support::wire_profile::test_wire_profile(), ); let service = QuotaRefreshService::new( store, @@ -567,7 +567,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::runtime_test_fingerprint(), + crate::support::wire_profile::test_wire_profile(), ); let service = QuotaRefreshService::with_min_refresh_interval_secs( store, @@ -608,7 +608,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::runtime_test_fingerprint(), + crate::support::wire_profile::test_wire_profile(), ); let service = QuotaRefreshService::with_min_refresh_interval_secs( store, @@ -666,7 +666,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::runtime_test_fingerprint(), + crate::support::wire_profile::test_wire_profile(), ); let service = QuotaRefreshService::with_min_refresh_interval_secs( store, @@ -722,7 +722,7 @@ async fn quota_refresh_service_should_stagger_multiple_locked_account_requests() let codex = CodexBackendClient::new( reqwest::Client::new(), server.uri(), - crate::support::fingerprint::runtime_test_fingerprint(), + crate::support::wire_profile::test_wire_profile(), ); let service = QuotaRefreshService::new( store, diff --git a/backend/tests/infra/database.rs b/backend/tests/infra/database.rs index a6674733e..b45abda47 100644 --- a/backend/tests/infra/database.rs +++ b/backend/tests/infra/database.rs @@ -25,8 +25,6 @@ async fn postgres_schema_is_terminal_v1_without_unused_storage() { "accounts", "admin_users", "client_api_keys", - "fingerprint_update_history", - "fingerprints", "ops_error_logs", "request_time_buckets", "runtime_settings", diff --git a/backend/tests/support/config.rs b/backend/tests/support/config.rs index 0485bb607..7ed7c7b63 100644 --- a/backend/tests/support/config.rs +++ b/backend/tests/support/config.rs @@ -42,7 +42,7 @@ pub(crate) fn test_config(database_url: String) -> AppConfig { max_per_account: 8, initial_event_timeout_ms: 20_000, }, - fingerprint: crate::support::fingerprint::test_fingerprint_config(), + wire_profile: crate::support::wire_profile::test_wire_profile_config(), admin: AdminConfig { session_ttl_minutes: 1440, default_username: "admin".to_string(), diff --git a/backend/tests/support/fingerprint.rs b/backend/tests/support/fingerprint.rs deleted file mode 100644 index 401a57971..000000000 --- a/backend/tests/support/fingerprint.rs +++ /dev/null @@ -1,75 +0,0 @@ -use codex_proxy_rs::{ - bootstrap::{ - config::{FingerprintConfig, FingerprintHeaderConfig}, - services::fingerprint_from_config, - }, - upstream::openai::fingerprint::{Fingerprint, RuntimeFingerprint}, -}; - -pub(crate) fn test_fingerprint_config() -> FingerprintConfig { - let header = |name: &str, value: &str| FingerprintHeaderConfig { - name: name.to_string(), - value: value.to_string(), - }; - - FingerprintConfig { - originator: "Codex Desktop".to_string(), - app_version: "26.707.51957".to_string(), - build_number: "5175".to_string(), - platform: "darwin".to_string(), - arch: "arm64".to_string(), - chromium_version: "146".to_string(), - user_agent_template: "Codex Desktop/{version} ({platform}; {arch})".to_string(), - default_headers: vec![ - header("Accept-Encoding", "gzip, deflate, br, zstd"), - header("Accept-Language", "en-US,en;q=0.9"), - header("sec-ch-ua-mobile", "?0"), - header("sec-ch-ua-platform", "\"macOS\""), - header("sec-fetch-site", "same-origin"), - header("sec-fetch-mode", "cors"), - header("sec-fetch-dest", "empty"), - ], - header_order: [ - "authorization", - "chatgpt-account-id", - "originator", - "x-openai-internal-codex-residency", - "x-client-request-id", - "x-codex-installation-id", - "x-codex-turn-state", - "openai-beta", - "user-agent", - "sec-ch-ua", - "sec-ch-ua-mobile", - "sec-ch-ua-platform", - "accept-encoding", - "accept-language", - "sec-fetch-site", - "sec-fetch-mode", - "sec-fetch-dest", - "content-type", - "accept", - "cookie", - ] - .map(str::to_string) - .to_vec(), - } -} - -pub(crate) fn test_fingerprint() -> Fingerprint { - fingerprint_from_config(&test_fingerprint_config()) -} - -pub(crate) fn test_fingerprint_with_updated_at(updated_at: Option<&str>) -> Fingerprint { - let mut fingerprint = test_fingerprint(); - 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/support/mod.rs b/backend/tests/support/mod.rs index 059cce885..745c6aa9c 100644 --- a/backend/tests/support/mod.rs +++ b/backend/tests/support/mod.rs @@ -3,7 +3,7 @@ pub(crate) mod admin; pub(crate) mod assertions; pub(crate) mod client_keys; pub(crate) mod config; -pub(crate) mod fingerprint; pub(crate) mod http; pub(crate) mod jwt; pub(crate) mod storage; +pub(crate) mod wire_profile; diff --git a/backend/tests/support/storage.rs b/backend/tests/support/storage.rs index 2c6207e55..acff03f01 100644 --- a/backend/tests/support/storage.rs +++ b/backend/tests/support/storage.rs @@ -13,7 +13,6 @@ use codex_proxy_rs::{ account_usage::store::PgAccountUsageStore, buckets::store::PgRequestBucketStore, ops::store::PgOpsErrorLogStore, usage::store::PgUsageRecordStore, }, - upstream::openai::fingerprint::PgFingerprintStore, }; use sqlx::{Connection, PgConnection, PgPool, postgres::PgPoolOptions}; use uuid::Uuid; @@ -174,7 +173,6 @@ pub(crate) fn background_task_stores(pool: PgPool, redis: RedisConnection) -> Ba admin_users: PgAdminUserStore::new(pool.clone()), admin_sessions: RedisAdminSessionStore::new(redis.clone()), cookies: PgCookieStore::new(pool.clone()), - fingerprints: PgFingerprintStore::new(pool.clone()), session_affinity: RedisSessionAffinityStore::new(redis.clone()), refresh_leases: RedisRefreshLeaseStore::new(redis.clone()), model_snapshots: RedisModelSnapshotStore::new(redis), diff --git a/backend/tests/support/wire_profile.rs b/backend/tests/support/wire_profile.rs new file mode 100644 index 000000000..4086040fa --- /dev/null +++ b/backend/tests/support/wire_profile.rs @@ -0,0 +1,34 @@ +use std::sync::Arc; + +use codex_proxy_rs::{ + bootstrap::{config::WireProfileConfig, services::wire_profile_from_config}, + upstream::openai::profile::CodexWireProfile, +}; + +use crate::support::storage::timestamp; + +pub(crate) fn test_wire_profile_config() -> WireProfileConfig { + WireProfileConfig { + originator: "Codex Desktop".to_string(), + codex_version: "0.144.2".to_string(), + desktop_version: "26.707.72221".to_string(), + desktop_build: "5307".to_string(), + os_type: "Mac OS".to_string(), + os_version: "15.7.1".to_string(), + arch: "arm64".to_string(), + terminal: "unknown".to_string(), + verified_at: timestamp("2026-07-14T18:25:50Z"), + } +} + +pub(crate) fn test_wire_profile_value() -> CodexWireProfile { + wire_profile_from_config(&test_wire_profile_config()) +} + +pub(crate) fn test_wire_profile() -> Arc { + Arc::new(test_wire_profile_value()) +} + +pub(crate) fn wire_profile(profile: CodexWireProfile) -> Arc { + Arc::new(profile) +} diff --git a/backend/tests/upstream/openai/desktop_release.rs b/backend/tests/upstream/openai/desktop_release.rs new file mode 100644 index 000000000..ca9b0602c --- /dev/null +++ b/backend/tests/upstream/openai/desktop_release.rs @@ -0,0 +1,94 @@ +use codex_proxy_rs::upstream::openai::desktop_release::{ + APPCAST_POLL_INTERVAL, DesktopReleaseChecker, DesktopReleaseStatus, + parse_latest_desktop_release, +}; +use wiremock::{ + Mock, MockServer, ResponseTemplate, + matchers::{method, path}, +}; + +const APPCAST: &str = r#" + + + + 26.707.72221 + Tue, 14 Jul 2026 07:56:40 +0000 + 5307 + 26.707.72221 + 12.0 + arm64 + + + + + + + 5263 + 26.707.71524 + + + +"#; + +#[test] +fn desktop_release_poll_interval_should_be_one_day() { + assert_eq!(APPCAST_POLL_INTERVAL, std::time::Duration::from_hours(24)); +} + +#[test] +fn appcast_parser_should_return_first_full_release_and_ignore_deltas() { + let release = parse_latest_desktop_release(APPCAST).expect("parse appcast"); + + assert_eq!(release.version, "26.707.72221"); + assert_eq!(release.build, "5307"); + assert_eq!( + release.published_at, + Some(crate::support::storage::timestamp("2026-07-14T07:56:40Z")) + ); + assert_eq!(release.minimum_system_version.as_deref(), Some("12.0")); + assert_eq!(release.hardware_requirements.as_deref(), Some("arm64")); + assert_eq!( + release.download_url.as_deref(), + Some("https://example.invalid/Codex.zip") + ); + assert_eq!(release.download_size, Some(565_564_803)); + assert!(release.signature_present); +} + +#[test] +fn appcast_parser_should_reject_release_without_codex_desktop_version() { + let error = parse_latest_desktop_release("") + .expect_err("missing version should fail"); + + assert!(error.to_string().contains("shortVersionString")); +} + +#[tokio::test] +async fn desktop_release_checker_should_update_observation_only() { + let server = MockServer::start().await; + Mock::given(method("GET")) + .and(path("/appcast.xml")) + .respond_with(ResponseTemplate::new(200).set_body_raw(APPCAST, "application/xml")) + .mount(&server) + .await; + let status = DesktopReleaseStatus::default(); + let checker = DesktopReleaseChecker::with_client( + reqwest::Client::new(), + format!("{}/appcast.xml", server.uri()), + status.clone(), + ); + + checker.check_and_record().await.expect("check appcast"); + + let snapshot = status.snapshot(); + assert_eq!( + snapshot + .latest + .as_ref() + .map(|release| release.version.as_str()), + Some("26.707.72221") + ); + assert!(snapshot.checked_at.is_some()); + assert!(snapshot.last_error.is_none()); +} diff --git a/backend/tests/upstream/openai/fingerprint/mod.rs b/backend/tests/upstream/openai/fingerprint/mod.rs deleted file mode 100644 index 03e6f41d2..000000000 --- a/backend/tests/upstream/openai/fingerprint/mod.rs +++ /dev/null @@ -1,190 +0,0 @@ -use std::{num::NonZeroU32, time::Duration}; - -use codex_proxy_rs::upstream::openai::fingerprint::{ - APPCAST_POLL_INTERVAL, PgFingerprintStore, UpdateChecker, -}; - -use crate::support::storage::init_test_db; - -#[test] -fn fingerprint_appcast_poll_interval_should_be_one_day() { - assert_eq!(APPCAST_POLL_INTERVAL, Duration::from_hours(24)); -} - -#[tokio::test] -async fn fingerprint_store_should_update_current_record() { - let (pool, _guard) = init_test_db("fingerprint-store").await; - let repo = PgFingerprintStore::new(pool.clone()); - let default_fingerprint = crate::support::fingerprint::test_fingerprint(); - - repo.ensure_current_seed(&default_fingerprint) - .await - .expect("seed current fingerprint"); - repo.update_current_version("26.800.1", "6001", Some("147")) - .await - .expect("first update"); - repo.update_current_version("26.800.2", "6002", None) - .await - .expect("second update"); - - let stored = repo - .load_current() - .await - .expect("load current") - .expect("stored fingerprint"); - let count: (i64,) = sqlx::query_as("select count(*) from fingerprints where id = 'current'") - .fetch_one(&pool) - .await - .expect("count row"); - - assert_eq!(count.0, 1); - assert_eq!(stored.app_version, "26.800.2"); - assert_eq!(stored.build_number, "6002"); - assert_eq!(stored.chromium_version, "147"); -} - -#[tokio::test] -async fn fingerprint_store_should_keep_latest_update_history_rows() { - let (pool, _guard) = init_test_db("fingerprint-history-limit").await; - let repo = PgFingerprintStore::new(pool.clone()); - repo.ensure_current_seed(&crate::support::fingerprint::test_fingerprint()) - .await - .expect("seed current fingerprint"); - sqlx::query( - "insert into fingerprint_update_history ( - id, current_fingerprint_id, app_version, build_number, source, created_at - ) - select - 'history-' || lpad(sequence::text, 3, '0'), - 'current', - '26.900.' || sequence::text, - sequence::text, - 'auto_update', - $1 + sequence * interval '1 second' - from generate_series(0, 101) as sequence", - ) - .bind(chrono::Utc::now()) - .execute(&pool) - .await - .expect("insert fingerprint history rows"); - - let deleted = repo - .trim_update_history_to_limit(NonZeroU32::new(100).unwrap()) - .await - .expect("trim fingerprint history rows"); - let (remaining, oldest_remaining): (i64, i64) = sqlx::query_as( - "select - count(*), - count(*) filter (where id in ('history-000', 'history-001')) - from fingerprint_update_history", - ) - .fetch_one(&pool) - .await - .expect("count retained fingerprint history rows"); - - assert_eq!((deleted, remaining, oldest_remaining), (2, 100, 0)); -} - -#[tokio::test] -async fn update_checker_should_report_available_update_from_appcast() { - let server = wiremock::MockServer::start().await; - wiremock::Mock::given(wiremock::matchers::method("GET")) - .and(wiremock::matchers::path("/appcast.xml")) - .respond_with(wiremock::ResponseTemplate::new(200).set_body_raw( - r#" - - - - - - - - "#, - "application/xml", - )) - .mount(&server) - .await; - - let dir = tempfile::tempdir().expect("temp dir"); - let (pool, _guard) = init_test_db("fingerprint-update-check").await; - let repo = PgFingerprintStore::new(pool); - repo.ensure_current_seed(&crate::support::fingerprint::test_fingerprint()) - .await - .expect("seed current fingerprint"); - - let checker = UpdateChecker::with_client( - repo, - reqwest::Client::new(), - format!("{}/appcast.xml", server.uri()), - dir.path().join("extracted-fingerprint.json"), - "26.800.1", - "6001", - ); - - let state = checker.check_for_update().await.expect("update state"); - - assert!(state.update_available); - assert_eq!(state.latest_version.as_deref(), Some("26.900.1")); - assert_eq!(state.latest_build.as_deref(), Some("7001")); -} - -#[tokio::test] -async fn update_checker_should_apply_available_update_to_store() { - let server = wiremock::MockServer::start().await; - wiremock::Mock::given(wiremock::matchers::method("GET")) - .and(wiremock::matchers::path("/appcast.xml")) - .respond_with(wiremock::ResponseTemplate::new(200).set_body_raw( - r#" - - - - - - - - "#, - "application/xml", - )) - .mount(&server) - .await; - - let dir = tempfile::tempdir().expect("temp dir"); - let (pool, _guard) = init_test_db("fingerprint-update-apply").await; - let repo = PgFingerprintStore::new(pool); - repo.ensure_current_seed(&crate::support::fingerprint::test_fingerprint()) - .await - .expect("seed current fingerprint"); - let extracted_path = dir.path().join("extracted-fingerprint.json"); - std::fs::write( - &extracted_path, - r#"{"app_version":"26.900.1","build_number":"7001","chromium_version":"147"}"#, - ) - .expect("write extracted fingerprint"); - - let checker = UpdateChecker::with_client( - repo.clone(), - reqwest::Client::new(), - format!("{}/appcast.xml", server.uri()), - extracted_path, - "26.800.1", - "6001", - ); - - let updated = checker - .check_and_apply_update() - .await - .expect("apply update") - .expect("available update should return current fingerprint"); - let stored = repo - .load_current() - .await - .expect("load current") - .expect("stored fingerprint"); - - 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/openai/mod.rs b/backend/tests/upstream/openai/mod.rs index c0d61d017..427576822 100644 --- a/backend/tests/upstream/openai/mod.rs +++ b/backend/tests/upstream/openai/mod.rs @@ -1,4 +1,5 @@ -pub(crate) mod fingerprint; +mod desktop_release; +mod profile; pub(crate) mod protocol; pub(crate) mod token_client; pub(crate) mod transport; diff --git a/backend/tests/upstream/openai/profile.rs b/backend/tests/upstream/openai/profile.rs new file mode 100644 index 000000000..3273781e4 --- /dev/null +++ b/backend/tests/upstream/openai/profile.rs @@ -0,0 +1,9 @@ +#[test] +fn wire_profile_should_generate_codex_core_user_agent() { + let profile = crate::support::wire_profile::test_wire_profile_value(); + + assert_eq!( + profile.user_agent(), + "Codex Desktop/0.144.2 (Mac OS 15.7.1; arm64) unknown (Codex Desktop; 26.707.72221)" + ); +} diff --git a/backend/tests/upstream/openai/transport/client.rs b/backend/tests/upstream/openai/transport/client.rs index 3b1c9588e..34c79a04c 100644 --- a/backend/tests/upstream/openai/transport/client.rs +++ b/backend/tests/upstream/openai/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::runtime_test_fingerprint(), + crate::support::wire_profile::test_wire_profile(), ) .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/openai/transport/headers.rs b/backend/tests/upstream/openai/transport/headers.rs index db0089b47..f0b757498 100644 --- a/backend/tests/upstream/openai/transport/headers.rs +++ b/backend/tests/upstream/openai/transport/headers.rs @@ -1,28 +1,46 @@ use super::*; -use codex_proxy_rs::upstream::openai::fingerprint::RuntimeFingerprint; -use codex_proxy_rs::upstream::openai::transport::build_ordered_codex_headers; +use codex_proxy_rs::upstream::openai::transport::build_codex_headers; use codex_proxy_rs::upstream::openai::transport::websocket::CodexWebSocketConnection; use serde_json::Value; #[test] -fn ordered_codex_headers_should_preserve_fingerprint_priority_and_request_fields() { - let fingerprint = crate::support::fingerprint::test_fingerprint(); +fn codex_headers_should_use_wire_profile_without_browser_metadata() { + let profile = crate::support::wire_profile::test_wire_profile_value(); - let headers = build_ordered_codex_headers( - &fingerprint, + let headers = build_codex_headers( + &profile, "access-token", Some("acct-1"), Some("turn-1"), "req-1", - ); - let keys = headers.keys().cloned().collect::>(); + ) + .expect("valid profile headers"); - assert_eq!(headers["authorization"], "Bearer access-token"); - assert_eq!(headers["chatgpt-account-id"], "acct-1"); - assert_eq!(headers["x-client-request-id"], "req-1"); - assert_eq!(headers["x-codex-turn-state"], "turn-1"); - assert_eq!(headers["accept"], "text/event-stream"); - assert_eq!(keys.first().map(String::as_str), Some("authorization")); + let value = |name: &str| headers.get(name).and_then(|value| value.to_str().ok()); + assert_eq!(value("authorization"), Some("Bearer access-token")); + assert_eq!(value("chatgpt-account-id"), Some("acct-1")); + assert_eq!(value("originator"), Some("Codex Desktop")); + assert_eq!( + value("user-agent"), + Some("Codex Desktop/0.144.2 (Mac OS 15.7.1; arm64) unknown (Codex Desktop; 26.707.72221)") + ); + assert_eq!(value("x-client-request-id"), Some("req-1")); + assert_eq!(value("x-codex-turn-state"), Some("turn-1")); + assert_eq!(value("accept"), Some("text/event-stream")); + for browser_header in [ + "sec-ch-ua", + "sec-ch-ua-mobile", + "sec-ch-ua-platform", + "sec-fetch-site", + "sec-fetch-mode", + "sec-fetch-dest", + "accept-language", + ] { + assert!( + headers.get(browser_header).is_none(), + "sent {browser_header}" + ); + } } #[test] @@ -282,7 +300,7 @@ async fn codex_backend_client_websocket_should_forward_headers_and_preserve_payl let backend = CodexBackendClient::new( reqwest::Client::builder().no_proxy().build().unwrap(), format!("http://{addr}"), - crate::support::fingerprint::runtime_test_fingerprint(), + crate::support::wire_profile::test_wire_profile(), ) .with_websocket_pool(pool); @@ -433,7 +451,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::runtime_test_fingerprint(), + crate::support::wire_profile::test_wire_profile(), ); let mut request = codex_proxy_rs::upstream::openai::protocol::responses::CodexResponsesRequest::new_http_sse( @@ -490,7 +508,7 @@ 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() { +async fn codex_backend_client_should_send_effective_wire_profile_user_agent() { 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")) @@ -503,13 +521,12 @@ async fn codex_backend_client_should_use_latest_runtime_fingerprint_for_new_requ .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 profile = crate::support::wire_profile::test_wire_profile(); + let expected_user_agent = profile.user_agent(); let client = CodexBackendClient::new( reqwest::Client::builder().no_proxy().build().unwrap(), server.uri(), - runtime_fingerprint.clone(), + profile, ); let mut request = codex_proxy_rs::upstream::openai::protocol::responses::CodexResponsesRequest::new_http_sse( @@ -538,19 +555,9 @@ async fn codex_backend_client_should_use_latest_runtime_fingerprint_for_new_requ }; client - .create_response(&request, request_context("req_fingerprint_1")) + .create_response(&request, request_context("req_profile")) .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"); + .expect("response should succeed"); let user_agents = server .received_requests() @@ -568,13 +575,7 @@ async fn codex_backend_client_should_use_latest_runtime_fingerprint_for_new_requ }) .collect::>(); - assert_eq!( - user_agents, - vec![ - "Codex Desktop/26.800.1 (darwin; arm64)".to_string(), - "Codex Desktop/26.900.1 (darwin; arm64)".to_string(), - ] - ); + assert_eq!(user_agents, vec![expected_user_agent]); } #[tokio::test] @@ -592,7 +593,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::runtime_test_fingerprint(), + crate::support::wire_profile::test_wire_profile(), ); let usage = client @@ -636,7 +637,7 @@ async fn codex_backend_client_usage_should_use_wham_usage_headers() { headers .get("user-agent") .and_then(|value| value.to_str().ok()), - Some("Codex Desktop/26.707.51957 (darwin; arm64)") + Some("Codex Desktop/0.144.2 (Mac OS 15.7.1; arm64) unknown (Codex Desktop; 26.707.72221)") ); assert_eq!( headers.get("accept").and_then(|value| value.to_str().ok()), @@ -679,12 +680,12 @@ async fn codex_backend_client_models_should_use_original_auxiliary_headers() { }))) .mount(&server) .await; - let fingerprint = crate::support::fingerprint::runtime_test_fingerprint(); - let client_version = fingerprint.current().app_version.clone(); + let profile = crate::support::wire_profile::test_wire_profile(); + let client_version = profile.codex_version.clone(); let client = CodexBackendClient::new( reqwest::Client::builder().no_proxy().build().unwrap(), server.uri(), - fingerprint, + profile, ); let models = client @@ -732,7 +733,7 @@ async fn codex_backend_client_models_should_use_original_auxiliary_headers() { } #[tokio::test] -async fn codex_backend_client_should_send_http_sse_headers_in_fingerprint_order() { +async fn codex_backend_client_should_send_codex_context_without_browser_headers() { let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); let addr = listener.local_addr().unwrap(); let server = tokio::spawn(async move { @@ -758,7 +759,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::runtime_test_fingerprint(), + crate::support::wire_profile::test_wire_profile(), ); client @@ -788,36 +789,38 @@ async fn codex_backend_client_should_send_http_sse_headers_in_fingerprint_order( let raw_request = server.await.unwrap(); let header_names = read_header_names(&raw_request); - assert_header_subsequence( - &header_names, - &[ - "authorization", - "chatgpt-account-id", - "originator", - "user-agent", - "sec-ch-ua", - "sec-ch-ua-mobile", - "sec-ch-ua-platform", - "accept-encoding", - "accept-language", - "sec-fetch-site", - "sec-fetch-mode", - "sec-fetch-dest", - "content-type", - "cookie", - "accept", - "openai-beta", - "x-openai-internal-codex-residency", - "x-client-request-id", - "x-codex-installation-id", - "session-id", - "x-codex-window-id", - "x-codex-turn-state", - "x-codex-turn-metadata", - "x-codex-beta-features", - "x-responsesapi-include-timing-metrics", - "version", - "x-codex-parent-thread-id", - ], - ); + for required_header in [ + "authorization", + "chatgpt-account-id", + "originator", + "user-agent", + "content-type", + "cookie", + "accept", + "openai-beta", + "x-openai-internal-codex-residency", + "x-client-request-id", + "x-codex-installation-id", + "session-id", + "x-codex-window-id", + "x-codex-turn-state", + "x-codex-turn-metadata", + "x-codex-beta-features", + "x-responsesapi-include-timing-metrics", + "version", + "x-codex-parent-thread-id", + ] { + assert!(header_names.iter().any(|name| name == required_header)); + } + for browser_header in [ + "sec-ch-ua", + "sec-ch-ua-mobile", + "sec-ch-ua-platform", + "accept-language", + "sec-fetch-site", + "sec-fetch-mode", + "sec-fetch-dest", + ] { + assert!(!header_names.iter().any(|name| name == browser_header)); + } } diff --git a/backend/tests/upstream/openai/transport/http_client.rs b/backend/tests/upstream/openai/transport/http_client.rs index a519cbbf0..0e734d6da 100644 --- a/backend/tests/upstream/openai/transport/http_client.rs +++ b/backend/tests/upstream/openai/transport/http_client.rs @@ -126,7 +126,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::runtime_test_fingerprint(), + crate::support::wire_profile::test_wire_profile(), ); let mut request = codex_proxy_rs::upstream::openai::protocol::responses::CodexResponsesRequest::new_http_sse( @@ -183,7 +183,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::runtime_test_fingerprint(), + crate::support::wire_profile::test_wire_profile(), ); let mut request = codex_proxy_rs::upstream::openai::protocol::responses::CodexResponsesRequest::new_http_sse( @@ -247,7 +247,7 @@ async fn codex_backend_http_sse_should_fail_after_five_minutes_without_stream_da let client = CodexBackendClient::new( reqwest::Client::builder().no_proxy().build().unwrap(), base_url, - crate::support::fingerprint::runtime_test_fingerprint(), + crate::support::wire_profile::test_wire_profile(), ); let mut request = codex_proxy_rs::upstream::openai::protocol::responses::CodexResponsesRequest::new_http_sse( @@ -298,7 +298,7 @@ async fn codex_backend_client_should_capture_only_safe_response_metadata() { let client = CodexBackendClient::new( reqwest::Client::builder().no_proxy().build().unwrap(), server.uri(), - crate::support::fingerprint::runtime_test_fingerprint(), + crate::support::wire_profile::test_wire_profile(), ); let mut request = codex_proxy_rs::upstream::openai::protocol::responses::CodexResponsesRequest::new_http_sse( diff --git a/backend/tests/upstream/openai/transport/latency.rs b/backend/tests/upstream/openai/transport/latency.rs index 874f4d3bb..e9d876f97 100644 --- a/backend/tests/upstream/openai/transport/latency.rs +++ b/backend/tests/upstream/openai/transport/latency.rs @@ -36,7 +36,7 @@ async fn cold_websocket_should_fall_back_without_recording_a_successful_connect( let backend = CodexBackendClient::new( reqwest::Client::builder().no_proxy().build().unwrap(), format!("http://{addr}"), - crate::support::fingerprint::runtime_test_fingerprint(), + crate::support::wire_profile::test_wire_profile(), ) .with_websocket_pool(Arc::clone(&pool)) .with_websocket_fast_path_budget(Duration::from_millis(30)); @@ -91,7 +91,7 @@ async fn timed_out_websocket_should_finish_in_background_and_serve_the_next_requ let backend = CodexBackendClient::new( reqwest::Client::builder().no_proxy().build().unwrap(), format!("http://{addr}"), - crate::support::fingerprint::runtime_test_fingerprint(), + crate::support::wire_profile::test_wire_profile(), ) .with_websocket_pool(Arc::clone(&pool)) .with_websocket_fast_path_budget(Duration::from_millis(30)); @@ -146,7 +146,7 @@ async fn shared_websocket_opening_should_keep_the_original_fast_path_deadline() let backend = CodexBackendClient::new( reqwest::Client::builder().no_proxy().build().unwrap(), format!("http://{addr}"), - crate::support::fingerprint::runtime_test_fingerprint(), + crate::support::wire_profile::test_wire_profile(), ) .with_websocket_pool(Arc::clone(&pool)) .with_websocket_fast_path_budget(Duration::from_millis(200)); @@ -215,7 +215,7 @@ async fn account_eviction_should_cancel_a_background_websocket_opening() { let backend = CodexBackendClient::new( reqwest::Client::builder().no_proxy().build().unwrap(), format!("http://{addr}"), - crate::support::fingerprint::runtime_test_fingerprint(), + crate::support::wire_profile::test_wire_profile(), ) .with_websocket_pool(Arc::clone(&pool)) .with_websocket_fast_path_budget(Duration::from_millis(30)); @@ -280,7 +280,7 @@ async fn pool_shutdown_should_cancel_and_join_a_background_websocket_opening() { let backend = CodexBackendClient::new( reqwest::Client::builder().no_proxy().build().unwrap(), format!("http://{addr}"), - crate::support::fingerprint::runtime_test_fingerprint(), + crate::support::wire_profile::test_wire_profile(), ) .with_websocket_pool(Arc::clone(&pool)) .with_websocket_fast_path_budget(Duration::from_millis(30)); @@ -323,7 +323,7 @@ async fn store_false_warmup_should_never_fall_back_to_http() { let backend = CodexBackendClient::new( reqwest::Client::builder().no_proxy().build().unwrap(), format!("http://{addr}"), - crate::support::fingerprint::runtime_test_fingerprint(), + crate::support::wire_profile::test_wire_profile(), ) .with_websocket_pool(Arc::new(CodexWebSocketPool::default())); let mut body = serde_json::Map::new(); @@ -380,7 +380,7 @@ async fn websocket_opening_upstream_error_should_not_fall_back_to_http() { let backend = CodexBackendClient::new( reqwest::Client::builder().no_proxy().build().unwrap(), format!("http://{addr}"), - crate::support::fingerprint::runtime_test_fingerprint(), + crate::support::wire_profile::test_wire_profile(), ) .with_websocket_pool(Arc::new(CodexWebSocketPool::default())); @@ -438,7 +438,7 @@ async fn connection_local_continuation_should_use_the_exact_socket() { let backend = CodexBackendClient::new( reqwest::Client::builder().no_proxy().build().unwrap(), format!("http://{addr}"), - crate::support::fingerprint::runtime_test_fingerprint(), + crate::support::wire_profile::test_wire_profile(), ) .with_websocket_pool(Arc::new(CodexWebSocketPool::default())); let first_request = new_chain_request("conversation-exact"); @@ -478,7 +478,7 @@ async fn missing_exact_socket_should_fail_without_opening_a_connection() { let backend = CodexBackendClient::new( reqwest::Client::builder().no_proxy().build().unwrap(), format!("http://{addr}"), - crate::support::fingerprint::runtime_test_fingerprint(), + crate::support::wire_profile::test_wire_profile(), ) .with_websocket_pool(Arc::new(CodexWebSocketPool::default())); let mut request = new_chain_request("conversation-exact-missing"); @@ -532,7 +532,7 @@ async fn concurrent_same_key_should_singleflight_websocket_opening() { let backend = CodexBackendClient::new( reqwest::Client::builder().no_proxy().build().unwrap(), format!("http://{addr}"), - crate::support::fingerprint::runtime_test_fingerprint(), + crate::support::wire_profile::test_wire_profile(), ) .with_websocket_pool(Arc::new(CodexWebSocketPool::default())) .with_websocket_fast_path_budget(Duration::from_millis(200)); @@ -584,7 +584,7 @@ async fn payload_send_failure_should_not_open_http_fallback() { let backend = CodexBackendClient::new( reqwest::Client::builder().no_proxy().build().unwrap(), format!("http://{addr}"), - crate::support::fingerprint::runtime_test_fingerprint(), + crate::support::wire_profile::test_wire_profile(), ) .with_websocket_pool(Arc::new(CodexWebSocketPool::default())); @@ -731,7 +731,7 @@ async fn fast_path_miss_and_late_failure_should_count_as_one_breaker_failure() { let backend = CodexBackendClient::new( reqwest::Client::builder().no_proxy().build().unwrap(), format!("http://{addr}"), - crate::support::fingerprint::runtime_test_fingerprint(), + crate::support::wire_profile::test_wire_profile(), ) .with_websocket_pool(Arc::clone(&pool)) .with_websocket_fast_path_budget(Duration::from_millis(30)) @@ -812,7 +812,7 @@ async fn half_open_upstream_response_should_close_origin_breaker() { let backend = CodexBackendClient::new( reqwest::Client::builder().no_proxy().build().unwrap(), format!("http://{addr}"), - crate::support::fingerprint::runtime_test_fingerprint(), + crate::support::wire_profile::test_wire_profile(), ) .with_websocket_pool(Arc::new(CodexWebSocketPool::default())) .with_websocket_fast_path_budget(Duration::from_millis(100)) diff --git a/backend/tests/upstream/openai/transport/mod.rs b/backend/tests/upstream/openai/transport/mod.rs index 29a4d137e..6a6103fc6 100644 --- a/backend/tests/upstream/openai/transport/mod.rs +++ b/backend/tests/upstream/openai/transport/mod.rs @@ -113,19 +113,6 @@ pub(crate) fn read_header_names(request: &str) -> Vec { .collect() } -pub(crate) fn assert_header_subsequence(actual: &[String], expected: &[&str]) { - let mut offset = 0; - for expected_name in expected { - let Some(position) = actual[offset..] - .iter() - .position(|actual_name| actual_name == expected_name) - else { - panic!("missing header {expected_name}; actual order: {actual:?}"); - }; - offset += position + 1; - } -} - pub(crate) async fn read_http_request(stream: &mut TcpStream) -> String { let mut request = Vec::new(); let mut buffer = [0u8; 1024]; diff --git a/backend/tests/upstream/openai/transport/websocket.rs b/backend/tests/upstream/openai/transport/websocket.rs index e5dbdcc84..96d8f1e69 100644 --- a/backend/tests/upstream/openai/transport/websocket.rs +++ b/backend/tests/upstream/openai/transport/websocket.rs @@ -130,7 +130,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::runtime_test_fingerprint(), + crate::support::wire_profile::test_wire_profile(), ) .with_websocket_pool(pool); let mut request = @@ -728,7 +728,7 @@ async fn codex_backend_client_should_not_reuse_websocket_rate_limit_headers() { let backend = CodexBackendClient::new( reqwest::Client::builder().no_proxy().build().unwrap(), format!("http://{addr}"), - crate::support::fingerprint::runtime_test_fingerprint(), + crate::support::wire_profile::test_wire_profile(), ) .with_websocket_pool(pool); let request = pooled_websocket_request("conversation-rate-limit-reuse"); @@ -986,7 +986,7 @@ async fn codex_backend_client_should_timeout_when_upstream_is_silent() { let backend = CodexBackendClient::new( reqwest::Client::builder().no_proxy().build().unwrap(), format!("http://{addr}"), - crate::support::fingerprint::runtime_test_fingerprint(), + crate::support::wire_profile::test_wire_profile(), ) .with_websocket_initial_event_timeout(Some(Duration::from_millis(30))); let mut request = @@ -1154,7 +1154,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::runtime_test_fingerprint(), + crate::support::wire_profile::test_wire_profile(), ); let mut response = backend @@ -1228,7 +1228,7 @@ async fn codex_backend_client_stream_should_keep_socket_after_structural_activit let backend = CodexBackendClient::new( reqwest::Client::builder().no_proxy().build().unwrap(), format!("http://{addr}"), - crate::support::fingerprint::runtime_test_fingerprint(), + crate::support::wire_profile::test_wire_profile(), ) .with_websocket_initial_event_timeout(Some(Duration::from_millis(30))); let request = pooled_websocket_request("conversation-structural-no-pool"); @@ -1289,7 +1289,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::runtime_test_fingerprint(), + crate::support::wire_profile::test_wire_profile(), ); let mut response = backend @@ -1386,7 +1386,7 @@ async fn codex_backend_client_stream_should_preserve_burst_during_downstream_bac let backend = CodexBackendClient::new( reqwest::Client::builder().no_proxy().build().unwrap(), format!("http://{addr}"), - crate::support::fingerprint::runtime_test_fingerprint(), + crate::support::wire_profile::test_wire_profile(), ) .with_websocket_pool(pool); let request = pooled_websocket_request("conversation-backpressure"); @@ -1476,7 +1476,7 @@ async fn codex_backend_client_stream_should_cancel_while_inbound_is_backpressure let backend = CodexBackendClient::new( reqwest::Client::builder().no_proxy().build().unwrap(), format!("http://{addr}"), - crate::support::fingerprint::runtime_test_fingerprint(), + crate::support::wire_profile::test_wire_profile(), ); let request = pooled_websocket_request("conversation-backpressure-cancel"); @@ -1536,7 +1536,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::runtime_test_fingerprint(), + crate::support::wire_profile::test_wire_profile(), ); let mut response = backend @@ -1603,7 +1603,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::runtime_test_fingerprint(), + crate::support::wire_profile::test_wire_profile(), ); let mut response = backend @@ -1700,7 +1700,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::runtime_test_fingerprint(), + crate::support::wire_profile::test_wire_profile(), ) .with_websocket_pool(pool); diff --git a/backend/tests/upstream/openai/transport/websocket_pool.rs b/backend/tests/upstream/openai/transport/websocket_pool.rs index 1bdd194e4..cf1c5c057 100644 --- a/backend/tests/upstream/openai/transport/websocket_pool.rs +++ b/backend/tests/upstream/openai/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::runtime_test_fingerprint(), + crate::support::wire_profile::test_wire_profile(), ) .with_websocket_pool(Arc::clone(&pool)); let mut request = @@ -121,7 +121,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::runtime_test_fingerprint(), + crate::support::wire_profile::test_wire_profile(), ) .with_websocket_pool(Arc::clone(&pool)); let mut request = @@ -215,7 +215,7 @@ async fn codex_backend_client_stream_should_keep_fresh_socket_after_structural_a let backend = CodexBackendClient::new( reqwest::Client::builder().no_proxy().build().unwrap(), format!("http://{addr}"), - crate::support::fingerprint::runtime_test_fingerprint(), + crate::support::wire_profile::test_wire_profile(), ) .with_websocket_pool(Arc::clone(&pool)); let request = pooled_websocket_request("conversation-structural-fresh"); @@ -304,7 +304,7 @@ async fn codex_backend_client_stream_should_keep_reused_socket_after_structural_ let backend = CodexBackendClient::new( reqwest::Client::builder().no_proxy().build().unwrap(), format!("http://{addr}"), - crate::support::fingerprint::runtime_test_fingerprint(), + crate::support::wire_profile::test_wire_profile(), ) .with_websocket_pool(Arc::clone(&pool)); let request = pooled_websocket_request("conversation-structural-reuse"); @@ -362,7 +362,7 @@ async fn codex_backend_client_stream_should_use_http_when_pool_is_at_cap() { let backend = CodexBackendClient::new( reqwest::Client::builder().no_proxy().build().unwrap(), format!("http://{addr}"), - crate::support::fingerprint::runtime_test_fingerprint(), + crate::support::wire_profile::test_wire_profile(), ) .with_websocket_pool(Arc::clone(&pool)); let request = pooled_websocket_request("conversation-structural-bypass"); @@ -417,7 +417,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::runtime_test_fingerprint(), + crate::support::wire_profile::test_wire_profile(), ) .with_websocket_pool(Arc::clone(&pool)); let mut request = @@ -496,7 +496,7 @@ async fn websocket_pool_should_use_http_while_exact_key_is_busy() { let backend = CodexBackendClient::new( reqwest::Client::builder().no_proxy().build().unwrap(), format!("http://{addr}"), - crate::support::fingerprint::runtime_test_fingerprint(), + crate::support::wire_profile::test_wire_profile(), ) .with_websocket_pool(pool); let request = pooled_websocket_request("conversation-busy"); @@ -589,7 +589,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::runtime_test_fingerprint(), + crate::support::wire_profile::test_wire_profile(), ) .with_websocket_pool(pool); let request = pooled_websocket_request("conversation-drop"); @@ -669,7 +669,7 @@ async fn websocket_pool_should_use_http_for_new_keys_after_account_cap() { let backend = CodexBackendClient::new( reqwest::Client::builder().no_proxy().build().unwrap(), format!("http://{addr}"), - crate::support::fingerprint::runtime_test_fingerprint(), + crate::support::wire_profile::test_wire_profile(), ) .with_websocket_pool(pool); let first_request = pooled_websocket_request("conversation-cap-one"); @@ -782,7 +782,7 @@ async fn websocket_pool_should_replace_idle_connection_after_pong_deadline() { let backend = CodexBackendClient::new( reqwest::Client::builder().no_proxy().build().unwrap(), format!("http://{addr}"), - crate::support::fingerprint::runtime_test_fingerprint(), + crate::support::wire_profile::test_wire_profile(), ) .with_websocket_pool(Arc::clone(&pool)); let request = pooled_websocket_request("conversation-no-pong"); @@ -871,7 +871,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::runtime_test_fingerprint(), + crate::support::wire_profile::test_wire_profile(), ) .with_websocket_pool(Arc::clone(&pool)); let request = pooled_websocket_request("conversation-gc"); @@ -990,7 +990,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::runtime_test_fingerprint(), + crate::support::wire_profile::test_wire_profile(), ) .with_websocket_pool(Arc::clone(&pool)); let mut request = @@ -1069,7 +1069,7 @@ async fn codex_backend_client_should_treat_active_business_frames_as_ping_livene let backend = CodexBackendClient::new( reqwest::Client::builder().no_proxy().build().unwrap(), format!("http://{addr}"), - crate::support::fingerprint::runtime_test_fingerprint(), + crate::support::wire_profile::test_wire_profile(), ) .with_websocket_pool(Arc::clone(&pool)); let request = pooled_websocket_request("conversation-active-without-pong"); @@ -1158,7 +1158,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::runtime_test_fingerprint(), + crate::support::wire_profile::test_wire_profile(), ) .with_websocket_pool(Arc::clone(&pool)); let mut request = @@ -1243,7 +1243,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::runtime_test_fingerprint(), + crate::support::wire_profile::test_wire_profile(), ) .with_websocket_pool(Arc::clone(&pool)); let mut request = @@ -1357,7 +1357,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::runtime_test_fingerprint(), + crate::support::wire_profile::test_wire_profile(), ) .with_websocket_pool(Arc::clone(&pool)); let mut request = @@ -1456,7 +1456,7 @@ async fn codex_backend_client_should_discard_pooled_websocket_after_error_termin let backend = CodexBackendClient::new( reqwest::Client::builder().no_proxy().build().unwrap(), format!("http://{addr}"), - crate::support::fingerprint::runtime_test_fingerprint(), + crate::support::wire_profile::test_wire_profile(), ) .with_websocket_pool(pool); let mut request = @@ -1538,7 +1538,7 @@ async fn codex_backend_client_should_discard_pooled_websocket_after_unknown_resp let backend = CodexBackendClient::new( reqwest::Client::builder().no_proxy().build().unwrap(), format!("http://{addr}"), - crate::support::fingerprint::runtime_test_fingerprint(), + crate::support::wire_profile::test_wire_profile(), ) .with_websocket_pool(pool); let request = pooled_websocket_request("conversation-pool-unknown-failed"); diff --git a/deploy/config.example.yaml b/deploy/config.example.yaml index f511a779f..a3a1ac98f 100644 --- a/deploy/config.example.yaml +++ b/deploy/config.example.yaml @@ -8,7 +8,7 @@ # 三个密码建议分别执行 `openssl rand -hex 24` 生成。 x-cpr: - schema_version: 1 + schema_version: 2 server: # 本地 cargo run 使用;Docker 内由 compose.yaml 固定覆盖为 0.0.0.0:8080。 @@ -47,53 +47,19 @@ x-cpr: # 首个真实输出的绝对超时,单位毫秒;0 表示禁用。 initial_event_timeout_ms: 20000 - fingerprint: - # PostgreSQL 中不存在当前指纹时,使用这里的数据初始化。 + wire_profile: + # 该画像来自 Desktop 制品与 bundled Codex Core 的联合审计。 + # appcast 只报告新 Desktop 制品,不会自动改写这里的请求身份。 originator: 'Codex Desktop' - app_version: '26.707.51957' - build_number: '5175' - platform: 'darwin' + codex_version: '0.144.2' + desktop_version: '26.707.72221' + desktop_build: '5307' + # 固定参考主机画像;必须在重新抓包核验后再修改。 + os_type: 'Mac OS' + os_version: '15.7.1' arch: 'arm64' - chromium_version: '146' - user_agent_template: 'Codex Desktop/{version} ({platform}; {arch})' - - default_headers: - - name: 'Accept-Encoding' - value: 'gzip, deflate, br, zstd' - - name: 'Accept-Language' - value: 'en-US,en;q=0.9' - - name: 'sec-ch-ua-mobile' - value: '?0' - - name: 'sec-ch-ua-platform' - value: '"macOS"' - - name: 'sec-fetch-site' - value: 'same-origin' - - name: 'sec-fetch-mode' - value: 'cors' - - name: 'sec-fetch-dest' - value: 'empty' - - header_order: - - authorization - - chatgpt-account-id - - originator - - x-openai-internal-codex-residency - - x-client-request-id - - x-codex-installation-id - - x-codex-turn-state - - openai-beta - - user-agent - - sec-ch-ua - - sec-ch-ua-mobile - - sec-ch-ua-platform - - accept-encoding - - accept-language - - sec-fetch-site - - sec-fetch-mode - - sec-fetch-dest - - content-type - - accept - - cookie + terminal: 'unknown' + verified_at: '2026-07-15T02:25:50+08:00' admin: session_ttl_minutes: 1440 diff --git a/docs/architecture.md b/docs/architecture.md index b9bcc10b4..4a497a7a6 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -18,7 +18,7 @@ Codex Proxy RS 是一个单进程 Rust 网关,同时提供: | 依赖 | 责任 | | --- | --- | -| PostgreSQL | 账号、Key、设置、Cookie、指纹和遥测事实的权威存储 | +| PostgreSQL | 账号、Key、设置、Cookie 和遥测事实的权威存储 | | Redis | 管理会话、刷新租约、模型快照、响应归属和会话级短期状态 | | ChatGPT Codex | Responses、模型目录、配额和 OAuth token 刷新 | | `.runtime/` | 身份派生密钥、更新状态、日志及本地 PostgreSQL/Redis 数据 | @@ -105,6 +105,8 @@ backend/src/ upstream/openai/ ├── token_client.rs ├── failure.rs +├── profile.rs +├── desktop_release.rs ├── protocol/ │ ├── responses.rs │ ├── events.rs @@ -124,13 +126,13 @@ upstream/openai/ │ ├── websocket_pool.rs │ ├── websocket_breaker.rs │ └── websocket_pump.rs -└── fingerprint/ ``` - `protocol` 定义 Responses body、canonical SSE/WS 事实和序列化,不包含账号策略。 - `failure.rs` 将各 transport 错误规范化为稳定的 `UpstreamFailureFacts`,不解释账号状态。 - `transport` 拥有 HTTP/SSE、WebSocket、TLS、header、连接池、熔断和 transport metrics。 -- `fingerprint` 拥有 Codex Desktop 指纹的存储、运行时快照和更新。 +- `profile.rs` 定义配置驱动、启动后不可变的 Codex Desktop 上游请求画像。 +- `desktop_release.rs` 只观测官方 appcast;发布变化不会自动改写生效请求画像。 - `token_client.rs` 只实现 OAuth token 刷新协议。 ### fleet @@ -409,7 +411,6 @@ PostgreSQL 保存: | 失败请求事实 | `ops_error_logs` | | 请求聚合桶 | `request_time_buckets` | | Cloudflare Cookie | `account_cookies` | -| Codex 指纹 | `fingerprints`、`fingerprint_update_history` | | 迁移记录 | `schema_migrations` | 时间列使用 `timestamptz`,应用按 UTC 时间点读写,中国自然日和展示时区由 `infra::time` 定义。迁移版本严格递增并校验名称与 SQL checksum。 @@ -459,7 +460,7 @@ Compose 将应用配置只读挂载到 `/app/deploy/config.yaml`。应用数据 6. 读取 `runtime_settings`。 7. 创建领域 store、service、OpenAI client 和账号池。 8. 读取或创建 `identity_hmac_secret`。 -9. 初始化指纹、管理员和模型快照。 +9. 构造上游请求画像并初始化管理员和模型快照。 10. 启动后台任务并挂载 HTTP router。 后台任务由 `TaskCoordinator` 管理: @@ -467,7 +468,7 @@ Compose 将应用配置只读挂载到 `/app/deploy/config.yaml`。应用数据 - Cookie 清理和事实表 retention。 - 模型目录刷新。 - token 与 quota 刷新。 -- Codex 指纹更新。 +- Codex Desktop 官方发布观测。 - settings 订阅。 - WebSocket pool 生命周期。 diff --git a/frontend/src/views/dashboard/components/ServiceStatusCard.vue b/frontend/src/views/dashboard/components/ServiceStatusCard.vue deleted file mode 100644 index 0a129c61b..000000000 --- a/frontend/src/views/dashboard/components/ServiceStatusCard.vue +++ /dev/null @@ -1,82 +0,0 @@ - - - diff --git a/frontend/src/views/dashboard/components/WireProfileCard.vue b/frontend/src/views/dashboard/components/WireProfileCard.vue new file mode 100644 index 000000000..f5f1faaed --- /dev/null +++ b/frontend/src/views/dashboard/components/WireProfileCard.vue @@ -0,0 +1,158 @@ + + + diff --git a/frontend/src/views/dashboard/composables/useDashboard.ts b/frontend/src/views/dashboard/composables/useDashboard.ts index f0cdb59ec..4ca1ff18e 100644 --- a/frontend/src/views/dashboard/composables/useDashboard.ts +++ b/frontend/src/views/dashboard/composables/useDashboard.ts @@ -2,16 +2,7 @@ import { useIntervalFn } from '@vueuse/core' import { clamp } from 'es-toolkit' import { onMounted, ref } from 'vue' -import { - Activity, - CloudCheck, - FileText, - Gauge, - MonitorCheck, - RefreshCw, - Timer, - Users, -} from '@lucide/vue' +import { Activity, FileText, Timer, Users } from '@lucide/vue' import { getDashboardSummary, getDashboardTrend } from '@/api' import { withMinimumDuration } from '@/utils/async' @@ -26,7 +17,7 @@ export function useDashboard(): any { const trendSummary = ref([]) const healthTimeline = ref(emptyDashboardSummary().healthTimeline) const accountUsage = ref([]) - const serviceStatuses = ref([]) + const wireProfile = ref(null) const usageRecords = ref([]) const poolSummary = ref(null) const capacityInfo = ref(null) @@ -109,7 +100,7 @@ export function useDashboard(): any { metrics.value = metricCards(summary) healthTimeline.value = summary.healthTimeline accountUsage.value = summary.accountUsage.map(accountUsageItem) - serviceStatuses.value = summary.serviceStatuses.map(serviceStatusItem) + wireProfile.value = summary.wireProfile ?? null usageRecords.value = summary.usageRecords poolSummary.value = summary.poolSummary capacityInfo.value = summary.capacityInfo @@ -162,7 +153,7 @@ export function useDashboard(): any { points: '', }, accountUsage: [], - serviceStatuses: [], + wireProfile: null, usageRecords: [], poolSummary: { total: 0, @@ -453,17 +444,6 @@ export function useDashboard(): any { return 'normal' } - function serviceStatusItem(item: any, index: number) { - const icons = [MonitorCheck, MonitorCheck, CloudCheck, RefreshCw, Gauge] - return { - label: item.label, - value: item.value || '-', - detail: item.detail || '-', - tone: item.tone, - icon: icons[index] ?? MonitorCheck, - } - } - function trendState(current: number, previous: number, fallbackTone: string) { if (current > previous) return { direction: 'up', tone: 'success' } if (current < previous) return { direction: 'down', tone: 'danger' } @@ -524,7 +504,7 @@ export function useDashboard(): any { trendSummary, healthTimeline, accountUsage, - serviceStatuses, + wireProfile, usageRecords, poolSummary, capacityInfo, diff --git a/frontend/src/views/dashboard/index.vue b/frontend/src/views/dashboard/index.vue index 0efb55a18..fd3100ea9 100644 --- a/frontend/src/views/dashboard/index.vue +++ b/frontend/src/views/dashboard/index.vue @@ -8,7 +8,7 @@ import UsageRecordCard from './components/UsageRecordCard.vue' import MetricCard from './components/MetricCard.vue' import RequestHealthTimelineCard from './components/RequestHealthTimelineCard.vue' import RequestTrendCard from './components/RequestTrendCard.vue' -import ServiceStatusCard from './components/ServiceStatusCard.vue' +import WireProfileCard from './components/WireProfileCard.vue' import { useDashboard } from './composables/useDashboard' const { @@ -21,7 +21,7 @@ const { trendSummary, healthTimeline, accountUsage, - serviceStatuses, + wireProfile, usageRecords, poolSummary, capacityInfo, @@ -72,7 +72,7 @@ const { :loading="trendLoading" @trend-change="loadTrend" /> - +