diff --git a/backend/Cargo.lock b/backend/Cargo.lock index 1be45146d..5d0bad4a8 100644 --- a/backend/Cargo.lock +++ b/backend/Cargo.lock @@ -954,6 +954,29 @@ dependencies = [ "rand_core 0.10.1", ] +[[package]] +name = "cssparser" +version = "0.37.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8c9cdaae01d5ed7882b04d795e7f752f46ff52d2fa3b50a20d28c464510bba98" +dependencies = [ + "cssparser-macros", + "dtoa-short", + "itoa", + "phf 0.13.1", + "smallvec", +] + +[[package]] +name = "cssparser-macros" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10a2a99df6e410a8ff4245aa2006499ea662245f967cc7c0a38c83ef8eb44dbf" +dependencies = [ + "quote", + "syn 2.0.119", +] + [[package]] name = "ctutils" version = "0.4.2" @@ -1005,6 +1028,27 @@ version = "0.5.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" +[[package]] +name = "derive_more" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d751e9e49156b02b44f9c1815bcb94b984cdcc4396ecc32521c739452808b134" +dependencies = [ + "derive_more-impl", +] + +[[package]] +name = "derive_more-impl" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "799a97264921d8623a957f6c3b9011f3b5492f557bbb7a5a19b7fa6d06ba8dcb" +dependencies = [ + "proc-macro2", + "quote", + "rustc_version", + "syn 2.0.119", +] + [[package]] name = "digest" version = "0.10.7" @@ -1053,6 +1097,21 @@ version = "0.15.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1aaf95b3e5c8f23aa320147307562d361db0ae0d51242340f558153b4eb2439b" +[[package]] +name = "dtoa" +version = "1.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c3cf4824e2d5f025c7b531afcb2325364084a16806f6d47fbc1f5fbd9960590" + +[[package]] +name = "dtoa-short" +version = "0.3.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd1511a7b6a56299bd043a9c167a6d2bfb37bf84a6dfceaba651168adfb43c87" +dependencies = [ + "dtoa", +] + [[package]] name = "dunce" version = "1.0.5" @@ -1074,6 +1133,12 @@ dependencies = [ "zeroize", ] +[[package]] +name = "ego-tree" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b04dc5a38e4f151a79d9f2451ae6037fb6eaf5cba34771f44781f80e508498e3" + [[package]] name = "either" version = "1.16.0" @@ -1416,6 +1481,7 @@ dependencies = [ "bytes", "futures", "futures-timer", + "semver", "serde", "serde_json", "thiserror", @@ -1438,6 +1504,7 @@ dependencies = [ "gateway-core", "hex", "reqwest", + "scraper", "semver", "serde", "serde_json", @@ -1501,6 +1568,15 @@ dependencies = [ "version_check", ] +[[package]] +name = "getopts" +version = "0.2.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfe4fbac503b8d1f88e6676011885f34b7174f46e59956bba534ba83abded4df" +dependencies = [ + "unicode-width", +] + [[package]] name = "getrandom" version = "0.2.17" @@ -1661,6 +1737,16 @@ dependencies = [ "digest 0.11.3", ] +[[package]] +name = "html5ever" +version = "0.39.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46a1761807faccc9a19e86944bbf40610014066306f96edcdedc2fb714bcb7b8" +dependencies = [ + "log", + "markup5ever", +] + [[package]] name = "http" version = "0.2.12" @@ -2064,6 +2150,17 @@ version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" +[[package]] +name = "markup5ever" +version = "0.39.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7122d987ec5f704ee56f6e5b41a7d93722e9aae27ae07cafa4036c4d3f9757de" +dependencies = [ + "log", + "tendril", + "web_atoms", +] + [[package]] name = "matchers" version = "0.2.0" @@ -2132,6 +2229,12 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "new_debug_unreachable" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086" + [[package]] name = "nu-ansi-term" version = "0.50.3" @@ -2283,7 +2386,7 @@ version = "0.11.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1fd6780a80ae0c52cc120a26a1a42c1ae51b247a253e4e06113d23d2c2edd078" dependencies = [ - "phf_macros", + "phf_macros 0.11.3", "phf_shared 0.11.3", ] @@ -2296,6 +2399,27 @@ dependencies = [ "phf_shared 0.12.1", ] +[[package]] +name = "phf" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c1562dc717473dbaa4c1f85a36410e03c047b2e7df7f45ee938fbef64ae7fadf" +dependencies = [ + "phf_macros 0.13.1", + "phf_shared 0.13.1", + "serde", +] + +[[package]] +name = "phf_codegen" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "49aa7f9d80421bca176ca8dbfebe668cc7a2684708594ec9f3c0db0805d5d6e1" +dependencies = [ + "phf_generator 0.13.1", + "phf_shared 0.13.1", +] + [[package]] name = "phf_generator" version = "0.11.3" @@ -2306,19 +2430,42 @@ dependencies = [ "rand 0.8.7", ] +[[package]] +name = "phf_generator" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "135ace3a761e564ec88c03a77317a7c6b80bb7f7135ef2544dbe054243b89737" +dependencies = [ + "fastrand", + "phf_shared 0.13.1", +] + [[package]] name = "phf_macros" version = "0.11.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f84ac04429c13a7ff43785d75ad27569f2951ce0ffd30a3321230db2fc727216" dependencies = [ - "phf_generator", + "phf_generator 0.11.3", "phf_shared 0.11.3", "proc-macro2", "quote", "syn 2.0.119", ] +[[package]] +name = "phf_macros" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "812f032b54b1e759ccd5f8b6677695d5268c588701effba24601f6932f8269ef" +dependencies = [ + "phf_generator 0.13.1", + "phf_shared 0.13.1", + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "phf_shared" version = "0.11.3" @@ -2337,6 +2484,15 @@ dependencies = [ "siphasher", ] +[[package]] +name = "phf_shared" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e57fef6bc5981e38c2ce2d63bfa546861309f875b8a75f092d1d54ae2d64f266" +dependencies = [ + "siphasher", +] + [[package]] name = "pin-project-lite" version = "0.2.17" @@ -2389,6 +2545,12 @@ dependencies = [ "zerocopy", ] +[[package]] +name = "precomputed-hash" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "925383efa346730478fb4838dbe9137d2a47675ad789c546d150a6e1dd4ab31c" + [[package]] name = "primefield" version = "0.14.0" @@ -2917,6 +3079,21 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" +[[package]] +name = "scraper" +version = "0.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bdd0be4d296f048bfb06dd01bbc80ef789ddd2e55583e8d2e6b804942abfabc2" +dependencies = [ + "cssparser", + "ego-tree", + "getopts", + "html5ever", + "precomputed-hash", + "selectors", + "tendril", +] + [[package]] name = "sec1" version = "0.8.1" @@ -2964,6 +3141,25 @@ dependencies = [ "libc", ] +[[package]] +name = "selectors" +version = "0.38.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8adfa1c298912827b8a28b223b3b874357397ae706e6190acd9bf28cee99114d" +dependencies = [ + "bitflags", + "cssparser", + "derive_more", + "log", + "new_debug_unreachable", + "phf 0.13.1", + "phf_codegen", + "precomputed-hash", + "rustc-hash", + "servo_arc", + "smallvec", +] + [[package]] name = "semver" version = "1.0.28" @@ -3047,6 +3243,15 @@ dependencies = [ "serde", ] +[[package]] +name = "servo_arc" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "170fb83ab34de17dc69aa7c67482b22218ddb85da56546f9bd6b929e32a05930" +dependencies = [ + "stable_deref_trait", +] + [[package]] name = "sha1" version = "0.10.7" @@ -3386,6 +3591,30 @@ version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" +[[package]] +name = "string_cache" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a18596f8c785a729f2819c0f6a7eae6ebeebdfffbfe4214ae6b087f690e31901" +dependencies = [ + "new_debug_unreachable", + "parking_lot", + "phf_shared 0.13.1", + "precomputed-hash", +] + +[[package]] +name = "string_cache_codegen" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "585635e46db231059f76c5849798146164652513eb9e8ab2685939dd90f29b69" +dependencies = [ + "phf_generator 0.13.1", + "phf_shared 0.13.1", + "proc-macro2", + "quote", +] + [[package]] name = "stringprep" version = "0.1.5" @@ -3475,6 +3704,15 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "tendril" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5fed54709c5b3a53d09bb1c113ea4f5ceafd1e772ddcb0030a82e1d56c087b08" +dependencies = [ + "new_debug_unreachable", +] + [[package]] name = "thiserror" version = "2.0.20" @@ -3890,6 +4128,12 @@ version = "0.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7df058c713841ad818f1dc5d3fd88063241cc61f49f5fbea4b951e8cf5a8d71d" +[[package]] +name = "unicode-width" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254" + [[package]] name = "untrusted" version = "0.7.1" @@ -4073,6 +4317,18 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "web_atoms" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba8b815c1b593dc0baf78dd0f4fc8fdb2de53198fb1163738093e9a311c33fb3" +dependencies = [ + "phf 0.13.1", + "phf_codegen", + "string_cache", + "string_cache_codegen", +] + [[package]] name = "webpki-roots" version = "1.0.8" diff --git a/backend/apps/gateway/src/bootstrap.rs b/backend/apps/gateway/src/bootstrap.rs index 2ea606032..aaa97bc3e 100644 --- a/backend/apps/gateway/src/bootstrap.rs +++ b/backend/apps/gateway/src/bootstrap.rs @@ -79,6 +79,7 @@ pub async fn run() -> Result<(), BootstrapError> { vec![openai.admin_provider(), xai.admin_provider()], core.snapshot_control(), core.account_probe(), + host.client_distribution_resolver(), host.system_operations(), ) .await?; diff --git a/backend/crates/gateway-admin/src/lib.rs b/backend/crates/gateway-admin/src/lib.rs index d2d43fdd5..14d515427 100644 --- a/backend/crates/gateway-admin/src/lib.rs +++ b/backend/crates/gateway-admin/src/lib.rs @@ -22,19 +22,22 @@ mod use_case; pub use use_case::{ account_groups::AccountGroupService, accounts::AccountsService, auth::AuthService, - backup::BackupService, client_keys::ClientKeyService, observability::ObservabilityService, - openai::OpenAiService, settings::SettingsService, system::SystemService, xai::XaiService, + backup::BackupService, client_distribution::ClientDistributionService, + client_keys::ClientKeyService, observability::ObservabilityService, openai::OpenAiService, + settings::SettingsService, system::SystemService, xai::XaiService, }; use model::{AdminError, AdminErrorKind}; use ports::{ + client_distribution::ClientDistributionResolver, provider::{ProviderAdmin, ProviderAdminError, ProviderAdminErrorKind, ProviderAdminRegistry}, store::AdminStorePorts, system::SystemOperations, }; use use_case::{ account_groups::DefaultAccountGroupService, accounts::DefaultAccountsService, - auth::DefaultAuthService, backup::DefaultBackupService, client_keys::DefaultClientKeyService, + auth::DefaultAuthService, backup::DefaultBackupService, + client_distribution::DefaultClientDistributionService, client_keys::DefaultClientKeyService, observability::DefaultObservabilityService, openai::DefaultOpenAiService, settings::DefaultSettingsService, system::DefaultSystemService, xai::DefaultXaiService, }; @@ -149,6 +152,7 @@ pub struct AdminServices { accounts: Arc, account_groups: Arc, client_keys: Arc, + client_distribution: Arc, observability: Arc, settings: Arc, system: Arc, @@ -178,6 +182,11 @@ impl AdminServices { self.client_keys.as_ref() } + #[must_use] + pub fn client_distribution(&self) -> &dyn ClientDistributionService { + self.client_distribution.as_ref() + } + #[must_use] pub fn observability(&self) -> &dyn ObservabilityService { self.observability.as_ref() @@ -238,6 +247,7 @@ pub async fn initialize( providers: Vec>, snapshot: Arc, probe: Arc, + client_distribution: Arc, system: Arc, ) -> Result { config @@ -290,6 +300,7 @@ pub async fn initialize( store.client_keys(), snapshot.clone(), )), + client_distribution: Arc::new(DefaultClientDistributionService::new(client_distribution)), observability: Arc::new(DefaultObservabilityService::new( store.observability(), store.settings(), diff --git a/backend/crates/gateway-admin/src/model/client_distribution.rs b/backend/crates/gateway-admin/src/model/client_distribution.rs new file mode 100644 index 000000000..faf7545dd --- /dev/null +++ b/backend/crates/gateway-admin/src/model/client_distribution.rs @@ -0,0 +1,58 @@ +//! 管理设置页使用的 Codex 客户端下载信息。 + +use chrono::{DateTime, Utc}; + +/// Windows 安装包架构。 +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] +pub enum ClientArchitecture { + X64, + Arm64, +} + +impl ClientArchitecture { + #[must_use] + pub const fn as_str(self) -> &'static str { + match self { + Self::X64 => "x64", + Self::Arm64 => "arm64", + } + } +} + +/// 下载地址的可信来源。 +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ClientDownloadSource { + MicrosoftStore, + OfficialOpenAi, +} + +impl ClientDownloadSource { + #[must_use] + pub const fn as_str(self) -> &'static str { + match self { + Self::MicrosoftStore => "microsoft_store", + Self::OfficialOpenAi => "official_openai", + } + } +} + +/// 一个已经过 Host 校验、可以直接交给浏览器下载的安装包。 +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ClientDownloadPackage { + pub architecture: ClientArchitecture, + pub source: ClientDownloadSource, + pub version: Option, + pub file_name: String, + pub size_bytes: Option, + pub download_url: String, + pub expires_at: Option>, +} + +/// Codex Desktop Windows 下载解析结果。 +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct CodexDesktopWindowsDownloads { + pub resolved_at: DateTime, + pub cached: bool, + pub warning: Option, + pub packages: Vec, +} diff --git a/backend/crates/gateway-admin/src/model/mod.rs b/backend/crates/gateway-admin/src/model/mod.rs index 892908d73..c2fda018b 100644 --- a/backend/crates/gateway-admin/src/model/mod.rs +++ b/backend/crates/gateway-admin/src/model/mod.rs @@ -6,6 +6,7 @@ pub mod account_groups; pub mod accounts; pub mod auth; pub mod backup; +pub mod client_distribution; pub mod client_keys; pub mod observability; pub mod provider_credentials; diff --git a/backend/crates/gateway-admin/src/model/settings.rs b/backend/crates/gateway-admin/src/model/settings.rs index 3ac8b5741..eeaa27c9a 100644 --- a/backend/crates/gateway-admin/src/model/settings.rs +++ b/backend/crates/gateway-admin/src/model/settings.rs @@ -24,6 +24,8 @@ pub struct RuntimeSettings { pub max_concurrent_per_account: u32, pub request_interval_ms: u64, pub rotation_strategy: RotationStrategy, + pub min_codex_desktop_version: Option, + pub min_codex_cli_version: Option, pub usage_retention_days: u32, pub ops_event_retention_days: u32, pub audit_retention_days: u32, @@ -39,6 +41,8 @@ pub struct ReplaceRuntimeSettings { pub max_concurrent_per_account: u32, pub request_interval_ms: u64, pub rotation_strategy: RotationStrategy, + pub min_codex_desktop_version: Option, + pub min_codex_cli_version: Option, pub usage_retention_days: u32, pub ops_event_retention_days: u32, pub audit_retention_days: u32, diff --git a/backend/crates/gateway-admin/src/ports/client_distribution.rs b/backend/crates/gateway-admin/src/ports/client_distribution.rs new file mode 100644 index 000000000..a62de6e62 --- /dev/null +++ b/backend/crates/gateway-admin/src/ports/client_distribution.rs @@ -0,0 +1,10 @@ +//! 客户端安装包解析的外部能力端口。 + +use async_trait::async_trait; + +use crate::model::client_distribution::CodexDesktopWindowsDownloads; + +#[async_trait] +pub trait ClientDistributionResolver: Send + Sync { + async fn resolve_codex_desktop_windows(&self, refresh: bool) -> CodexDesktopWindowsDownloads; +} diff --git a/backend/crates/gateway-admin/src/ports/mod.rs b/backend/crates/gateway-admin/src/ports/mod.rs index 4169383ff..2b3101dde 100644 --- a/backend/crates/gateway-admin/src/ports/mod.rs +++ b/backend/crates/gateway-admin/src/ports/mod.rs @@ -1,6 +1,7 @@ //! 管理用例依赖的外部能力端口。 pub mod backup; +pub mod client_distribution; pub mod provider; pub mod store; pub mod system; diff --git a/backend/crates/gateway-admin/src/use_case/client_distribution.rs b/backend/crates/gateway-admin/src/use_case/client_distribution.rs new file mode 100644 index 000000000..40970d3ac --- /dev/null +++ b/backend/crates/gateway-admin/src/use_case/client_distribution.rs @@ -0,0 +1,33 @@ +//! Codex 客户端下载信息用例。 + +use std::sync::Arc; + +use async_trait::async_trait; + +use crate::{ + model::client_distribution::CodexDesktopWindowsDownloads, + ports::client_distribution::ClientDistributionResolver, +}; + +#[async_trait] +pub trait ClientDistributionService: Send + Sync { + async fn codex_desktop_windows(&self, refresh: bool) -> CodexDesktopWindowsDownloads; +} + +pub(crate) struct DefaultClientDistributionService { + resolver: Arc, +} + +impl DefaultClientDistributionService { + #[must_use] + pub(crate) const fn new(resolver: Arc) -> Self { + Self { resolver } + } +} + +#[async_trait] +impl ClientDistributionService for DefaultClientDistributionService { + async fn codex_desktop_windows(&self, refresh: bool) -> CodexDesktopWindowsDownloads { + self.resolver.resolve_codex_desktop_windows(refresh).await + } +} diff --git a/backend/crates/gateway-admin/src/use_case/mod.rs b/backend/crates/gateway-admin/src/use_case/mod.rs index 6eae1a6ea..56e00fb58 100644 --- a/backend/crates/gateway-admin/src/use_case/mod.rs +++ b/backend/crates/gateway-admin/src/use_case/mod.rs @@ -4,6 +4,7 @@ pub mod account_groups; pub mod accounts; pub mod auth; pub mod backup; +pub mod client_distribution; pub mod client_keys; pub mod observability; pub mod openai; diff --git a/backend/crates/gateway-admin/src/use_case/settings.rs b/backend/crates/gateway-admin/src/use_case/settings.rs index 970289e7b..5e62eaddd 100644 --- a/backend/crates/gateway-admin/src/use_case/settings.rs +++ b/backend/crates/gateway-admin/src/use_case/settings.rs @@ -3,6 +3,7 @@ use std::sync::Arc; use async_trait::async_trait; +use gateway_core::policy::CodexClientVersion; use gateway_core::routing::snapshot::SnapshotControl; use rand_core::{OsRng, RngCore as _}; @@ -119,6 +120,8 @@ fn validate_settings(command: &ReplaceRuntimeSettings) -> Result<(), AdminError> && command.usage_retention_days >= 31 && command.ops_event_retention_days > 0 && command.audit_retention_days > 0 + && valid_client_version(command.min_codex_desktop_version.as_deref()) + && valid_client_version(command.min_codex_cli_version.as_deref()) && i64::try_from(command.request_interval_ms).is_ok(); if valid { Ok(()) @@ -126,3 +129,7 @@ fn validate_settings(command: &ReplaceRuntimeSettings) -> Result<(), AdminError> Err(AdminError::invalid("运行时设置不满足约束")) } } + +fn valid_client_version(value: Option<&str>) -> bool { + value.is_none_or(|value| CodexClientVersion::parse(value).is_ok()) +} diff --git a/backend/crates/gateway-admin/tests/use_case/accounts.rs b/backend/crates/gateway-admin/tests/use_case/accounts.rs index bce93d14a..60a0d9582 100644 --- a/backend/crates/gateway-admin/tests/use_case/accounts.rs +++ b/backend/crates/gateway-admin/tests/use_case/accounts.rs @@ -798,6 +798,8 @@ impl SettingsStore for StaticSettingsStore { max_concurrent_per_account: 1, request_interval_ms: 0, rotation_strategy: RotationStrategy::Smart, + min_codex_desktop_version: None, + min_codex_cli_version: None, usage_retention_days: 30, ops_event_retention_days: 30, audit_retention_days: 30, diff --git a/backend/crates/gateway-admin/tests/use_case/mod.rs b/backend/crates/gateway-admin/tests/use_case/mod.rs index d6459e055..d148ff798 100644 --- a/backend/crates/gateway-admin/tests/use_case/mod.rs +++ b/backend/crates/gateway-admin/tests/use_case/mod.rs @@ -31,6 +31,7 @@ use gateway_admin::{ BatchUpdateAccounts, DeleteAccounts, UpdateAccount, }, auth::{AdminAuditEvent, AdminSession}, + client_distribution::CodexDesktopWindowsDownloads, client_keys::{ ClientKeyListQuery, ClientKeyPage, ClientKeyRecord, ClientKeySecret, DeleteClientKey, NewClientKey, SetClientKeyEnabled, UpdateClientKey, @@ -55,6 +56,7 @@ use gateway_admin::{ }, ports::{ backup::BackupStorePorts, + client_distribution::ClientDistributionResolver, provider::{ProviderAdmin, ProviderAdminError, ProviderAdminErrorKind}, store::{ AccountGroupStore, AccountRuntimeStore, AccountStore, AdminAccountStorePorts, @@ -205,6 +207,7 @@ impl AdminHarness { self.providers, Arc::new(NoopSnapshot), self.probe, + Arc::new(NoopClientDistribution), self.system, ) .await @@ -213,6 +216,20 @@ impl AdminHarness { } } +struct NoopClientDistribution; + +#[async_trait] +impl ClientDistributionResolver for NoopClientDistribution { + async fn resolve_codex_desktop_windows(&self, _: bool) -> CodexDesktopWindowsDownloads { + CodexDesktopWindowsDownloads { + resolved_at: Utc::now(), + cached: false, + warning: None, + packages: Vec::new(), + } + } +} + #[derive(Default)] struct BootstrapAuthStore { password_hash: Mutex>, diff --git a/backend/crates/gateway-admin/tests/use_case/observability.rs b/backend/crates/gateway-admin/tests/use_case/observability.rs index 8f5b4aff7..0b4ff2a50 100644 --- a/backend/crates/gateway-admin/tests/use_case/observability.rs +++ b/backend/crates/gateway-admin/tests/use_case/observability.rs @@ -721,6 +721,8 @@ impl SettingsStore for FixtureSettingsStore { max_concurrent_per_account: 1, request_interval_ms: 0, rotation_strategy: RotationStrategy::Smart, + min_codex_desktop_version: None, + min_codex_cli_version: None, usage_retention_days: 31, ops_event_retention_days: 30, audit_retention_days: 30, diff --git a/backend/crates/gateway-admin/tests/use_case/settings.rs b/backend/crates/gateway-admin/tests/use_case/settings.rs index 2a0f5aa66..ab401b437 100644 --- a/backend/crates/gateway-admin/tests/use_case/settings.rs +++ b/backend/crates/gateway-admin/tests/use_case/settings.rs @@ -67,6 +67,8 @@ async fn settings_should_reject_zero_refresh_margin_before_store_call() { max_concurrent_per_account: 1, request_interval_ms: 0, rotation_strategy: RotationStrategy::Smart, + min_codex_desktop_version: None, + min_codex_cli_version: None, usage_retention_days: 31, ops_event_retention_days: 30, audit_retention_days: 30, diff --git a/backend/crates/gateway-api/src/admin/settings.rs b/backend/crates/gateway-api/src/admin/settings.rs index eb4753030..b9f112633 100644 --- a/backend/crates/gateway-api/src/admin/settings.rs +++ b/backend/crates/gateway-api/src/admin/settings.rs @@ -10,14 +10,18 @@ use axum::{ routing::{get, post}, }; use chrono::{DateTime, Utc}; +use gateway_admin::model::client_distribution::{ + ClientDownloadPackage, CodexDesktopWindowsDownloads, +}; use gateway_admin::model::settings::{ ModelMappings as DomainModelMappings, ReplaceRuntimeSettings, RotationStrategy, RuntimeSettings, }; +use gateway_core::policy::CodexClientVersion; use gateway_core::routing::{PublicModelId, UpstreamModelId}; use serde::{Deserialize, Serialize}; use super::{ - AdminAuth, AdminEnvelope, AdminError, AdminJson, AdminResponse, AdminSessionState, + AdminAuth, AdminEnvelope, AdminError, AdminJson, AdminQuery, AdminResponse, AdminSessionState, WireValidationError, wire::map_admin_service_error, }; @@ -34,6 +38,8 @@ pub struct RuntimeSettingsView { pub max_concurrent_per_account: u64, pub request_interval_ms: u64, pub rotation_strategy: String, + pub min_codex_desktop_version: Option, + pub min_codex_cli_version: Option, pub usage_retention_days: u64, pub ops_event_retention_days: u64, pub audit_retention_days: u64, @@ -50,6 +56,8 @@ pub struct UpdateRuntimeSettingsRequest { pub max_concurrent_per_account: u64, pub request_interval_ms: u64, pub rotation_strategy: String, + pub min_codex_desktop_version: Option, + pub min_codex_cli_version: Option, pub usage_retention_days: u64, pub ops_event_retention_days: u64, pub audit_retention_days: u64, @@ -75,6 +83,14 @@ impl UpdateRuntimeSettingsRequest { if RotationStrategy::parse(&self.rotation_strategy).is_none() { return Err(WireValidationError::new("rotationStrategy")); } + validate_optional_client_version( + self.min_codex_desktop_version.as_deref(), + "minCodexDesktopVersion", + )?; + validate_optional_client_version( + self.min_codex_cli_version.as_deref(), + "minCodexCliVersion", + )?; Ok(()) } @@ -90,6 +106,8 @@ impl UpdateRuntimeSettingsRequest { request_interval_ms: self.request_interval_ms, rotation_strategy: RotationStrategy::parse(&self.rotation_strategy) .ok_or_else(|| WireValidationError::new("rotationStrategy"))?, + min_codex_desktop_version: self.min_codex_desktop_version, + min_codex_cli_version: self.min_codex_cli_version, usage_retention_days: u32::try_from(self.usage_retention_days) .map_err(|_| WireValidationError::new("settingsUsageRetentionOverflow"))?, ops_event_retention_days: u32::try_from(self.ops_event_retention_days) @@ -109,6 +127,8 @@ impl From for RuntimeSettingsView { max_concurrent_per_account: u64::from(settings.max_concurrent_per_account), request_interval_ms: settings.request_interval_ms, rotation_strategy: settings.rotation_strategy.as_str().to_owned(), + min_codex_desktop_version: settings.min_codex_desktop_version, + min_codex_cli_version: settings.min_codex_cli_version, usage_retention_days: u64::from(settings.usage_retention_days), ops_event_retention_days: u64::from(settings.ops_event_retention_days), audit_retention_days: u64::from(settings.audit_retention_days), @@ -145,6 +165,58 @@ pub struct DeletedAdminApiKey { pub message: &'static str, } +#[derive(Debug, Clone, Copy, Default, Deserialize)] +#[serde(default, deny_unknown_fields)] +struct ClientDownloadsQuery { + refresh: bool, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +#[serde(rename_all = "camelCase")] +struct ClientDownloadPackageView { + architecture: String, + source: String, + version: Option, + file_name: String, + size_bytes: Option, + download_url: String, + expires_at: Option>, +} + +impl From for ClientDownloadPackageView { + fn from(package: ClientDownloadPackage) -> Self { + Self { + architecture: package.architecture.as_str().to_owned(), + source: package.source.as_str().to_owned(), + version: package.version, + file_name: package.file_name, + size_bytes: package.size_bytes, + download_url: package.download_url, + expires_at: package.expires_at, + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +#[serde(rename_all = "camelCase")] +struct CodexDesktopWindowsDownloadsView { + resolved_at: DateTime, + cached: bool, + warning: Option, + packages: Vec, +} + +impl From for CodexDesktopWindowsDownloadsView { + fn from(downloads: CodexDesktopWindowsDownloads) -> Self { + Self { + resolved_at: downloads.resolved_at, + cached: downloads.cached, + warning: downloads.warning, + packages: downloads.packages.into_iter().map(Into::into).collect(), + } + } +} + impl Default for DeletedAdminApiKey { fn default() -> Self { Self { @@ -161,6 +233,10 @@ where Router::new() .route("/api/admin/settings", get(settings::)) .route("/api/admin/settings/update", post(update_settings::)) + .route( + "/api/admin/settings/client-downloads/codex-desktop/windows", + get(codex_desktop_windows_downloads::), + ) .route( "/api/admin/settings/admin-api-key", get(admin_api_key_status::), @@ -175,6 +251,25 @@ where ) } +async fn codex_desktop_windows_downloads( + _auth: AdminAuth, + State(state): State, + AdminQuery(query): AdminQuery, +) -> impl IntoResponse +where + S: AdminSessionState + Send + Sync, +{ + let downloads = state + .admin_services() + .client_distribution() + .codex_desktop_windows(query.refresh) + .await; + AdminResponse::new( + StatusCode::OK, + AdminEnvelope::ok(CodexDesktopWindowsDownloadsView::from(downloads)), + ) +} + async fn settings( _auth: AdminAuth, State(state): State, @@ -322,6 +417,16 @@ fn valid_model_name(value: &str, max_len: usize) -> bool { && !value.bytes().any(|byte| byte.is_ascii_control()) } +fn validate_optional_client_version( + value: Option<&str>, + field: &'static str, +) -> Result<(), WireValidationError> { + if value.is_some_and(|value| CodexClientVersion::parse(value).is_err()) { + return Err(WireValidationError::new(field)); + } + Ok(()) +} + fn map_wire_error(error: WireValidationError) -> AdminError { let message = match error.field() { "settingsRefreshConcurrencyOverflow" => "refreshConcurrency 不合法".to_owned(), @@ -329,6 +434,8 @@ fn map_wire_error(error: WireValidationError) -> AdminError { "settingsUsageRetentionOverflow" => "usageRetentionDays 不合法".to_owned(), "settingsOpsRetentionOverflow" => "opsEventRetentionDays 不合法".to_owned(), "settingsAuditRetentionOverflow" => "auditRetentionDays 不合法".to_owned(), + "minCodexDesktopVersion" => "Codex Desktop 最低版本格式不合法".to_owned(), + "minCodexCliVersion" => "Codex CLI 最低版本格式不合法".to_owned(), field => format!("{field} 字段不合法"), }; AdminError::bad_request(message) diff --git a/backend/crates/gateway-api/src/openai/auth.rs b/backend/crates/gateway-api/src/openai/auth.rs index af2b57c55..7ea21cb20 100644 --- a/backend/crates/gateway-api/src/openai/auth.rs +++ b/backend/crates/gateway-api/src/openai/auth.rs @@ -5,9 +5,13 @@ use axum::{ response::{IntoResponse, Response}, }; use gateway_core::engine::execution::AuthenticatedClient; +use gateway_core::policy::{ClientVersionRejection, CodexClientKind, CodexClientVersion}; use super::{ - error::{missing_client_api_key_response, runtime_unavailable_response}, + error::{ + client_version_rejection_response, missing_client_api_key_response, + runtime_unavailable_response, + }, service::OpenAiService, }; @@ -26,6 +30,44 @@ pub enum ClientApiKeyAuthError { RuntimeUnavailable, } +/// Client API Key 或最低版本准入失败。 +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum ClientAccessError { + Authentication(ClientApiKeyAuthError), + Version(ClientVersionRejection), +} + +impl From for ClientAccessError { + fn from(error: ClientApiKeyAuthError) -> Self { + Self::Authentication(error) + } +} + +impl From for ClientAccessError { + fn from(error: ClientVersionRejection) -> Self { + Self::Version(error) + } +} + +/// 从有界请求头中识别出的客户端及其可选合法版本。 +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct IdentifiedCodexClient { + kind: CodexClientKind, + version: Option, +} + +impl IdentifiedCodexClient { + #[must_use] + pub const fn kind(&self) -> CodexClientKind { + self.kind + } + + #[must_use] + pub const fn version(&self) -> Option<&CodexClientVersion> { + self.version.as_ref() + } +} + impl ClientApiKeyAuthError { /// 返回可用于日志和指标的稳定失败原因。 #[must_use] @@ -67,19 +109,121 @@ pub fn bearer_client_api_key(headers: &HeaderMap) -> Result<&str, ClientApiKeyAu pub(crate) fn authenticate_client( service: &OpenAiService, headers: &HeaderMap, -) -> Result { - bearer_client_api_key(headers).and_then(|key| service.authenticate(key)) +) -> Result { + let key = bearer_client_api_key(headers)?; + let client = service.authenticate(key)?; + if let Some(identified) = identify_codex_client(headers) { + client + .snapshot() + .min_codex_client_versions() + .enforce(identified.kind(), identified.version())?; + } + Ok(client) } -pub(crate) fn authentication_error_response(error: ClientApiKeyAuthError) -> Response { - log_client_api_key_auth_failure(error); - if error == ClientApiKeyAuthError::RuntimeUnavailable { - runtime_unavailable_response().into_response() - } else { - missing_client_api_key_response().into_response() +pub(crate) fn client_access_error_response(error: ClientAccessError) -> Response { + match error { + ClientAccessError::Authentication(error) => { + log_client_api_key_auth_failure(error); + if error == ClientApiKeyAuthError::RuntimeUnavailable { + runtime_unavailable_response().into_response() + } else { + missing_client_api_key_response().into_response() + } + } + ClientAccessError::Version(error) => { + tracing::info!( + client = error.kind().as_str(), + current_version = error.current().map(ToString::to_string), + min_version = %error.min(), + "Codex client version requirement rejected request" + ); + client_version_rejection_response(&error).into_response() + } } } +/// Desktop 优先于其内嵌的 CLI/Core 标记;未知客户端保持兼容并返回 `None`。 +#[must_use] +pub fn identify_codex_client(headers: &HeaderMap) -> Option { + const MAXIMUM_HEADER_LENGTH: usize = 4096; + + let originator = bounded_ascii_header(headers, "originator", MAXIMUM_HEADER_LENGTH); + let user_agent = bounded_ascii_header(headers, "user-agent", MAXIMUM_HEADER_LENGTH); + let is_desktop = originator.is_some_and(|value| value.eq_ignore_ascii_case("Codex Desktop")) + || user_agent.is_some_and(|value| contains_ascii_case_insensitive(value, "Codex Desktop")); + if is_desktop { + let explicit_version = bounded_ascii_header(headers, "version", MAXIMUM_HEADER_LENGTH); + let version = match explicit_version { + Some(value) => CodexClientVersion::parse(value).ok(), + None => user_agent + .and_then(desktop_version_from_user_agent) + .and_then(|value| CodexClientVersion::parse(value).ok()), + }; + return Some(IdentifiedCodexClient { + kind: CodexClientKind::Desktop, + version, + }); + } + + let user_agent = user_agent?; + for product in user_agent.split(|character: char| { + character.is_ascii_whitespace() || matches!(character, '(' | ')' | ';' | ',' | '[' | ']') + }) { + for prefix in ["codex_cli_rs", "codex-cli"] { + if product.eq_ignore_ascii_case(prefix) { + return Some(IdentifiedCodexClient { + kind: CodexClientKind::Cli, + version: None, + }); + } + let Some((name, version)) = product.split_once('/') else { + continue; + }; + if name.eq_ignore_ascii_case(prefix) { + return Some(IdentifiedCodexClient { + kind: CodexClientKind::Cli, + version: CodexClientVersion::parse(version).ok(), + }); + } + } + } + None +} + +fn bounded_ascii_header<'a>( + headers: &'a HeaderMap, + name: &'static str, + maximum_length: usize, +) -> Option<&'a str> { + let value = headers.get(name)?.to_str().ok()?; + if !value.is_ascii() { + return None; + } + Some(&value[..value.len().min(maximum_length)]) +} + +fn desktop_version_from_user_agent(user_agent: &str) -> Option<&str> { + let marker = "Codex Desktop;"; + let start = find_ascii_case_insensitive(user_agent, marker)? + marker.len(); + let version = user_agent[start..] + .trim_start() + .split([')', ' ', ';', ',']) + .next()?; + (!version.is_empty()).then_some(version) +} + +fn contains_ascii_case_insensitive(haystack: &str, needle: &str) -> bool { + find_ascii_case_insensitive(haystack, needle).is_some() +} + +fn find_ascii_case_insensitive(haystack: &str, needle: &str) -> Option { + haystack + .as_bytes() + .windows(needle.len()) + .position(|window| window.eq_ignore_ascii_case(needle.as_bytes())) +} + fn log_client_api_key_auth_failure(error: ClientApiKeyAuthError) { match error { ClientApiKeyAuthError::RuntimeUnavailable => { diff --git a/backend/crates/gateway-api/src/openai/error.rs b/backend/crates/gateway-api/src/openai/error.rs index 16f8144b2..565efeda8 100644 --- a/backend/crates/gateway-api/src/openai/error.rs +++ b/backend/crates/gateway-api/src/openai/error.rs @@ -9,6 +9,7 @@ use axum::{ use gateway_core::{ engine::EngineError, error::{GatewayError, GatewayErrorKind}, + policy::{ClientVersionRejection, CodexClientKind}, }; use serde_json::{Value, json}; @@ -58,6 +59,47 @@ pub fn runtime_unavailable_response() -> (StatusCode, Json) { ) } +/// 已识别 Codex 客户端不满足最低版本要求。 +pub fn client_version_rejection_response( + rejection: &ClientVersionRejection, +) -> (StatusCode, Json) { + let client_label = match rejection.kind() { + CodexClientKind::Desktop => "Codex Desktop", + CodexClientKind::Cli => "Codex CLI", + }; + let (message, code, current_version) = match rejection.current() { + Some(current) => ( + format!( + "{client_label} {current} is below the minimum required version {}. Upgrade {client_label} and retry.", + rejection.min() + ), + "client_version_too_old", + Some(current.to_string()), + ), + None => ( + format!( + "A valid {client_label} version is required. Upgrade to version {} or newer and retry.", + rejection.min() + ), + "client_version_unavailable", + None, + ), + }; + ( + StatusCode::UPGRADE_REQUIRED, + Json(json!({ + "error": { + "message": message, + "type": "invalid_request_error", + "code": code, + "client": rejection.kind().as_str(), + "current_version": current_version, + "min_version": rejection.min().to_string() + } + })), + ) +} + /// 对外模型不存在。 pub fn model_not_found_response() -> (StatusCode, Json) { openai_error_response( diff --git a/backend/crates/gateway-api/src/openai/images/http.rs b/backend/crates/gateway-api/src/openai/images/http.rs index a023de02d..ab79beda7 100644 --- a/backend/crates/gateway-api/src/openai/images/http.rs +++ b/backend/crates/gateway-api/src/openai/images/http.rs @@ -16,7 +16,7 @@ use serde_json::{Map, Value}; use crate::ApiState; use crate::openai::{ - auth::{authenticate_client, authentication_error_response}, + auth::{authenticate_client, client_access_error_response}, error::{engine_error_response, gateway_error_response, protocol_error_response}, responses::{PendingExecution, ProtocolError, ProtocolErrorBody, request_client_context}, }; @@ -71,7 +71,7 @@ async fn handle_image_request( let service = state.openai(); let client = match authenticate_client(service, &headers) { Ok(client) => client, - Err(error) => return authentication_error_response(error), + Err(error) => return client_access_error_response(error), }; let (client_ip, user_agent) = request_client_context( &headers, diff --git a/backend/crates/gateway-api/src/openai/models.rs b/backend/crates/gateway-api/src/openai/models.rs index a30f22b5e..cfd4bb7c0 100644 --- a/backend/crates/gateway-api/src/openai/models.rs +++ b/backend/crates/gateway-api/src/openai/models.rs @@ -13,7 +13,7 @@ use serde_json::{Value, json}; use crate::ApiState; use super::{ - auth::{authenticate_client, authentication_error_response}, + auth::{authenticate_client, client_access_error_response}, error::model_not_found_response, }; @@ -34,7 +34,7 @@ pub(crate) async fn models( let service = state.openai(); let client = match authenticate_client(service, &headers) { Ok(client) => client, - Err(error) => return authentication_error_response(error), + Err(error) => return client_access_error_response(error), }; if query @@ -81,7 +81,7 @@ pub(crate) async fn model_detail( let service = state.openai(); let client = match authenticate_client(service, &headers) { Ok(client) => client, - Err(error) => return authentication_error_response(error), + Err(error) => return client_access_error_response(error), }; let Ok(public_model) = PublicModelId::new(model_id) else { return model_not_found_response().into_response(); @@ -105,7 +105,7 @@ pub(crate) async fn model_catalog(State(state): State, headers: Header let service = state.openai(); let client = match authenticate_client(service, &headers) { Ok(client) => client, - Err(error) => return authentication_error_response(error), + Err(error) => return client_access_error_response(error), }; let models = service .public_models(&client) @@ -125,7 +125,7 @@ pub(crate) async fn model_info( let service = state.openai(); let client = match authenticate_client(service, &headers) { Ok(client) => client, - Err(error) => return authentication_error_response(error), + Err(error) => return client_access_error_response(error), }; let Ok(public_model) = PublicModelId::new(model_id) else { return model_not_found_response().into_response(); diff --git a/backend/crates/gateway-api/src/openai/responses/http.rs b/backend/crates/gateway-api/src/openai/responses/http.rs index 652732509..f5990b5d5 100644 --- a/backend/crates/gateway-api/src/openai/responses/http.rs +++ b/backend/crates/gateway-api/src/openai/responses/http.rs @@ -23,7 +23,7 @@ use gateway_protocol::openai::sse::{DONE_SSE_FRAME, response_failed_sse_event_wi use crate::ApiState; use crate::openai::{ - auth::{authenticate_client, authentication_error_response}, + auth::{authenticate_client, client_access_error_response}, error::{ engine_error_response, gateway_error_contract, gateway_error_from_engine, gateway_error_response, protocol_error_response, runtime_unavailable_response, @@ -65,7 +65,7 @@ async fn handle_responses( let service = state.openai(); let client = match authenticate_client(service, &headers) { Ok(client) => client, - Err(error) => return authentication_error_response(error), + Err(error) => return client_access_error_response(error), }; let decoded = match if review { decode_review_request_with_headers(&body, &headers) diff --git a/backend/crates/gateway-api/src/openai/responses/mod.rs b/backend/crates/gateway-api/src/openai/responses/mod.rs index f4a7f63af..ba2223419 100644 --- a/backend/crates/gateway-api/src/openai/responses/mod.rs +++ b/backend/crates/gateway-api/src/openai/responses/mod.rs @@ -4,7 +4,7 @@ mod error; mod http; mod request; mod response; -mod websocket; +pub mod websocket; pub use error::{ProtocolError, ProtocolErrorBody, RequestDecodeError, ResponseEncodeError}; pub(super) use http::{PendingExecution, request_client_context}; diff --git a/backend/crates/gateway-api/src/openai/responses/websocket/connection.rs b/backend/crates/gateway-api/src/openai/responses/websocket/connection.rs index ece0f2206..9c557cab4 100644 --- a/backend/crates/gateway-api/src/openai/responses/websocket/connection.rs +++ b/backend/crates/gateway-api/src/openai/responses/websocket/connection.rs @@ -24,21 +24,23 @@ const INBOUND_EVENT_BUFFER: usize = 32; const DOWNSTREAM_WRITE_TIMEOUT: Duration = Duration::from_secs(5 * 60); const CONNECTION_MAX_AGE: Duration = Duration::from_secs(60 * 60); +/// WebSocket pump 的写入与生命周期预算。 #[derive(Clone, Copy)] -pub(super) struct ConnectionConfig { - pub(super) write_timeout: Duration, - pub(super) max_age: Duration, +pub struct ConnectionConfig { + write_timeout: Duration, + max_age: Duration, } impl ConnectionConfig { - pub(super) const PRODUCTION: Self = Self { + /// 生产环境固定预算。 + pub const PRODUCTION: Self = Self { write_timeout: DOWNSTREAM_WRITE_TIMEOUT, max_age: CONNECTION_MAX_AGE, }; } /// 业务层可观察的客户端输入;Ping/Pong 始终由 pump 消费。 -pub(super) enum ConnectionEvent { +pub enum ConnectionEvent { Text(String), Binary, Expired, @@ -47,7 +49,7 @@ pub(super) enum ConnectionEvent { /// 下游写入阶段;名称刻意使用 write,而不是暗示客户端已消费的 delivery。 #[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub(super) enum FramePhase { +pub enum FramePhase { Metadata, First, Data, @@ -85,21 +87,26 @@ impl FramePhase { } } +/// 一次下游写入的请求归属与协议阶段。 #[derive(Clone)] -pub(super) struct WriteContext { +pub struct WriteContext { request_id: Option>, phase: FramePhase, } impl WriteContext { - pub(super) fn request(request_id: &Arc, phase: FramePhase) -> Self { + /// 创建归属于某个请求的写入上下文。 + #[must_use] + pub fn request(request_id: &Arc, phase: FramePhase) -> Self { Self { request_id: Some(Arc::clone(request_id)), phase, } } - pub(super) const fn connection(phase: FramePhase) -> Self { + /// 创建连接级写入上下文。 + #[must_use] + pub const fn connection(phase: FramePhase) -> Self { Self { request_id: None, phase, @@ -107,8 +114,9 @@ impl WriteContext { } } +/// WebSocket pump 停止的稳定原因。 #[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub(super) enum PumpExitReason { +pub enum PumpExitReason { ClientClose, PeerEof, ReadError, @@ -140,8 +148,9 @@ impl PumpExitReason { } } +/// 下游 WebSocket 写入失败。 #[derive(Debug, Error)] -pub(super) enum ConnectionWriteError { +pub enum ConnectionWriteError { #[error("downstream WebSocket pump is closed")] Closed, #[error("downstream WebSocket write timed out after {timeout:?}")] @@ -179,7 +188,8 @@ impl ConnectionStats { } } -pub(super) struct ResponsesWebSocketConnection { +/// 协调层持有的单 owner WebSocket 连接句柄。 +pub struct ResponsesWebSocketConnection { connection_id: Arc, opened_at: Instant, expired: Arc, @@ -209,7 +219,9 @@ impl ResponsesWebSocketConnection { &self.connection_id } - pub(super) fn is_expired(&self) -> bool { + /// 返回连接是否已经达到生命周期上限。 + #[must_use] + pub fn is_expired(&self) -> bool { self.expired.load(Ordering::Acquire) } @@ -217,7 +229,8 @@ impl ResponsesWebSocketConnection { self.opened_at.elapsed() } - pub(super) async fn next_event(&mut self) -> Option { + /// 等待下一个需要业务层处理的客户端事件。 + pub async fn next_event(&mut self) -> Option { let event = self.incoming.recv().await; if let Some(ConnectionEvent::Exited(reason)) = event.as_ref() { self.exit_reason.get_or_insert(*reason); @@ -227,7 +240,12 @@ impl ResponsesWebSocketConnection { event } - pub(super) async fn send_text( + /// 串行写入文本帧,并等待 pump 确认 transport 写入结果。 + /// + /// # Errors + /// + /// pump 已关闭、写入超时或 transport 失败时返回稳定错误。 + pub async fn send_text( &mut self, payload: String, context: WriteContext, @@ -391,7 +409,8 @@ impl Drop for ResponsesWebSocketConnection { } } -pub(super) fn spawn_connection( +/// 为一个 socket 启动单 owner pump,并返回协调层句柄。 +pub fn spawn_connection( socket: S, connection_id: Arc, cancellation: CancellationToken, diff --git a/backend/crates/gateway-api/src/openai/responses/websocket/mod.rs b/backend/crates/gateway-api/src/openai/responses/websocket/mod.rs index 9d0750338..a436cb75c 100644 --- a/backend/crates/gateway-api/src/openai/responses/websocket/mod.rs +++ b/backend/crates/gateway-api/src/openai/responses/websocket/mod.rs @@ -1,6 +1,6 @@ //! OpenAI Responses WebSocket 的升级、连接与串行 session 编排。 -mod connection; +pub mod connection; mod forward; mod protocol; @@ -26,7 +26,7 @@ use gateway_core::{ use crate::{ ApiState, openai::{ - auth::{authenticate_client, authentication_error_response}, + auth::{authenticate_client, client_access_error_response}, error::runtime_unavailable_response, service::OpenAiService, }, @@ -54,7 +54,7 @@ pub(crate) async fn responses_websocket( let service = state.openai().clone(); let client = match authenticate_client(&service, &headers) { Ok(client) => client, - Err(error) => return authentication_error_response(error), + Err(error) => return client_access_error_response(error), }; let (client_ip, user_agent) = request_client_context( &headers, diff --git a/backend/crates/gateway-api/tests/admin/mod.rs b/backend/crates/gateway-api/tests/admin/mod.rs index 9779c3312..a14a0c969 100644 --- a/backend/crates/gateway-api/tests/admin/mod.rs +++ b/backend/crates/gateway-api/tests/admin/mod.rs @@ -25,6 +25,10 @@ use gateway_admin::{ AccountsUpdateResult, BatchUpdateAccounts, DeleteAccounts, UpdateAccount, }, auth::{AdminAuditEvent, AdminSession}, + client_distribution::{ + ClientArchitecture, ClientDownloadPackage, ClientDownloadSource, + CodexDesktopWindowsDownloads, + }, client_keys::{ ClientKeyListQuery, ClientKeyPage, ClientKeyRecord, ClientKeySecret, DeleteClientKey, NewClientKey, SetClientKeyEnabled, UpdateClientKey, @@ -50,6 +54,7 @@ use gateway_admin::{ system::{SystemOperationAccepted, SystemUpdateDetail, SystemUpdateStatus, SystemVersion}, }, ports::{ + client_distribution::ClientDistributionResolver, provider::{ProviderAdmin, ProviderAdminError, ProviderAdminErrorKind}, store::{ AccountGroupStore, AccountRuntimeStore, AccountStore, AdminAccountStorePorts, @@ -143,6 +148,7 @@ impl AdminTestFixture { providers, Arc::new(NoopSnapshot), Arc::new(NoopProbe), + Arc::new(StaticClientDistribution), system, ) .await @@ -165,6 +171,30 @@ impl AdminTestFixture { } } +struct StaticClientDistribution; + +#[async_trait] +impl ClientDistributionResolver for StaticClientDistribution { + async fn resolve_codex_desktop_windows(&self, _: bool) -> CodexDesktopWindowsDownloads { + CodexDesktopWindowsDownloads { + resolved_at: Utc::now(), + cached: false, + warning: None, + packages: vec![ClientDownloadPackage { + architecture: ClientArchitecture::X64, + source: ClientDownloadSource::MicrosoftStore, + version: Some("26.825.6671.0".to_owned()), + file_name: "OpenAI.Codex_26.825.6671.0_x64__2p2nqsd0c76g0.msix".to_owned(), + size_bytes: Some(744_250_000), + download_url: + "https://dl.delivery.mp.microsoft.com/filestreamingservice/files/test" + .to_owned(), + expires_at: Some(Utc::now() + Duration::hours(1)), + }], + } + } +} + #[derive(Clone)] pub(super) struct AdminTestState(AdminServices); @@ -319,6 +349,8 @@ impl SettingsStore for MemorySettingsStore { max_concurrent_per_account: command.max_concurrent_per_account, request_interval_ms: command.request_interval_ms, rotation_strategy: command.rotation_strategy, + min_codex_desktop_version: command.min_codex_desktop_version, + min_codex_cli_version: command.min_codex_cli_version, usage_retention_days: command.usage_retention_days, ops_event_retention_days: command.ops_event_retention_days, audit_retention_days: command.audit_retention_days, @@ -1073,6 +1105,8 @@ fn test_runtime_settings() -> RuntimeSettings { max_concurrent_per_account: 3, request_interval_ms: 50, rotation_strategy: RotationStrategy::Smart, + min_codex_desktop_version: None, + min_codex_cli_version: None, usage_retention_days: 31, ops_event_retention_days: 30, audit_retention_days: 90, diff --git a/backend/crates/gateway-api/tests/admin/settings.rs b/backend/crates/gateway-api/tests/admin/settings.rs index f5d76884a..ea2f0953c 100644 --- a/backend/crates/gateway-api/tests/admin/settings.rs +++ b/backend/crates/gateway-api/tests/admin/settings.rs @@ -46,6 +46,8 @@ fn update_body() -> Value { "maxConcurrentPerAccount": 5, "requestIntervalMs": 25, "rotationStrategy": "round_robin", + "minCodexDesktopVersion": "26.825.6671", + "minCodexCliVersion": "0.40.0", "usageRetentionDays": 32, "opsEventRetentionDays": 31, "auditRetentionDays": 91 @@ -62,6 +64,19 @@ fn settings_request_should_reject_unknown_rotation_strategy() { assert_eq!(request.validate().unwrap_err().field(), "rotationStrategy"); } +#[test] +fn settings_request_should_reject_non_semver_client_min() { + let mut body = update_body(); + body["minCodexCliVersion"] = json!("v0.40.0"); + let request: UpdateRuntimeSettingsRequest = + serde_json::from_value(body).expect("decode settings"); + + assert_eq!( + request.validate().unwrap_err().field(), + "minCodexCliVersion" + ); +} + #[test] fn settings_response_should_cover_the_full_runtime_settings_contract() { use std::collections::BTreeMap; @@ -90,6 +105,8 @@ fn settings_response_should_cover_the_full_runtime_settings_contract() { max_concurrent_per_account: 5, request_interval_ms: 25, rotation_strategy: RotationStrategy::RoundRobin, + min_codex_desktop_version: Some("26.825.6671".to_owned()), + min_codex_cli_version: Some("0.40.0".to_owned()), usage_retention_days: 32, ops_event_retention_days: 31, audit_retention_days: 91, @@ -112,6 +129,8 @@ fn settings_response_should_cover_the_full_runtime_settings_contract() { "maxConcurrentPerAccount": 5, "requestIntervalMs": 25, "rotationStrategy": "round_robin", + "minCodexDesktopVersion": "26.825.6671", + "minCodexCliVersion": "0.40.0", "usageRetentionDays": 32, "opsEventRetentionDays": 31, "auditRetentionDays": 91, @@ -159,6 +178,8 @@ fn settings_request_and_response_fields_should_stay_in_lockstep() { request_interval_ms: request.request_interval_ms, rotation_strategy: RotationStrategy::parse(&request.rotation_strategy) .expect("fixture rotation strategy"), + min_codex_desktop_version: request.min_codex_desktop_version, + min_codex_cli_version: request.min_codex_cli_version, usage_retention_days: u32::try_from(request.usage_retention_days).expect("u32"), ops_event_retention_days: u32::try_from(request.ops_event_retention_days).expect("u32"), audit_retention_days: u32::try_from(request.audit_retention_days).expect("u32"), @@ -234,6 +255,31 @@ async fn settings_post_should_replace_global_model_mappings() { assert_eq!(data["modelMappings"]["grok-latest"], "grok-4.5"); } +#[tokio::test] +async fn client_downloads_should_return_validated_direct_links() { + let fixture = AdminTestFixture::new().await; + fixture.auth.insert_session("valid-session"); + let response = app(fixture.state()) + .oneshot(request( + Method::GET, + "/api/admin/settings/client-downloads/codex-desktop/windows?refresh=true", + None, + )) + .await + .expect("client downloads response"); + + assert_eq!(response.status(), StatusCode::OK); + let data = response_json(response).await["data"].clone(); + assert_eq!(data["packages"][0]["architecture"], "x64"); + assert_eq!(data["packages"][0]["source"], "microsoft_store"); + assert_eq!(data["packages"][0]["version"], "26.825.6671.0"); + assert!( + data["packages"][0]["downloadUrl"] + .as_str() + .is_some_and(|url| url.starts_with("https://dl.delivery.mp.microsoft.com/")) + ); +} + #[test] fn settings_request_should_reject_invalid_model_mapping_name() { let mut body = update_body(); diff --git a/backend/crates/gateway-api/tests/openai/auth.rs b/backend/crates/gateway-api/tests/openai/auth.rs index 3782df87b..131fb500e 100644 --- a/backend/crates/gateway-api/tests/openai/auth.rs +++ b/backend/crates/gateway-api/tests/openai/auth.rs @@ -1,6 +1,9 @@ use axum::http::{HeaderMap, HeaderValue, header::AUTHORIZATION}; -use gateway_api::openai::auth::{ClientApiKeyAuthError, bearer_client_api_key}; +use gateway_api::openai::auth::{ + ClientApiKeyAuthError, bearer_client_api_key, identify_codex_client, +}; +use gateway_core::policy::CodexClientKind; #[test] fn bearer_client_api_key_should_reject_missing_authorization() { @@ -88,3 +91,48 @@ fn client_auth_failure_reasons_should_be_stable_and_secret_free() { ] ); } + +#[test] +fn desktop_headers_should_take_precedence_over_embedded_cli_marker() { + let mut headers = HeaderMap::new(); + headers.insert("originator", HeaderValue::from_static("Codex Desktop")); + headers.insert("version", HeaderValue::from_static("26.825.6671")); + headers.insert( + "user-agent", + HeaderValue::from_static("codex_cli_rs/0.39.0 terminal (Codex Desktop; 26.1.0)"), + ); + + let identified = identify_codex_client(&headers).expect("recognized desktop"); + + assert_eq!(identified.kind(), CodexClientKind::Desktop); + assert_eq!( + identified.version().map(ToString::to_string).as_deref(), + Some("26.825.6671") + ); +} + +#[test] +fn cli_user_agent_should_expose_semver_or_recognized_missing_version() { + let mut headers = HeaderMap::new(); + headers.insert( + "user-agent", + HeaderValue::from_static("codex_cli_rs/0.39.0 (Linux; x86_64)"), + ); + let identified = identify_codex_client(&headers).expect("recognized CLI"); + assert_eq!(identified.kind(), CodexClientKind::Cli); + assert_eq!( + identified.version().map(ToString::to_string).as_deref(), + Some("0.39.0") + ); + + headers.insert( + "user-agent", + HeaderValue::from_static("codex-cli/not-a-version"), + ); + assert!( + identify_codex_client(&headers) + .expect("recognized invalid CLI") + .version() + .is_none() + ); +} diff --git a/backend/crates/gateway-api/tests/openai/mod.rs b/backend/crates/gateway-api/tests/openai/mod.rs index 184843a07..d5c48ca99 100644 --- a/backend/crates/gateway-api/tests/openai/mod.rs +++ b/backend/crates/gateway-api/tests/openai/mod.rs @@ -34,7 +34,10 @@ use gateway_core::engine::{ use gateway_core::error::StoreError; use gateway_core::health::{WorkerHealthSnapshot, WorkerHealthSource}; use gateway_core::lifecycle::{ConnectionDraining, ConnectionGuard, ConnectionLifecycle}; -use gateway_core::policy::{ClientApiKeyId, ClientPolicy, PlaintextClientApiKey, RateLimits}; +use gateway_core::policy::{ + ClientApiKeyId, ClientPolicy, CodexClientMinVersions, CodexClientVersion, + PlaintextClientApiKey, RateLimits, +}; use gateway_core::routing::snapshot::RuntimeSnapshotHandle; use gateway_core::routing::{ ClientRoutingScope, ConfigRevision, FrozenAccountScope, ModelCapabilities, ProviderKind, @@ -109,6 +112,30 @@ pub(super) fn authenticated_client_for_provider( .expect("authenticated client") } +pub(super) fn authenticated_client_with_min_versions( + plaintext: &str, + desktop: Option<&str>, + cli: Option<&str>, +) -> AuthenticatedClient { + let snapshot = + snapshot(plaintext, "openai").with_min_codex_client_versions(CodexClientMinVersions::new( + desktop.map(|version| CodexClientVersion::parse(version).expect("Desktop min version")), + cli.map(|version| CodexClientVersion::parse(version).expect("CLI min version")), + )); + let source = DefaultExecutionService::new( + RuntimeSnapshotHandle::new(snapshot), + Arc::new(UnusedExecutionStore), + ProviderRegistry::default(), + Arc::new(UnusedAdmissions), + Arc::new(UnusedCircuits), + Arc::new(UnusedContinuation), + Arc::new(IgnoredClientApiKeyUsage), + ); + source + .authenticate(plaintext) + .expect("authenticated client") +} + struct IgnoredClientApiKeyUsage; impl ClientApiKeyUsageSink for IgnoredClientApiKeyUsage { diff --git a/backend/crates/gateway-api/tests/openai/models.rs b/backend/crates/gateway-api/tests/openai/models.rs index 2cbfe51ba..9ff9ad881 100644 --- a/backend/crates/gateway-api/tests/openai/models.rs +++ b/backend/crates/gateway-api/tests/openai/models.rs @@ -15,7 +15,7 @@ use gateway_core::routing::{ }; use tower::ServiceExt; -use super::{api_router, authenticated_client}; +use super::{api_router, authenticated_client, authenticated_client_with_min_versions}; pub(super) struct ModelsExecution { client: AuthenticatedClient, @@ -36,6 +36,24 @@ impl ModelsExecution { profiles: true, }) } + + fn with_cli_min() -> Arc { + Arc::new(Self { + client: authenticated_client_with_min_versions("sk_models_test", None, Some("0.40.0")), + profiles: false, + }) + } + + fn with_desktop_min() -> Arc { + Arc::new(Self { + client: authenticated_client_with_min_versions( + "sk_models_test", + Some("26.825.51511"), + None, + ), + profiles: false, + }) + } } impl ExecutionService for ModelsExecution { @@ -144,6 +162,111 @@ async fn models_should_encode_the_service_visible_catalog() { ); } +#[tokio::test] +async fn models_should_reject_recognized_cli_below_configured_min() { + let request = Request::get("/v1/models") + .header(AUTHORIZATION, "Bearer sk_models_test") + .header("user-agent", "codex_cli_rs/0.39.0 (Linux; x86_64)") + .body(Body::empty()) + .expect("build models request"); + let response = api_router(ModelsExecution::with_cli_min()) + .await + .oneshot(request) + .await + .expect("list models response"); + + assert_eq!(response.status(), StatusCode::UPGRADE_REQUIRED); + let body = to_bytes(response.into_body(), usize::MAX) + .await + .expect("read version rejection body"); + let value: serde_json::Value = serde_json::from_slice(&body).expect("version rejection JSON"); + assert_eq!(value["error"]["code"], "client_version_too_old"); + assert_eq!(value["error"]["client"], "codex_cli"); + assert_eq!(value["error"]["current_version"], "0.39.0"); + assert_eq!(value["error"]["min_version"], "0.40.0"); +} + +#[tokio::test] +async fn models_should_reject_recognized_cli_with_invalid_version() { + let request = Request::get("/v1/models") + .header(AUTHORIZATION, "Bearer sk_models_test") + .header("user-agent", "codex-cli/not-a-version") + .body(Body::empty()) + .expect("build models request"); + let response = api_router(ModelsExecution::with_cli_min()) + .await + .oneshot(request) + .await + .expect("list models response"); + + assert_eq!(response.status(), StatusCode::UPGRADE_REQUIRED); + let body = to_bytes(response.into_body(), usize::MAX) + .await + .expect("read version rejection body"); + let value: serde_json::Value = serde_json::from_slice(&body).expect("version rejection JSON"); + assert_eq!(value["error"]["code"], "client_version_unavailable"); + assert!(value["error"]["current_version"].is_null()); + assert_eq!(value["error"]["min_version"], "0.40.0"); +} + +#[tokio::test] +async fn models_should_reject_desktop_below_its_configured_min() { + let request = Request::get("/v1/models") + .header(AUTHORIZATION, "Bearer sk_models_test") + .header("originator", "Codex Desktop") + .header("version", "26.825.50000") + .header("user-agent", "codex_cli_rs/99.0.0 (Codex Desktop)") + .body(Body::empty()) + .expect("build models request"); + let response = api_router(ModelsExecution::with_desktop_min()) + .await + .oneshot(request) + .await + .expect("list models response"); + + assert_eq!(response.status(), StatusCode::UPGRADE_REQUIRED); + let body = to_bytes(response.into_body(), usize::MAX) + .await + .expect("read version rejection body"); + let value: serde_json::Value = serde_json::from_slice(&body).expect("version rejection JSON"); + assert_eq!(value["error"]["code"], "client_version_too_old"); + assert_eq!(value["error"]["client"], "codex_desktop"); + assert_eq!(value["error"]["current_version"], "26.825.50000"); + assert_eq!(value["error"]["min_version"], "26.825.51511"); +} + +#[tokio::test] +async fn models_should_allow_cli_at_its_configured_min() { + let request = Request::get("/v1/models") + .header(AUTHORIZATION, "Bearer sk_models_test") + .header("user-agent", "codex_cli_rs/0.40.0 (Linux; x86_64)") + .body(Body::empty()) + .expect("build models request"); + let response = api_router(ModelsExecution::with_cli_min()) + .await + .oneshot(request) + .await + .expect("list models response"); + + assert_eq!(response.status(), StatusCode::OK); +} + +#[tokio::test] +async fn models_should_leave_unknown_clients_unrestricted() { + let request = Request::get("/v1/models") + .header(AUTHORIZATION, "Bearer sk_models_test") + .header("user-agent", "curl/8.14.1") + .body(Body::empty()) + .expect("build models request"); + let response = api_router(ModelsExecution::with_cli_min()) + .await + .oneshot(request) + .await + .expect("list models response"); + + assert_eq!(response.status(), StatusCode::OK); +} + #[tokio::test] async fn models_should_encode_provider_profiles_for_current_codex_clients() { let response = api_router(ModelsExecution::with_profiles()) @@ -301,11 +424,12 @@ async fn model_info_should_hide_unknown_models() { } #[tokio::test] -async fn models_should_require_a_valid_bearer_client_key() { - let response = api_router(ModelsExecution::new()) +async fn models_should_authenticate_before_applying_the_version_gate() { + let response = api_router(ModelsExecution::with_cli_min()) .await .oneshot( Request::get("/v1/models") + .header("user-agent", "codex_cli_rs/0.1.0 (Linux; x86_64)") .body(Body::empty()) .expect("build unauthenticated request"), ) diff --git a/backend/crates/gateway-api/tests/openai/responses/websocket/connection.rs b/backend/crates/gateway-api/tests/openai/responses/websocket/connection.rs index e1b22780d..736f150fe 100644 --- a/backend/crates/gateway-api/tests/openai/responses/websocket/connection.rs +++ b/backend/crates/gateway-api/tests/openai/responses/websocket/connection.rs @@ -190,16 +190,7 @@ fn crossing_limit_terminal_batch() -> CoordinatedEvent { .expect("terminal WebSocket batch") } -// 连接 runtime 没有正式公共 API;镜像测试直接编译该私有模块,以测试 socket pump, -// 不把测试钩子暴露给 gateway-api 的消费者。 -#[expect( - dead_code, - reason = "the mirrored test only exercises pump-facing seams" -)] -#[path = "../../../../src/openai/responses/websocket/connection.rs"] -mod connection_under_test; - -use connection_under_test::{ +use gateway_api::openai::responses::websocket::connection::{ ConnectionConfig, ConnectionEvent, ConnectionWriteError, FramePhase, ResponsesWebSocketConnection, WriteContext, spawn_connection, }; diff --git a/backend/crates/gateway-core/Cargo.toml b/backend/crates/gateway-core/Cargo.toml index 8f7ab4fdd..00478d81e 100644 --- a/backend/crates/gateway-core/Cargo.toml +++ b/backend/crates/gateway-core/Cargo.toml @@ -18,6 +18,7 @@ futures = "0.3.32" futures-timer = "3.0.3" serde = { version = "1.0.228", features = ["derive"] } serde_json = { version = "1.0.150", features = ["arbitrary_precision", "preserve_order"] } +semver = "1.0.27" thiserror = "2.0.18" tracing = "0.1.44" uuid = { version = "1.23.3", features = ["v7"] } diff --git a/backend/crates/gateway-core/src/policy/client_version.rs b/backend/crates/gateway-core/src/policy/client_version.rs new file mode 100644 index 000000000..1fee156c1 --- /dev/null +++ b/backend/crates/gateway-core/src/policy/client_version.rs @@ -0,0 +1,152 @@ +//! Codex 客户端最低版本策略。 + +use std::fmt; + +use semver::Version; + +const MAXIMUM_VERSION_LENGTH: usize = 64; + +/// 可被网关识别并限制最低版本的 Codex 客户端。 +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum CodexClientKind { + Desktop, + Cli, +} + +impl CodexClientKind { + #[must_use] + pub const fn as_str(self) -> &'static str { + match self { + Self::Desktop => "codex_desktop", + Self::Cli => "codex_cli", + } + } +} + +/// 经严格 SemVer 校验的 Codex 客户端版本。 +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)] +pub struct CodexClientVersion(Version); + +impl CodexClientVersion { + /// 仅接受没有前后空白、长度受限的标准 SemVer。 + pub fn parse(value: &str) -> Result { + if value.is_empty() + || value.len() > MAXIMUM_VERSION_LENGTH + || value.trim() != value + || value.bytes().any(|byte| byte.is_ascii_control()) + { + return Err(CodexClientVersionError); + } + Version::parse(value) + .map(Self) + .map_err(|_| CodexClientVersionError) + } +} + +impl fmt::Display for CodexClientVersion { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + self.0.fmt(formatter) + } +} + +/// 客户端版本 wire 值不合法。 +#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)] +#[error("Codex client version must be a valid semantic version")] +pub struct CodexClientVersionError; + +/// 运行快照中冻结的最低版本要求;`None` 表示该客户端不限制。 +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct CodexClientMinVersions { + desktop: Option, + cli: Option, +} + +impl CodexClientMinVersions { + #[must_use] + pub const fn new(desktop: Option, cli: Option) -> Self { + Self { desktop, cli } + } + + #[must_use] + pub const fn desktop(&self) -> Option<&CodexClientVersion> { + self.desktop.as_ref() + } + + #[must_use] + pub const fn cli(&self) -> Option<&CodexClientVersion> { + self.cli.as_ref() + } + + #[must_use] + pub const fn min_for(&self, kind: CodexClientKind) -> Option<&CodexClientVersion> { + match kind { + CodexClientKind::Desktop => self.desktop(), + CodexClientKind::Cli => self.cli(), + } + } + + /// 校验一个已识别客户端;配置未启用时直接放行。 + pub fn enforce( + &self, + kind: CodexClientKind, + current: Option<&CodexClientVersion>, + ) -> Result<(), ClientVersionRejection> { + let Some(min) = self.min_for(kind) else { + return Ok(()); + }; + let Some(current) = current else { + return Err(ClientVersionRejection::Unavailable { + kind, + min: min.clone(), + }); + }; + if current < min { + return Err(ClientVersionRejection::TooOld { + kind, + current: current.clone(), + min: min.clone(), + }); + } + Ok(()) + } +} + +/// 已识别客户端未满足冻结的最低版本要求。 +#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)] +pub enum ClientVersionRejection { + #[error("recognized Codex client did not provide a valid version")] + Unavailable { + kind: CodexClientKind, + min: CodexClientVersion, + }, + #[error("recognized Codex client is below the required minimum version")] + TooOld { + kind: CodexClientKind, + current: CodexClientVersion, + min: CodexClientVersion, + }, +} + +impl ClientVersionRejection { + #[must_use] + pub const fn kind(&self) -> CodexClientKind { + match self { + Self::Unavailable { kind, .. } | Self::TooOld { kind, .. } => *kind, + } + } + + #[must_use] + pub const fn current(&self) -> Option<&CodexClientVersion> { + match self { + Self::Unavailable { .. } => None, + Self::TooOld { current, .. } => Some(current), + } + } + + #[must_use] + pub const fn min(&self) -> &CodexClientVersion { + match self { + Self::Unavailable { min, .. } | Self::TooOld { min, .. } => min, + } + } +} diff --git a/backend/crates/gateway-core/src/policy/mod.rs b/backend/crates/gateway-core/src/policy/mod.rs index 3f56e54b2..7e0d27474 100644 --- a/backend/crates/gateway-core/src/policy/mod.rs +++ b/backend/crates/gateway-core/src/policy/mod.rs @@ -2,6 +2,12 @@ //! //! Client API Key 冻结账号分组权限;模型名称不参与权限判断。 +mod client_version; + +pub use client_version::{ + ClientVersionRejection, CodexClientKind, CodexClientMinVersions, CodexClientVersion, +}; + use std::fmt; use std::sync::Arc; diff --git a/backend/crates/gateway-core/src/routing/snapshot.rs b/backend/crates/gateway-core/src/routing/snapshot.rs index 2facaefdd..e5d91801a 100644 --- a/backend/crates/gateway-core/src/routing/snapshot.rs +++ b/backend/crates/gateway-core/src/routing/snapshot.rs @@ -16,7 +16,10 @@ use crate::engine::provider::{ProviderCatalogGeneration, ProviderRegistry}; use crate::error::RoutingError; use crate::health::{HealthProbe, HealthState}; use crate::operation::Operation; -use crate::policy::{ClientApiKeyId, ClientPolicy, PlaintextClientApiKey, RateLimits}; +use crate::policy::{ + ClientApiKeyId, ClientPolicy, CodexClientMinVersions, CodexClientVersion, + PlaintextClientApiKey, RateLimits, +}; use crate::task::{ DaemonRestartPolicy, DaemonTask, ScheduledTask, WorkerContribution, WorkerCycleContext, WorkerDefinitionError, WorkerId, WorkerKind, WorkerRegistration, WorkerRunnable, @@ -43,6 +46,8 @@ pub struct SnapshotSettingsFacts { request_interval_ms: u64, rotation_strategy: String, model_mappings: BTreeMap, + min_codex_desktop_version: Option, + min_codex_cli_version: Option, } impl SnapshotSettingsFacts { @@ -52,12 +57,16 @@ impl SnapshotSettingsFacts { request_interval_ms: u64, rotation_strategy: impl Into, model_mappings: BTreeMap, + min_codex_desktop_version: Option, + min_codex_cli_version: Option, ) -> Self { Self { max_concurrent_per_account, request_interval_ms, rotation_strategy: rotation_strategy.into(), model_mappings, + min_codex_desktop_version, + min_codex_cli_version, } } } @@ -356,6 +365,22 @@ async fn compile_runtime_snapshot( let account_directory = Arc::new(RuntimeAccountDirectory::new(accounts)); let model_mappings = facts.settings.model_mappings; + let min_client_versions = CodexClientMinVersions::new( + facts + .settings + .min_codex_desktop_version + .as_deref() + .map(CodexClientVersion::parse) + .transpose() + .map_err(|_| RuntimeSnapshotCompileError::InvalidData)?, + facts + .settings + .min_codex_cli_version + .as_deref() + .map(CodexClientVersion::parse) + .transpose() + .map_err(|_| RuntimeSnapshotCompileError::InvalidData)?, + ); let rotation_strategy = RotationStrategy::parse(facts.settings.rotation_strategy.as_str()) .ok_or(RuntimeSnapshotCompileError::InvalidData)?; let selection_policy = AccountSelectionPolicy::new( @@ -420,6 +445,7 @@ async fn compile_runtime_snapshot( .with_model_mappings(model_mappings) .with_account_directory(account_directory) .with_known_provider_catalogs(known_provider_catalogs) + .with_min_codex_client_versions(min_client_versions) }) } @@ -437,6 +463,7 @@ pub struct RuntimeSnapshot { known_provider_catalogs: Arc>, account_directory: Arc, client_policies: Arc>, + min_codex_client_versions: CodexClientMinVersions, } impl RuntimeSnapshot { @@ -518,6 +545,7 @@ impl RuntimeSnapshot { known_provider_catalogs: Arc::new(known_provider_catalogs), account_directory: Arc::new(RuntimeAccountDirectory::default()), client_policies: Arc::new(client_policy_map), + min_codex_client_versions: CodexClientMinVersions::default(), }) } @@ -533,6 +561,12 @@ impl RuntimeSnapshot { self } + #[must_use] + pub fn with_min_codex_client_versions(mut self, versions: CodexClientMinVersions) -> Self { + self.min_codex_client_versions = versions; + self + } + #[must_use] fn with_known_provider_catalogs(mut self, providers: BTreeSet) -> Self { self.known_provider_catalogs = Arc::new(providers); @@ -711,6 +745,11 @@ impl RuntimeSnapshot { self.client_policies.values() } + #[must_use] + pub const fn min_codex_client_versions(&self) -> &CodexClientMinVersions { + &self.min_codex_client_versions + } + pub fn plan( &self, public_model: &PublicModelId, diff --git a/backend/crates/gateway-core/tests/policy/mod.rs b/backend/crates/gateway-core/tests/policy/mod.rs index 912608c10..32975f84d 100644 --- a/backend/crates/gateway-core/tests/policy/mod.rs +++ b/backend/crates/gateway-core/tests/policy/mod.rs @@ -1,6 +1,9 @@ use std::sync::Arc; -use gateway_core::policy::{ClientApiKeyId, ClientPolicy, PlaintextClientApiKey, RateLimits}; +use gateway_core::policy::{ + ClientApiKeyId, ClientPolicy, ClientVersionRejection, CodexClientKind, CodexClientMinVersions, + CodexClientVersion, PlaintextClientApiKey, RateLimits, +}; use gateway_core::routing::{ClientRoutingScope, FrozenAccountScope, RuntimeAccountDirectory}; fn plaintext(value: &str) -> PlaintextClientApiKey { @@ -53,3 +56,55 @@ fn plaintext_client_key_debug_should_be_redacted() { assert!(!debug.contains("must_not_appear")); assert_eq!(key.expose_for_auth(), "sk_must_not_appear"); } + +#[test] +fn client_version_should_require_strict_semver() { + assert!(CodexClientVersion::parse("0.152.0").is_ok()); + assert!(CodexClientVersion::parse("v0.152.0").is_err()); + assert!(CodexClientVersion::parse("0.152").is_err()); + assert!(CodexClientVersion::parse(" 0.152.0").is_err()); + assert!(CodexClientVersion::parse("18446744073709551616.0.0").is_err()); +} + +#[test] +fn min_client_versions_should_reject_missing_and_old_recognized_clients() { + let min = CodexClientVersion::parse("0.40.0").expect("min version"); + let policy = CodexClientMinVersions::new(None, Some(min.clone())); + let old = CodexClientVersion::parse("0.39.0").expect("old version"); + let current = CodexClientVersion::parse("0.40.0").expect("current version"); + + assert_eq!( + policy.enforce(CodexClientKind::Cli, None), + Err(ClientVersionRejection::Unavailable { + kind: CodexClientKind::Cli, + min: min.clone(), + }) + ); + assert_eq!( + policy.enforce(CodexClientKind::Cli, Some(&old)), + Err(ClientVersionRejection::TooOld { + kind: CodexClientKind::Cli, + current: old, + min, + }) + ); + assert_eq!(policy.enforce(CodexClientKind::Cli, Some(¤t)), Ok(())); + assert_eq!(policy.enforce(CodexClientKind::Desktop, None), Ok(())); +} + +#[test] +fn min_client_versions_should_follow_semver_precedence() { + let min = CodexClientVersion::parse("1.0.0").expect("min version"); + let policy = CodexClientMinVersions::new(None, Some(min)); + let prerelease = CodexClientVersion::parse("1.0.0-rc.1").expect("prerelease"); + let release = CodexClientVersion::parse("1.0.0").expect("release"); + let newer = CodexClientVersion::parse("1.0.1").expect("newer release"); + + assert!( + policy + .enforce(CodexClientKind::Cli, Some(&prerelease)) + .is_err() + ); + assert_eq!(policy.enforce(CodexClientKind::Cli, Some(&release)), Ok(())); + assert_eq!(policy.enforce(CodexClientKind::Cli, Some(&newer)), Ok(())); +} diff --git a/backend/crates/gateway-core/tests/routing/snapshot.rs b/backend/crates/gateway-core/tests/routing/snapshot.rs index 00f4e02f9..3e14454f2 100644 --- a/backend/crates/gateway-core/tests/routing/snapshot.rs +++ b/backend/crates/gateway-core/tests/routing/snapshot.rs @@ -336,7 +336,60 @@ fn publisher_should_contribute_reconciliation_and_subscription_workers() { ); } +#[test] +fn compiler_should_freeze_valid_client_min_versions() { + let store = Arc::new(TestSnapshotStore::new(Ok(facts_with_min_versions( + 1, + 1, + Some("26.825.6671".to_owned()), + Some("0.40.0".to_owned()), + )))); + + let snapshot = block_on(compiler(store).compile()).expect("valid min versions"); + + assert_eq!( + snapshot + .min_codex_client_versions() + .desktop() + .map(ToString::to_string) + .as_deref(), + Some("26.825.6671") + ); + assert_eq!( + snapshot + .min_codex_client_versions() + .cli() + .map(ToString::to_string) + .as_deref(), + Some("0.40.0") + ); +} + +#[test] +fn compiler_should_reject_invalid_persisted_client_min_version() { + let store = Arc::new(TestSnapshotStore::new(Ok(facts_with_min_versions( + 1, + 1, + None, + Some("v0.40.0".to_owned()), + )))); + + assert_eq!( + block_on(compiler(store).compile()).expect_err("invalid min version"), + RuntimeSnapshotCompileError::InvalidData + ); +} + fn facts(config_revision: u64, observed_current_revision: u64) -> SnapshotFacts { + facts_with_min_versions(config_revision, observed_current_revision, None, None) +} + +fn facts_with_min_versions( + config_revision: u64, + observed_current_revision: u64, + desktop: Option, + cli: Option, +) -> SnapshotFacts { SnapshotFacts::new( revision(config_revision), revision(observed_current_revision), @@ -345,6 +398,8 @@ fn facts(config_revision: u64, observed_current_revision: u64) -> SnapshotFacts 50, "smart", BTreeMap::from([("public-model".to_owned(), "upstream-model".to_owned())]), + desktop, + cli, ), vec![SnapshotClientPolicyFacts::new( ClientApiKeyId::new("key_one").expect("key ID"), diff --git a/backend/crates/gateway-host/Cargo.toml b/backend/crates/gateway-host/Cargo.toml index 71a6ce7b7..adc0c71b9 100644 --- a/backend/crates/gateway-host/Cargo.toml +++ b/backend/crates/gateway-host/Cargo.toml @@ -18,6 +18,7 @@ gateway-admin = { path = "../gateway-admin" } gateway-core = { path = "../gateway-core" } hex = "0.4.3" reqwest = { version = "=0.12.28", default-features = false, features = ["json", "stream", "rustls-tls-native-roots"] } +scraper = "0.27.0" semver = "1.0.27" serde = { version = "1.0.228", features = ["derive"] } serde_json = "1.0.150" diff --git a/backend/crates/gateway-host/src/client_distribution.rs b/backend/crates/gateway-host/src/client_distribution.rs new file mode 100644 index 000000000..147bd4987 --- /dev/null +++ b/backend/crates/gateway-host/src/client_distribution.rs @@ -0,0 +1,399 @@ +//! RG-Adguard Store 页面解析与官方 Codex Desktop 下载回退。 + +use std::{collections::BTreeMap, time::Duration}; + +use async_trait::async_trait; +use chrono::{DateTime, NaiveDateTime, TimeDelta, Utc}; +use futures::StreamExt as _; +use gateway_admin::{ + model::client_distribution::{ + ClientArchitecture, ClientDownloadPackage, ClientDownloadSource, + CodexDesktopWindowsDownloads, + }, + ports::client_distribution::ClientDistributionResolver, +}; +use reqwest::{Client, Url, redirect::Policy}; +use scraper::{Html, Selector}; +use tokio::sync::Mutex; + +const RG_ADGUARD_ENDPOINT: &str = "https://store.rg-adguard.net/api/GetFiles"; +const RG_ADGUARD_ORIGIN: &str = "https://store.rg-adguard.net"; +const CODEX_STORE_PRODUCT_ID: &str = "9PLM9XGG6VKS"; +const PACKAGE_PUBLISHER_ID: &str = "2p2nqsd0c76g0"; +const MAXIMUM_RESPONSE_BYTES: usize = 512 * 1024; +const DYNAMIC_CACHE_SECONDS: i64 = 300; +const FALLBACK_CACHE_SECONDS: u64 = 60; +const MINIMUM_REMAINING_LINK_SECONDS: i64 = 600; + +const OFFICIAL_X64_URL: &str = "https://persistent.oaistatic.com/codex-app-prod/ChatGPT-x64.msix"; +const OFFICIAL_ARM64_URL: &str = + "https://persistent.oaistatic.com/codex-app-prod/ChatGPT-arm64.msix"; + +/// 下载页请求或解析失败的稳定分类。 +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ClientDistributionResolutionErrorKind { + /// 上游请求不可用。 + Unavailable, + /// 上游响应不满足受信合同。 + InvalidResponse, +} + +pub(crate) struct RgAdguardClientDistribution { + client: Option, + cache: Mutex>, +} + +struct CachedDownloads { + cached_at: std::time::Instant, + valid_until: std::time::Instant, + value: CodexDesktopWindowsDownloads, +} + +impl RgAdguardClientDistribution { + pub(crate) fn new() -> Self { + let client = Client::builder() + .connect_timeout(Duration::from_secs(5)) + .timeout(Duration::from_secs(12)) + .redirect(Policy::none()) + .build() + .inspect_err(|_| { + tracing::warn!( + "download resolver HTTP client initialization failed; official downloads will be used" + ); + }) + .ok(); + Self { + client, + cache: Mutex::new(None), + } + } + + async fn resolve_uncached(&self) -> (CodexDesktopWindowsDownloads, Duration) { + let now = Utc::now(); + match self.fetch_store_packages(now).await { + Ok(packages) => { + let (packages, warning) = complete_with_official_fallback(packages); + let valid_seconds = packages + .iter() + .filter_map(|package| package.expires_at) + .map(|expires_at| { + (expires_at - now).num_seconds() - MINIMUM_REMAINING_LINK_SECONDS + }) + .min() + .unwrap_or(DYNAMIC_CACHE_SECONDS) + .clamp(1, DYNAMIC_CACHE_SECONDS); + ( + CodexDesktopWindowsDownloads { + resolved_at: now, + cached: false, + warning, + packages, + }, + Duration::from_secs(u64::try_from(valid_seconds).unwrap_or(1)), + ) + } + Err(error_kind) => { + tracing::warn!( + error_kind = ?error_kind, + "RG-Adguard package resolution failed; using official downloads" + ); + ( + official_fallback(now), + Duration::from_secs(FALLBACK_CACHE_SECONDS), + ) + } + } + } + + async fn fetch_store_packages( + &self, + now: DateTime, + ) -> Result, ClientDistributionResolutionErrorKind> { + let client = self + .client + .as_ref() + .ok_or(ClientDistributionResolutionErrorKind::Unavailable)?; + let response = client + .post(RG_ADGUARD_ENDPOINT) + .header("origin", RG_ADGUARD_ORIGIN) + .header("referer", format!("{RG_ADGUARD_ORIGIN}/")) + .header( + "user-agent", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 Chrome/140 Safari/537.36", + ) + .form(&[ + ("type", "ProductId"), + ("url", CODEX_STORE_PRODUCT_ID), + ("ring", "Retail"), + ("lang", ""), + ]) + .send() + .await + .map_err(|_| ClientDistributionResolutionErrorKind::Unavailable)?; + if !response.status().is_success() { + return Err(ClientDistributionResolutionErrorKind::Unavailable); + } + + let mut bytes = Vec::new(); + let mut stream = response.bytes_stream(); + while let Some(chunk) = stream.next().await { + let chunk = chunk.map_err(|_| ClientDistributionResolutionErrorKind::Unavailable)?; + if bytes.len().saturating_add(chunk.len()) > MAXIMUM_RESPONSE_BYTES { + return Err(ClientDistributionResolutionErrorKind::InvalidResponse); + } + bytes.extend_from_slice(&chunk); + } + let html = std::str::from_utf8(&bytes) + .map_err(|_| ClientDistributionResolutionErrorKind::InvalidResponse)?; + parse_store_packages(html, now) + } +} + +#[async_trait] +impl ClientDistributionResolver for RgAdguardClientDistribution { + async fn resolve_codex_desktop_windows(&self, refresh: bool) -> CodexDesktopWindowsDownloads { + let requested_at = std::time::Instant::now(); + let mut cache = self.cache.lock().await; + if let Some(cached) = cache.as_ref() + && cached.valid_until > std::time::Instant::now() + && (!refresh || cached.cached_at >= requested_at) + { + let mut value = cached.value.clone(); + value.cached = true; + return value; + } + + let (value, ttl) = self.resolve_uncached().await; + let cached_at = std::time::Instant::now(); + *cache = Some(CachedDownloads { + cached_at, + valid_until: cached_at + ttl, + value: value.clone(), + }); + value + } +} + +/// 从 RG-Adguard HTML 中选择每个受支持架构的最新有效主包。 +/// +/// # Errors +/// +/// 页面没有任何满足包身份、下载地址和过期时间约束的结果时返回无效响应。 +pub fn parse_store_packages( + html: &str, + now: DateTime, +) -> Result, ClientDistributionResolutionErrorKind> { + let document = Html::parse_document(html); + let row_selector = Selector::parse("table.tftable tr") + .map_err(|_| ClientDistributionResolutionErrorKind::InvalidResponse)?; + let link_selector = + Selector::parse("a").map_err(|_| ClientDistributionResolutionErrorKind::InvalidResponse)?; + let cell_selector = Selector::parse("td") + .map_err(|_| ClientDistributionResolutionErrorKind::InvalidResponse)?; + let mut selected = BTreeMap::::new(); + + for row in document.select(&row_selector) { + let Some(link) = row.select(&link_selector).next() else { + continue; + }; + let file_name = link.text().collect::().trim().to_owned(); + let Some((architecture, version)) = parse_package_file_name(&file_name) else { + continue; + }; + let Some(href) = link.value().attr("href") else { + continue; + }; + let Some(download_url) = validate_download_url(href) else { + continue; + }; + let cells = row + .select(&cell_selector) + .map(|cell| cell.text().collect::().trim().to_owned()) + .collect::>(); + let Some(expires_at) = cells.iter().find_map(|cell| parse_expiry(cell)) else { + continue; + }; + if expires_at <= now + TimeDelta::seconds(MINIMUM_REMAINING_LINK_SECONDS) { + continue; + } + let size_bytes = cells.iter().find_map(|cell| parse_size_bytes(cell)); + let candidate = ParsedStorePackage { + version, + package: ClientDownloadPackage { + architecture, + source: ClientDownloadSource::MicrosoftStore, + version: Some(version.to_string()), + file_name, + size_bytes, + download_url, + expires_at: Some(expires_at), + }, + }; + let replace = selected + .get(&architecture) + .is_none_or(|existing| candidate.version > existing.version); + if replace { + selected.insert(architecture, candidate); + } + } + + if selected.is_empty() { + return Err(ClientDistributionResolutionErrorKind::InvalidResponse); + } + Ok(selected.into_values().map(|value| value.package).collect()) +} + +#[derive(Debug)] +struct ParsedStorePackage { + version: StorePackageVersion, + package: ClientDownloadPackage, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] +struct StorePackageVersion([u64; 4]); + +impl std::fmt::Display for StorePackageVersion { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!( + formatter, + "{}.{}.{}.{}", + self.0[0], self.0[1], self.0[2], self.0[3] + ) + } +} + +fn parse_package_file_name(file_name: &str) -> Option<(ClientArchitecture, StorePackageVersion)> { + let suffix = format!("__{PACKAGE_PUBLISHER_ID}.msix"); + let body = file_name + .strip_prefix("OpenAI.Codex_")? + .strip_suffix(&suffix)?; + let (version, architecture) = body.rsplit_once('_')?; + let architecture = match architecture { + "x64" => ClientArchitecture::X64, + "arm64" => ClientArchitecture::Arm64, + _ => return None, + }; + let mut parts = version.split('.'); + let values = [ + parts.next()?.parse::().ok()?, + parts.next()?.parse::().ok()?, + parts.next()?.parse::().ok()?, + parts.next()?.parse::().ok()?, + ]; + if parts.next().is_some() { + return None; + } + Some((architecture, StorePackageVersion(values))) +} + +fn validate_download_url(value: &str) -> Option { + let url = Url::parse(value).ok()?; + if !matches!(url.scheme(), "http" | "https") + || !url.username().is_empty() + || url.password().is_some() + || url.port().is_some() + || !matches!( + url.host_str(), + Some("dl.delivery.mp.microsoft.com" | "tlu.dl.delivery.mp.microsoft.com") + ) + || !url.path().starts_with("/filestreamingservice/files/") + || url.path() == "/filestreamingservice/files/" + { + return None; + } + Some(url.into()) +} + +fn parse_expiry(value: &str) -> Option> { + let value = value.strip_suffix(" GMT")?; + NaiveDateTime::parse_from_str(value, "%Y-%m-%d %H:%M:%S") + .ok() + .map(|value| value.and_utc()) +} + +fn parse_size_bytes(value: &str) -> Option { + let mut parts = value.split_ascii_whitespace(); + let amount = parts.next()?.replace(',', ""); + let unit = parts.next()?; + if parts.next().is_some() { + return None; + } + let multiplier = match unit { + "B" => 1_u128, + "KB" => 1_000, + "MB" => 1_000_000, + "GB" => 1_000_000_000, + _ => return None, + }; + let (whole, fraction) = amount.split_once('.').unwrap_or((amount.as_str(), "")); + if fraction.len() > 6 || !fraction.bytes().all(|byte| byte.is_ascii_digit()) { + return None; + } + let whole = whole.parse::().ok()?; + let scale = 10_u128.checked_pow(u32::try_from(fraction.len()).ok()?)?; + let fraction = if fraction.is_empty() { + 0 + } else { + fraction.parse::().ok()? + }; + let bytes = whole + .checked_mul(multiplier)? + .checked_add(fraction.checked_mul(multiplier)?.checked_div(scale)?)?; + u64::try_from(bytes).ok() +} + +fn official_fallback(now: DateTime) -> CodexDesktopWindowsDownloads { + let packages = [ClientArchitecture::X64, ClientArchitecture::Arm64] + .into_iter() + .map(official_package) + .collect(); + CodexDesktopWindowsDownloads { + resolved_at: now, + cached: false, + warning: Some( + "Microsoft Store 离线直链暂不可用,已切换为 OpenAI 官方稳定安装包。".to_owned(), + ), + packages, + } +} + +/// 为动态结果中缺失的架构补齐 OpenAI 官方稳定包。 +#[must_use] +pub fn complete_with_official_fallback( + packages: Vec, +) -> (Vec, Option) { + let mut packages = packages + .into_iter() + .map(|package| (package.architecture, package)) + .collect::>(); + let mut missing = Vec::new(); + for architecture in [ClientArchitecture::X64, ClientArchitecture::Arm64] { + if let std::collections::btree_map::Entry::Vacant(entry) = packages.entry(architecture) { + missing.push(architecture.as_str()); + entry.insert(official_package(architecture)); + } + } + let warning = (!missing.is_empty()).then(|| { + format!( + "{} 的 Microsoft Store 临时直链不可用,已切换为 OpenAI 官方稳定安装包。", + missing.join("、") + ) + }); + (packages.into_values().collect(), warning) +} + +fn official_package(architecture: ClientArchitecture) -> ClientDownloadPackage { + let (download_url, file_name) = match architecture { + ClientArchitecture::X64 => (OFFICIAL_X64_URL, "ChatGPT-x64.msix"), + ClientArchitecture::Arm64 => (OFFICIAL_ARM64_URL, "ChatGPT-arm64.msix"), + }; + ClientDownloadPackage { + architecture, + source: ClientDownloadSource::OfficialOpenAi, + version: None, + file_name: file_name.to_owned(), + size_bytes: None, + download_url: download_url.to_owned(), + expires_at: None, + } +} diff --git a/backend/crates/gateway-host/src/lib.rs b/backend/crates/gateway-host/src/lib.rs index 43f593e35..160e18235 100644 --- a/backend/crates/gateway-host/src/lib.rs +++ b/backend/crates/gateway-host/src/lib.rs @@ -1,5 +1,6 @@ //! 网关进程与操作系统能力:配置发现、日志、任务、自更新与 serve/drain。 +pub mod client_distribution; pub mod config; mod logging; pub mod serve; @@ -9,7 +10,9 @@ pub mod workers; use std::sync::Arc; use axum::Router; -use gateway_admin::ports::system::SystemOperations; +use gateway_admin::ports::{ + client_distribution::ClientDistributionResolver, system::SystemOperations, +}; use gateway_core::engine::CancellationToken; use gateway_core::health::WorkerHealthSource; use gateway_core::lifecycle::ConnectionLifecycle; @@ -17,6 +20,7 @@ use gateway_core::task::{WorkerContribution, WorkerLeaderLeasePort}; pub use config::{ConfigError, HostConfig, LoadableConfig, load_config}; +use self::client_distribution::RgAdguardClientDistribution; use self::logging::{LogGuard, initialize_logging}; use self::serve::{ConnectionTracker, serve_router}; use self::system_update::ProcessSystemOperations; @@ -30,6 +34,7 @@ pub struct HostBundle { connections: Arc, workers: WorkerSupervisor, system: Arc, + client_distribution: Arc, } /// 在启动其他包之前初始化进程级能力。 @@ -42,6 +47,7 @@ pub async fn initialize(config: HostConfig) -> Result { cancellation.clone(), config.system_update.clone(), )); + let client_distribution = Arc::new(RgAdguardClientDistribution::new()); Ok(HostBundle { config, _log_guard: log_guard, @@ -49,6 +55,7 @@ pub async fn initialize(config: HostConfig) -> Result { connections, workers, system, + client_distribution, }) } @@ -68,6 +75,12 @@ impl HostBundle { self.system.clone() } + #[must_use] + /// 返回惰性下载解析能力;网络请求只会在管理 API 调用时发生。 + pub fn client_distribution_resolver(&self) -> Arc { + self.client_distribution.clone() + } + #[must_use] pub fn connection_lifecycle(&self) -> Arc { self.connections.clone() diff --git a/backend/crates/gateway-host/tests/client_distribution.rs b/backend/crates/gateway-host/tests/client_distribution.rs new file mode 100644 index 000000000..5707200c2 --- /dev/null +++ b/backend/crates/gateway-host/tests/client_distribution.rs @@ -0,0 +1,72 @@ +use chrono::{TimeZone as _, Utc}; +use gateway_admin::model::client_distribution::{ + ClientArchitecture, ClientDownloadPackage, ClientDownloadSource, +}; +use gateway_host::client_distribution::{ + ClientDistributionResolutionErrorKind, complete_with_official_fallback, parse_store_packages, +}; + +#[test] +fn parser_selects_latest_valid_package_for_each_architecture() { + let now = Utc + .with_ymd_and_hms(2026, 9, 1, 0, 0, 0) + .single() + .expect("time"); + let html = r#" + + + + + + +
OpenAI.Codex_26.800.1.0_x64__2p2nqsd0c76g0.msix2026-09-01 01:00:00 GMT700 MB
OpenAI.Codex_26.825.6671.0_x64__2p2nqsd0c76g0.msix2026-09-01 02:00:00 GMT744.25 MB
OpenAI.Codex_26.825.6671.0_arm64__2p2nqsd0c76g0.msix2026-09-01 02:00:00 GMT710 MB
OpenAI.Codex_99.0.0.0_x64__2p2nqsd0c76g0.msix2026-09-01 02:00:00 GMT1 GB
OpenAI.Codex_26.825.6671.0_x64__2p2nqsd0c76g0.BlockMap2026-09-01 02:00:00 GMT1 MB
+ "#; + + let packages = parse_store_packages(html, now).expect("packages"); + + assert_eq!(packages.len(), 2); + assert_eq!(packages[0].architecture, ClientArchitecture::X64); + assert_eq!(packages[0].version.as_deref(), Some("26.825.6671.0")); + assert!(packages[0].download_url.starts_with("http://")); + assert_eq!(packages[0].size_bytes, Some(744_250_000)); + assert_eq!(packages[1].architecture, ClientArchitecture::Arm64); + assert!(packages[1].download_url.starts_with("https://")); +} + +#[test] +fn parser_rejects_expiring_results() { + let now = Utc + .with_ymd_and_hms(2026, 9, 1, 0, 0, 0) + .single() + .expect("time"); + let html = r#" + + +
OpenAI.Codex_26.825.6671.0_x64__2p2nqsd0c76g0.msix2026-09-01 00:09:59 GMT700 MB
+ "#; + + assert_eq!( + parse_store_packages(html, now), + Err(ClientDistributionResolutionErrorKind::InvalidResponse) + ); +} + +#[test] +fn missing_architecture_uses_only_its_official_fallback() { + let dynamic = ClientDownloadPackage { + architecture: ClientArchitecture::X64, + source: ClientDownloadSource::MicrosoftStore, + version: Some("26.825.6671.0".to_owned()), + file_name: "store-x64.msix".to_owned(), + size_bytes: None, + download_url: "https://dl.delivery.mp.microsoft.com/filestreamingservice/files/x64" + .to_owned(), + expires_at: None, + }; + + let (packages, warning) = complete_with_official_fallback(vec![dynamic]); + + assert_eq!(packages[0].source, ClientDownloadSource::MicrosoftStore); + assert_eq!(packages[1].source, ClientDownloadSource::OfficialOpenAi); + assert!(warning.is_some_and(|value| value.contains("arm64"))); +} diff --git a/backend/crates/gateway-host/tests/main.rs b/backend/crates/gateway-host/tests/main.rs index 2de636f9e..650ad1be0 100644 --- a/backend/crates/gateway-host/tests/main.rs +++ b/backend/crates/gateway-host/tests/main.rs @@ -1,3 +1,4 @@ +mod client_distribution; mod config; mod logging; mod serve; diff --git a/backend/crates/gateway-store/src/admin_adapter.rs b/backend/crates/gateway-store/src/admin_adapter.rs index 2b76466e0..070741758 100644 --- a/backend/crates/gateway-store/src/admin_adapter.rs +++ b/backend/crates/gateway-store/src/admin_adapter.rs @@ -45,6 +45,8 @@ impl SettingsStore for AdminSettingsStoreAdapter { request_interval_ms: command.request_interval_ms, rotation_strategy: command.rotation_strategy.as_str().to_owned(), model_mappings: store_model_mappings(command.model_mappings), + min_codex_desktop_version: command.min_codex_desktop_version, + min_codex_cli_version: command.min_codex_cli_version, usage_retention_days: command.usage_retention_days, ops_event_retention_days: command.ops_event_retention_days, audit_retention_days: command.audit_retention_days, @@ -61,6 +63,8 @@ impl SettingsStore for AdminSettingsStoreAdapter { "max_concurrent_per_account".to_owned(), "request_interval_ms".to_owned(), "rotation_strategy".to_owned(), + "min_codex_desktop_version".to_owned(), + "min_codex_cli_version".to_owned(), "retention".to_owned(), ], ), @@ -162,6 +166,8 @@ pub(crate) fn admin_runtime_settings( max_concurrent_per_account: settings.max_concurrent_per_account, request_interval_ms: settings.request_interval_ms, rotation_strategy, + min_codex_desktop_version: settings.min_codex_desktop_version, + min_codex_cli_version: settings.min_codex_cli_version, usage_retention_days: settings.usage_retention_days, ops_event_retention_days: settings.ops_event_retention_days, audit_retention_days: settings.audit_retention_days, diff --git a/backend/crates/gateway-store/src/postgres/runtime_settings.rs b/backend/crates/gateway-store/src/postgres/runtime_settings.rs index c2c1c4b40..1bc90b2a8 100644 --- a/backend/crates/gateway-store/src/postgres/runtime_settings.rs +++ b/backend/crates/gateway-store/src/postgres/runtime_settings.rs @@ -9,6 +9,7 @@ use chrono::{DateTime, Utc}; use sqlx::{PgPool, Postgres, Transaction}; use gateway_core::engine::credential::RotationStrategy; +use gateway_core::policy::CodexClientVersion; use gateway_core::provider_ports::{ ProviderRefreshPolicy, ProviderRuntimePolicyPort, ProviderStoreError, ProviderStoreErrorKind, }; @@ -25,6 +26,8 @@ pub struct RuntimeSettings { pub request_interval_ms: u64, pub rotation_strategy: String, pub model_mappings: BTreeMap, + pub min_codex_desktop_version: Option, + pub min_codex_cli_version: Option, pub usage_retention_days: u32, pub ops_event_retention_days: u32, pub audit_retention_days: u32, @@ -49,6 +52,8 @@ impl fmt::Debug for RuntimeSettings { .field("request_interval_ms", &self.request_interval_ms) .field("rotation_strategy", &self.rotation_strategy) .field("model_mappings", &self.model_mappings) + .field("min_codex_desktop_version", &self.min_codex_desktop_version) + .field("min_codex_cli_version", &self.min_codex_cli_version) .field("usage_retention_days", &self.usage_retention_days) .field("ops_event_retention_days", &self.ops_event_retention_days) .field("audit_retention_days", &self.audit_retention_days) @@ -66,6 +71,8 @@ pub struct RuntimeSettingsUpdate { pub request_interval_ms: u64, pub rotation_strategy: String, pub model_mappings: BTreeMap, + pub min_codex_desktop_version: Option, + pub min_codex_cli_version: Option, pub usage_retention_days: u32, pub ops_event_retention_days: u32, pub audit_retention_days: u32, @@ -94,6 +101,8 @@ impl RuntimeSettingsUpdate { || self.ops_event_retention_days == 0 || self.audit_retention_days == 0 || !valid_model_mappings(&self.model_mappings) + || !valid_client_version(self.min_codex_desktop_version.as_deref()) + || !valid_client_version(self.min_codex_cli_version.as_deref()) || RotationStrategy::parse(&self.rotation_strategy).is_none() { return Err(StoreError::InvalidData { @@ -154,7 +163,8 @@ pub(crate) async fn load_runtime_settings_from_pool(pool: &PgPool) -> StoreResul "select config_revision, admin_api_key, refresh_margin_seconds, refresh_concurrency, max_concurrent_per_account, request_interval_ms, rotation_strategy, model_mappings_json, usage_retention_days, ops_event_retention_days, - audit_retention_days, updated_at + audit_retention_days, min_codex_desktop_version, + min_codex_cli_version, updated_at from runtime_settings where id = 1", ) .fetch_optional(pool) @@ -192,7 +202,8 @@ pub(crate) async fn load_runtime_settings_in_transaction( "select config_revision, admin_api_key, refresh_margin_seconds, refresh_concurrency, max_concurrent_per_account, request_interval_ms, rotation_strategy, model_mappings_json, usage_retention_days, ops_event_retention_days, - audit_retention_days, updated_at + audit_retention_days, min_codex_desktop_version, + min_codex_cli_version, updated_at from runtime_settings where id = 1", ) .fetch_optional(&mut **transaction) @@ -225,7 +236,9 @@ pub(crate) async fn update_runtime_settings_in_transaction( usage_retention_days = $8, ops_event_retention_days = $9, audit_retention_days = $10, - updated_at = now() + min_codex_desktop_version = $11, + min_codex_cli_version = $12, + updated_at = now() where id = 1 returning config_revision", ) @@ -239,6 +252,8 @@ pub(crate) async fn update_runtime_settings_in_transaction( .bind(i64::from(update.usage_retention_days)) .bind(i64::from(update.ops_event_retention_days)) .bind(i64::from(update.audit_retention_days)) + .bind(update.min_codex_desktop_version.as_deref()) + .bind(update.min_codex_cli_version.as_deref()) .fetch_optional(&mut **transaction) .await .map_err(|_| postgres_unavailable("update runtime settings in transaction"))? @@ -298,6 +313,8 @@ type RuntimeSettingsRow = ( i64, i64, i64, + Option, + Option, DateTime, ); @@ -314,7 +331,9 @@ fn runtime_settings_from_row(row: RuntimeSettingsRow) -> StoreResult bool { && value.len() <= max_len && !value.bytes().any(|byte| byte.is_ascii_control()) } + +fn valid_client_version(value: Option<&str>) -> bool { + value.is_none_or(|value| CodexClientVersion::parse(value).is_ok()) +} diff --git a/backend/crates/gateway-store/src/postgres/snapshot.rs b/backend/crates/gateway-store/src/postgres/snapshot.rs index 8661e2f99..d0137ed96 100644 --- a/backend/crates/gateway-store/src/postgres/snapshot.rs +++ b/backend/crates/gateway-store/src/postgres/snapshot.rs @@ -26,6 +26,8 @@ pub struct SnapshotRuntimeSettings { pub request_interval_ms: u64, pub rotation_strategy: String, pub model_mappings: BTreeMap, + pub min_codex_desktop_version: Option, + pub min_codex_cli_version: Option, } #[derive(Debug, Clone, PartialEq, Eq)] @@ -143,6 +145,8 @@ impl SnapshotStorePort for PgRuntimeSnapshotRepository { data.settings.request_interval_ms, data.settings.rotation_strategy, data.settings.model_mappings, + data.settings.min_codex_desktop_version, + data.settings.min_codex_cli_version, ); let client_policies = data .client_api_keys @@ -222,11 +226,14 @@ async fn load_settings( i64, String, sqlx::types::Json>, + Option, + Option, ), >( "select config_revision, refresh_margin_seconds, refresh_concurrency, max_concurrent_per_account, request_interval_ms, rotation_strategy, - model_mappings_json + model_mappings_json, min_codex_desktop_version, + min_codex_cli_version from runtime_settings where id = 1", ) .fetch_optional(&mut **transaction) @@ -245,6 +252,8 @@ async fn load_settings( request_interval_ms: to_u64(row.4)?, rotation_strategy: row.5, model_mappings: row.6.0, + min_codex_desktop_version: row.7, + min_codex_cli_version: row.8, }, )) } diff --git a/backend/crates/gateway-store/tests/postgres/runtime_settings.rs b/backend/crates/gateway-store/tests/postgres/runtime_settings.rs index 55a9143e5..0cd15f002 100644 --- a/backend/crates/gateway-store/tests/postgres/runtime_settings.rs +++ b/backend/crates/gateway-store/tests/postgres/runtime_settings.rs @@ -19,6 +19,8 @@ fn settings_with_margin(refresh_margin_seconds: u64) -> RuntimeSettingsUpdate { ("gpt-5.4".to_owned(), "gpt-5.5".to_owned()), ("grok-latest".to_owned(), "grok-4.5".to_owned()), ]), + min_codex_desktop_version: None, + min_codex_cli_version: None, usage_retention_days: 31, ops_event_retention_days: 30, audit_retention_days: 90, @@ -41,6 +43,16 @@ fn runtime_settings_reject_invalid_model_mapping() { assert!(settings.validate().is_err()); } +#[test] +fn runtime_settings_reject_non_semver_client_min() { + let settings = RuntimeSettingsUpdate { + min_codex_cli_version: Some("v0.40.0".to_owned()), + ..settings_with_margin(3_600) + }; + + assert!(settings.validate().is_err()); +} + #[tokio::test] async fn refresh_margin_change_should_preserve_existing_account_refresh_facts() { let Some(database) = TestDatabase::create("refresh_margin_reschedule").await else { @@ -69,6 +81,33 @@ async fn refresh_margin_change_should_preserve_existing_account_refresh_facts() database.close().await; } +#[tokio::test] +async fn client_min_versions_should_round_trip_as_nullable_settings() { + let Some(database) = TestDatabase::create("client_min_versions").await else { + return; + }; + let repository = PgRuntimeSettingsRepository::new(database.pool.clone()); + let mut update = settings_with_margin(3_600); + update.min_codex_desktop_version = Some("26.825.6671".to_owned()); + update.min_codex_cli_version = Some("0.40.0".to_owned()); + + repository + .update_runtime_settings(update) + .await + .expect("update client min versions"); + let settings = repository + .load_runtime_settings() + .await + .expect("load client min versions"); + + assert_eq!( + settings.min_codex_desktop_version.as_deref(), + Some("26.825.6671") + ); + assert_eq!(settings.min_codex_cli_version.as_deref(), Some("0.40.0")); + database.close().await; +} + #[tokio::test] async fn unchanged_refresh_margin_should_preserve_existing_account_refresh_facts() { let Some(database) = TestDatabase::create("refresh_margin_unchanged").await else { diff --git a/backend/crates/providers/openai/src/transport/profile/desktop_artifact.rs b/backend/crates/providers/openai/src/transport/profile/desktop_artifact.rs index 080430ed1..a103c1535 100644 --- a/backend/crates/providers/openai/src/transport/profile/desktop_artifact.rs +++ b/backend/crates/providers/openai/src/transport/profile/desktop_artifact.rs @@ -58,14 +58,19 @@ struct CentralDirectory { entries: u16, } +/// ZIP central directory 中已经校验的 Codex Core 条目。 #[derive(Debug)] -pub(super) struct CoreEntry { - pub(super) name: Vec, +pub struct CoreEntry { + /// ZIP 内完整条目名。 + pub name: Vec, flags: u16, compression: u16, - pub(super) compressed_size: u64, - pub(super) uncompressed_size: u64, - pub(super) local_header_offset: u64, + /// 压缩后的字节数。 + pub compressed_size: u64, + /// 解压后的字节数。 + pub uncompressed_size: u64, + /// local file header 在制品内的偏移。 + pub local_header_offset: u64, } pub(super) async fn fetch_codex_core_version( @@ -184,7 +189,9 @@ async fn read_range( Ok(body) } -pub(super) fn parse_content_range(value: &str) -> Option<(u64, u64, u64)> { +/// 解析严格的 `bytes start-end/size` Content-Range。 +#[must_use] +pub fn parse_content_range(value: &str) -> Option<(u64, u64, u64)> { let value = value.strip_prefix("bytes ")?; let (range, size) = value.split_once('/')?; let (start, end) = range.split_once('-')?; @@ -240,7 +247,12 @@ fn parse_eocd( }) } -pub(super) fn find_core_entry( +/// 在 central directory 中定位唯一的 bundled Codex Core。 +/// +/// # Errors +/// +/// ZIP 元数据无效、条目不受支持,或 Core 不唯一时返回制品错误。 +pub fn find_core_entry( central: &[u8], expected_entries: u16, ) -> Result { @@ -466,16 +478,19 @@ fn validate_macho_header(header: &[u8]) -> Result<(), CodexDesktopArtifactError> Ok(()) } +/// 跨分块查找 bundled Core 版本标记的有界扫描器。 #[derive(Default)] -pub(super) struct CoreVersionScanner { +pub struct CoreVersionScanner { carry: Vec, } impl CoreVersionScanner { - pub(super) fn push( - &mut self, - bytes: &[u8], - ) -> Result, CodexDesktopArtifactError> { + /// 推入一个解压分块,并在发现完整合法版本时返回它。 + /// + /// # Errors + /// + /// 标记后的版本超过限制或不满足受支持格式时返回制品错误。 + pub fn push(&mut self, bytes: &[u8]) -> Result, CodexDesktopArtifactError> { let mut window = Vec::with_capacity(self.carry.len() + bytes.len()); window.extend_from_slice(&self.carry); window.extend_from_slice(bytes); diff --git a/backend/crates/providers/openai/src/transport/profile/mod.rs b/backend/crates/providers/openai/src/transport/profile/mod.rs index 2db909c5f..cdcd0b92e 100644 --- a/backend/crates/providers/openai/src/transport/profile/mod.rs +++ b/backend/crates/providers/openai/src/transport/profile/mod.rs @@ -19,7 +19,7 @@ use url::Url; use self::desktop_artifact::{CodexDesktopArtifactError, fetch_codex_core_version}; -mod desktop_artifact; +pub mod desktop_artifact; /// Codex Desktop 官方 appcast 地址。 pub const CODEX_DESKTOP_APPCAST_URL: &str = diff --git a/backend/crates/providers/openai/tests/transport/profile/desktop_artifact.rs b/backend/crates/providers/openai/tests/transport/profile/desktop_artifact.rs index e50627f8f..984f444fe 100644 --- a/backend/crates/providers/openai/tests/transport/profile/desktop_artifact.rs +++ b/backend/crates/providers/openai/tests/transport/profile/desktop_artifact.rs @@ -1,8 +1,6 @@ -#[allow(dead_code)] -#[path = "../../../src/transport/profile/desktop_artifact.rs"] -mod subject; - -use subject::{CoreVersionScanner, find_core_entry, parse_content_range}; +use provider_openai::transport::profile::desktop_artifact::{ + CoreVersionScanner, find_core_entry, parse_content_range, +}; #[test] fn scanner_accepts_stable_and_multi_part_alpha_versions_across_chunks() { diff --git a/backend/migrations/.frozen-sha256 b/backend/migrations/.frozen-sha256 index f5c6e7efc..53c56135e 100644 --- a/backend/migrations/.frozen-sha256 +++ b/backend/migrations/.frozen-sha256 @@ -1,2 +1,3 @@ 60bcece284f2d6a2cd3e8dea67833dfc1c7da625cdd5f0b63884c725fa00a3d4 0001_initial.sql 93219760c733616e7bba9a8933c207b26752b2ebf7e6edfe1ef42ba227c50a7d 0002_runtime_pressure_observability.sql +1b6ffab434c754814aebb0eee3b650bd9770010059472bccf5bc856b319880ca 0003_client_min_versions.sql diff --git a/backend/migrations/0003_client_min_versions.sql b/backend/migrations/0003_client_min_versions.sql new file mode 100644 index 000000000..55c3d1bfd --- /dev/null +++ b/backend/migrations/0003_client_min_versions.sql @@ -0,0 +1,21 @@ +alter table runtime_settings + add column min_codex_desktop_version text, + add column min_codex_cli_version text, + add constraint runtime_settings_client_versions_ck check ( + ( + min_codex_desktop_version is null + or ( + octet_length(min_codex_desktop_version) between 1 and 64 + and min_codex_desktop_version = btrim(min_codex_desktop_version) + and min_codex_desktop_version !~ '[[:cntrl:]]' + ) + ) + and ( + min_codex_cli_version is null + or ( + octet_length(min_codex_cli_version) between 1 and 64 + and min_codex_cli_version = btrim(min_codex_cli_version) + and min_codex_cli_version !~ '[[:cntrl:]]' + ) + ) + ); diff --git a/docs/api.md b/docs/api.md index a3273b663..4f95d64cd 100644 --- a/docs/api.md +++ b/docs/api.md @@ -17,6 +17,28 @@ Client Key 通过账号分组限定路由范围:未绑定分组时可使用全 已启用分组成员的并集。分组可以混合 `openai` 与 `xai` 账号;同一请求只会在模型能力明确匹配且满足 重放安全边界时跨 Provider fallback。 +运行设置可以分别配置 `minCodexDesktopVersion` 与 `minCodexCliVersion`。两者只接受 SemVer,`null` +表示不限制。API 在 Client Key 鉴权成功后识别官方 Desktop/CLI 请求头;已识别客户端没有合法版本,或版本 +低于对应门槛时,所有 `/v1/*` HTTP 请求和新 WebSocket 握手在访问上游前返回 `426 Upgrade Required`。 +未知客户端保持兼容,不应用版本门禁。 + +低版本响应使用 OpenAI 风格错误合同: + +```json +{ + "error": { + "message": "Codex CLI 0.151.0 is below the minimum required version 0.152.0. Upgrade Codex CLI and retry.", + "type": "invalid_request_error", + "code": "client_version_too_old", + "client": "codex_cli", + "current_version": "0.151.0", + "min_version": "0.152.0" + } +} +``` + +已识别但缺失或携带非法版本时,`code` 为 `client_version_unavailable`,`current_version` 为 `null`。 + ### 管理接口 除登录、会话状态和登出外,所有 `/api/admin/*` 请求都需要以下任一鉴权方式: @@ -375,6 +397,7 @@ PostgreSQL 或 Redis。管理端只在用户打开弹窗或点击刷新时调用 | --- | --- | --- | | `GET` | `/api/admin/settings` | 读取运行设置 | | `POST` | `/api/admin/settings/update` | 原子替换全部运行设置 | +| `GET` | `/api/admin/settings/client-downloads/codex-desktop/windows` | 提取 Codex Desktop Windows 离线安装直链;`refresh=true` 强制刷新进程内短缓存 | | `GET` | `/api/admin/settings/admin-api-key` | 只返回管理 API Key 是否存在 | | `POST` | `/api/admin/settings/admin-api-key/delete` | 删除管理 API Key | | `POST` | `/api/admin/settings/admin-api-key/regenerate` | 重新生成并一次性返回完整管理 API Key | @@ -388,12 +411,44 @@ refreshConcurrency maxConcurrentPerAccount requestIntervalMs rotationStrategy +minCodexDesktopVersion +minCodexCliVersion usageRetentionDays opsEventRetentionDays auditRetentionDays ``` `rotationStrategy` 可取 `smart`、`quota_reset_priority`、`round_robin`、`sticky`。 +两个 `minCodex*Version` 字段为 `string | null`,只设置最低版本,不存在最大版本字段。 + +Windows 离线包接口固定解析 Microsoft Store Product ID `9PLM9XGG6VKS` 的 Retail 包,不接受调用方提供 +产品 ID、上游地址、ring 或文件名。后端只返回通过包名、架构、Microsoft CDN host/path、scheme 和失效 +时间校验的 `x64` / `arm64` MSIX 直链,不代理安装包字节。Store 内容通道返回 HTTP/80 临时地址时保留 +原始 scheme,不强制改写为该 host 不保证支持的 HTTPS。动态链接不足 10 分钟即失效时不会下发;某个架构 +解析失败时只将该架构降级到 OpenAI 官方 HTTPS 稳定 MSIX,并通过 `warning` 说明。响应形状为: + +```json +{ + "resolvedAt": "2026-09-01T06:30:00Z", + "cached": false, + "warning": null, + "packages": [ + { + "architecture": "x64", + "source": "microsoft_store", + "version": "26.825.6671.0", + "fileName": "OpenAI.Codex_26.825.6671.0_x64__2p2nqsd0c76g0.msix", + "sizeBytes": 744250000, + "downloadUrl": "http://dl.delivery.mp.microsoft.com/filestreamingservice/files/...", + "expiresAt": "2026-09-01T07:30:00Z" + } + ] +} +``` + +`source` 为 `microsoft_store` 或 `official_openai`。Store 的四段 package version 只用于下载展示,不参与 +Desktop 三段 SemVer 门禁,也不会自动回写最低版本设置。完整安全与回退设计见 +[客户端最低版本与下载方案](client-min-version-plan.md)。 ## 9. 备份 diff --git a/docs/architecture.md b/docs/architecture.md index 5920f3d76..3792acd35 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -43,6 +43,8 @@ flowchart LR Store --> Object[(S3 / R2)] Host[gateway-host] -. lifecycle / workers / update .-> API + Host -. lazy client distribution resolver .-> Admin + Host --> StoreLinks[RG-Adguard / Microsoft CDN] ``` `backend/apps/gateway` 是唯一组合根,按 Host → Store → Provider → Core → Admin → API → Worker 的顺序 @@ -95,9 +97,14 @@ sequenceDiagram E->>S: enqueue terminal observation and accounting ``` -请求开始时冻结 `RuntimeSnapshot`、Client Key 的账号范围、模型映射、Provider 候选顺序和调度策略。 +请求开始时冻结 `RuntimeSnapshot`、Client Key 的账号范围、模型映射、Codex 客户端最低版本、Provider +候选顺序和调度策略。 运行中的请求不再拼接新旧配置,也不在热路径查询分组关系。 +Client Key 鉴权完成后,API adapter 从有界请求头识别 Codex Desktop/CLI,Core 使用同一请求冻结的 +`RuntimeSnapshot` 比较对应最低版本。Desktop 优先于其 User-Agent 内嵌的 CLI/Core 标记;未知客户端不 +应用门禁。低版本或已识别但版本不可用时,在进入 Provider 前返回稳定的 `426` 合同。 + 核心不变量: - 一个客户端请求对应一条 `model_requests`;attempt 是请求内事实,不建立第二张权威表。 @@ -190,6 +197,7 @@ PostgreSQL 周期对账才是正确性基础。 | 日志、OAuth 恢复记录、在线更新状态、备份暂存 | `.runtime/` | 部署节点本地运行文件 | | 重置卡库存与消费结果 | OpenAI upstream | 后端不建立本地卡库存;前端只保留当前浏览器会话的最近查询 | | Provider 公开模型与请求画像 | Provider/runtime cache | 由官方目录或发布源刷新,不写成第二份业务配置 | +| Windows 安装包临时直链 | Host 进程内短缓存 | 按需解析、严格校验、到期前丢弃;不写 PostgreSQL/Redis,也不代理包字节 | 账号对外状态不是独立列,而是 PostgreSQL credential/quota 事实与 Redis cooldown 的统一投影: `normal`、`quota_exhausted`、`rate_limited`、`disabled`、`error`。只有明确上游证据才能恢复或终态化账号, @@ -237,6 +245,10 @@ Worker 由各 Bundle 贡献、由 Host 统一监督: 启动只有在配置、PostgreSQL、Redis、Provider、Core、Admin、API 和 Worker 全部初始化成功后才进入服务。 健康检查综合 Core、Store 与 Worker 状态,但不会把单个 Provider 的业务降级等同于整个进程失活。 +组合根在启动时只把 Host 的 `ClientDistributionResolver` 能力注入 Admin;此时不访问 RG-Adguard,下载 +HTTP Client 构造失败也不会阻断网关启动。外部解析在已认证管理员首次打开客户端下载弹窗时惰性执行, +失败时按架构使用 OpenAI 官方稳定地址。Microsoft Store 内容通道返回的 HTTP/80 临时链接保留原始 scheme, +不会被错误改写到该 host 不保证支持的 HTTPS 虚拟主机。 关闭分为两段:先停止接收新连接并 drain HTTP/WS,再取消并等待 Worker;两段各有独立预算,Compose 的 `stop_grace_period` 必须覆盖二者之和。超时后只丢弃仍未落盘的可恢复观测,不执行隐式业务重放。 diff --git a/frontend/src/api/modules/settings.ts b/frontend/src/api/modules/settings.ts index b57ee1197..68bed568d 100644 --- a/frontend/src/api/modules/settings.ts +++ b/frontend/src/api/modules/settings.ts @@ -9,12 +9,34 @@ export interface RuntimeSettings { maxConcurrentPerAccount: number requestIntervalMs: number rotationStrategy: RotationStrategy + minCodexDesktopVersion: string | null + minCodexCliVersion: string | null usageRetentionDays: number opsEventRetentionDays: number auditRetentionDays: number updatedAt: string } +export type ClientArchitecture = 'x64' | 'arm64' +export type ClientDownloadSource = 'microsoft_store' | 'official_openai' + +export interface ClientDownloadPackage { + architecture: ClientArchitecture + source: ClientDownloadSource + version: string | null + fileName: string + sizeBytes: number | null + downloadUrl: string + expiresAt: string | null +} + +export interface CodexDesktopWindowsDownloads { + resolvedAt: string + cached: boolean + warning: string | null + packages: ClientDownloadPackage[] +} + export interface AdminApiKeyStatus { exists: boolean } @@ -64,3 +86,11 @@ export function deleteAdminApiKey() { method: 'POST', }) } + +export function getCodexDesktopWindowsDownloads(refresh = false) { + return request({ + url: '/api/admin/settings/client-downloads/codex-desktop/windows', + method: 'GET', + params: refresh ? { refresh: true } : undefined, + }) +} diff --git a/frontend/src/components/base/BaseModal/index.vue b/frontend/src/components/base/BaseModal/index.vue index 49838f5d3..375145467 100644 --- a/frontend/src/components/base/BaseModal/index.vue +++ b/frontend/src/components/base/BaseModal/index.vue @@ -1,5 +1,6 @@ + + diff --git a/frontend/src/views/settings/components/client-version/components/ClientDesktopInstallPanel.vue b/frontend/src/views/settings/components/client-version/components/ClientDesktopInstallPanel.vue new file mode 100644 index 000000000..a2db58962 --- /dev/null +++ b/frontend/src/views/settings/components/client-version/components/ClientDesktopInstallPanel.vue @@ -0,0 +1,77 @@ + + + diff --git a/frontend/src/views/settings/components/client-version/components/ClientDesktopOfflineDownloads.vue b/frontend/src/views/settings/components/client-version/components/ClientDesktopOfflineDownloads.vue new file mode 100644 index 000000000..8e28a65bc --- /dev/null +++ b/frontend/src/views/settings/components/client-version/components/ClientDesktopOfflineDownloads.vue @@ -0,0 +1,169 @@ + + + diff --git a/frontend/src/views/settings/components/client-version/components/ClientInstallCommandList.vue b/frontend/src/views/settings/components/client-version/components/ClientInstallCommandList.vue new file mode 100644 index 000000000..47241c1c2 --- /dev/null +++ b/frontend/src/views/settings/components/client-version/components/ClientInstallCommandList.vue @@ -0,0 +1,41 @@ + + + diff --git a/frontend/src/views/settings/components/client-version/components/ClientVersionGuideModal.vue b/frontend/src/views/settings/components/client-version/components/ClientVersionGuideModal.vue new file mode 100644 index 000000000..4a425bcbe --- /dev/null +++ b/frontend/src/views/settings/components/client-version/components/ClientVersionGuideModal.vue @@ -0,0 +1,81 @@ + + + diff --git a/frontend/src/views/settings/components/client-version/components/ClientVersionPolicyCard.vue b/frontend/src/views/settings/components/client-version/components/ClientVersionPolicyCard.vue new file mode 100644 index 000000000..f8a45ab5d --- /dev/null +++ b/frontend/src/views/settings/components/client-version/components/ClientVersionPolicyCard.vue @@ -0,0 +1,91 @@ + + + diff --git a/frontend/src/views/settings/components/client-version/composables/useClientDownloads.ts b/frontend/src/views/settings/components/client-version/composables/useClientDownloads.ts new file mode 100644 index 000000000..19d631e31 --- /dev/null +++ b/frontend/src/views/settings/components/client-version/composables/useClientDownloads.ts @@ -0,0 +1,43 @@ +import type { CodexDesktopWindowsDownloads } from '@/api' +import { shallowRef } from 'vue' + +import { getCodexDesktopWindowsDownloads } from '@/api' +import { errorMessage } from '@/utils/async' + +export function useClientDownloads() { + const open = shallowRef(false) + const loading = shallowRef(false) + const error = shallowRef('') + const downloads = shallowRef(null) + + async function load(refresh = false): Promise { + if (loading.value) + return + loading.value = true + error.value = '' + try { + downloads.value = await getCodexDesktopWindowsDownloads(refresh) + } + catch (cause: unknown) { + error.value = errorMessage(cause, 'Windows 离线安装包加载失败') + } + finally { + loading.value = false + } + } + + function showGuide() { + open.value = true + if (!downloads.value) + void load() + } + + return { + open, + loading, + error, + downloads, + load, + showGuide, + } +} diff --git a/frontend/src/views/settings/components/client-version/index.vue b/frontend/src/views/settings/components/client-version/index.vue new file mode 100644 index 000000000..3a75e1d35 --- /dev/null +++ b/frontend/src/views/settings/components/client-version/index.vue @@ -0,0 +1,52 @@ + + + diff --git a/frontend/src/views/settings/composables/useSettingsForm.ts b/frontend/src/views/settings/composables/useSettingsForm.ts index a485ee4a7..80ae90721 100644 --- a/frontend/src/views/settings/composables/useSettingsForm.ts +++ b/frontend/src/views/settings/composables/useSettingsForm.ts @@ -18,6 +18,8 @@ export function useSettingsForm() { maxConcurrentPerAccount: null as number | null, requestIntervalMs: null as number | null, rotationStrategy: '' as RotationStrategy | '', + minCodexDesktopVersion: '', + minCodexCliVersion: '', usageRetentionDays: 31, opsEventRetentionDays: 30, auditRetentionDays: 90, @@ -41,6 +43,13 @@ export function useSettingsForm() { const refreshConcurrencyValue = numericModel('refreshConcurrency') const maxConcurrentPerAccountValue = numericModel('maxConcurrentPerAccount') const requestIntervalMsValue = numericModel('requestIntervalMs') + const minCodexDesktopVersionError = computed(() => versionError(form.minCodexDesktopVersion)) + const minCodexCliVersionError = computed(() => versionError(form.minCodexCliVersion)) + + function versionError(value: string): string { + const normalized = value.trim() + return normalized && !isSemver(normalized) ? '请输入标准 SemVer,例如 0.152.0' : '' + } function applySettings(data: Awaited>) { form.refreshMarginSeconds = data.refreshMarginSeconds @@ -48,6 +57,8 @@ export function useSettingsForm() { form.maxConcurrentPerAccount = data.maxConcurrentPerAccount form.requestIntervalMs = data.requestIntervalMs form.rotationStrategy = data.rotationStrategy + form.minCodexDesktopVersion = data.minCodexDesktopVersion ?? '' + form.minCodexCliVersion = data.minCodexCliVersion ?? '' form.usageRetentionDays = data.usageRetentionDays form.opsEventRetentionDays = data.opsEventRetentionDays form.auditRetentionDays = data.auditRetentionDays @@ -112,6 +123,10 @@ export function useSettingsForm() { toast.warning('请完整填写运行参数和调度策略') return } + if (minCodexDesktopVersionError.value || minCodexCliVersionError.value) { + toast.warning('请修正客户端最低版本格式') + return + } try { saving.value = true const result = await updateSettings({ @@ -121,6 +136,8 @@ export function useSettingsForm() { maxConcurrentPerAccount, requestIntervalMs, rotationStrategy, + minCodexDesktopVersion: form.minCodexDesktopVersion.trim() || null, + minCodexCliVersion: form.minCodexCliVersion.trim() || null, usageRetentionDays: form.usageRetentionDays, opsEventRetentionDays: form.opsEventRetentionDays, auditRetentionDays: form.auditRetentionDays, @@ -151,7 +168,50 @@ export function useSettingsForm() { refreshConcurrencyValue, maxConcurrentPerAccountValue, requestIntervalMsValue, + minCodexDesktopVersionError, + minCodexCliVersionError, saveSettings, loadSettings, } } + +function isSemver(value: string): boolean { + if (value.length > 64 || value.startsWith('v')) + return false + + const buildParts = value.split('+') + if (buildParts.length > 2) + return false + const [versionAndPrerelease = '', build] = buildParts + if (build !== undefined && !validIdentifiers(build, false)) + return false + + const prereleaseSeparator = versionAndPrerelease.indexOf('-') + const core = prereleaseSeparator < 0 + ? versionAndPrerelease + : versionAndPrerelease.slice(0, prereleaseSeparator) + const prerelease = prereleaseSeparator < 0 + ? undefined + : versionAndPrerelease.slice(prereleaseSeparator + 1) + if (prerelease !== undefined && !validIdentifiers(prerelease, true)) + return false + + const coreParts = core.split('.') + return coreParts.length === 3 && coreParts.every(validCoreNumericIdentifier) +} + +function validIdentifiers(value: string, rejectNumericLeadingZeros: boolean): boolean { + return Boolean(value) && value.split('.').every((identifier) => { + if (!identifier || !/^[\da-z-]+$/i.test(identifier)) + return false + return !rejectNumericLeadingZeros || !/^\d+$/.test(identifier) || validNumericIdentifier(identifier) + }) +} + +function validNumericIdentifier(value: string): boolean { + return /^(?:0|[1-9]\d*)$/.test(value) +} + +function validCoreNumericIdentifier(value: string): boolean { + return validNumericIdentifier(value) && BigInt(value) <= 18_446_744_073_709_551_615n +} diff --git a/frontend/src/views/settings/index.vue b/frontend/src/views/settings/index.vue index 4c9cc90d9..9bb78634a 100644 --- a/frontend/src/views/settings/index.vue +++ b/frontend/src/views/settings/index.vue @@ -10,6 +10,7 @@ import BaseSegmented from '@/components/base/BaseSegmented.vue' import AdminApiKeyCard from './components/AdminApiKeyCard.vue' import SettingsBackupSection from './components/backup/SettingsBackupSection.vue' +import ClientVersionSettings from './components/client-version/index.vue' import ModelAliasesCard from './components/ModelAliasesCard.vue' import RotationStrategyCard from './components/RotationStrategyCard.vue' import RuntimeSettingsCard from './components/RuntimeSettingsCard.vue' @@ -48,6 +49,8 @@ const { refreshConcurrencyValue, maxConcurrentPerAccountValue, requestIntervalMsValue, + minCodexDesktopVersionError, + minCodexCliVersionError, saveSettings, loadSettings, } = useSettingsForm() @@ -126,6 +129,14 @@ watch( v-model:request-interval-ms="requestIntervalMsValue" /> + +