diff --git a/backend/crates/gateway-store/src/postgres/proxies.rs b/backend/crates/gateway-store/src/postgres/proxies.rs index fe338a1fe..dd61ebb81 100644 --- a/backend/crates/gateway-store/src/postgres/proxies.rs +++ b/backend/crates/gateway-store/src/postgres/proxies.rs @@ -815,6 +815,11 @@ impl ProxyStore for PgProxyRepository { .await .map_err(store_error)?; if current.revision != revision { + // Drop 只排队回滚,返回冲突前需释放事务锁,避免误挡紧接着的编辑。 + transaction + .rollback() + .await + .map_err(|_| store_error(unavailable()))?; return Err(store_error(conflict(id))); } save_test(&mut transaction, ¤t, result) diff --git a/backend/crates/gateway-store/tests/postgres/proxies.rs b/backend/crates/gateway-store/tests/postgres/proxies.rs index 6280b8240..aa5309549 100644 --- a/backend/crates/gateway-store/tests/postgres/proxies.rs +++ b/backend/crates/gateway-store/tests/postgres/proxies.rs @@ -517,6 +517,80 @@ async fn rejected_import_reservations_release_proxy_lock_before_returning() { database.close().await; } +#[tokio::test] +async fn stale_proxy_test_releases_lock_before_next_update() { + let Some(database) = TestDatabase::create("stale_proxy_test_lock").await else { + return; + }; + let store = PgProxyRepository::new(database.pool.clone()); + let context = context(); + let mut saved = store + .create( + NewProxy { + name: "过期检测结果".to_owned(), + proxy: OutboundProxy::parse("http://127.0.0.1:19090").unwrap(), + auto_location: true, + location: None, + test: None, + }, + &context, + ) + .await + .unwrap() + .record; + let stale_revision = saved.revision; + saved = store + .record_test(&saved.id, saved.revision, success(), &context) + .await + .unwrap() + .record; + + // 暂停连接归还时的后台清理,确保锁由返回错误前的显式回滚释放。 + let release_gate = std::sync::Arc::new(tokio::sync::Semaphore::new(0)); + let rejected_pool = database + .pool + .options() + .clone() + .max_connections(1) + .after_release({ + let release_gate = release_gate.clone(); + move |_, _| { + let release_gate = release_gate.clone(); + Box::pin(async move { + let _permit = release_gate.acquire().await.unwrap(); + Ok(true) + }) + } + }) + .connect_with((*database.pool.connect_options()).clone()) + .await + .unwrap(); + let stale_writer = PgProxyRepository::new(rejected_pool.clone()); + let error = stale_writer + .record_test(&saved.id, stale_revision, success(), &context) + .await + .unwrap_err(); + let update = store + .update( + UpdateProxy { + id: saved.id.clone(), + revision: saved.revision, + name: saved.name.clone(), + proxy: None, + auto_location: None, + location: None, + test: None, + }, + &context, + ) + .await; + release_gate.add_permits(1); + rejected_pool.close().await; + database.close().await; + assert_eq!(error.kind(), AdminStoreErrorKind::Conflict); + assert!(update.is_ok(), "过期检测结果不应阻挡后续编辑: {update:?}"); +} + #[tokio::test] async fn import_reservation_blocks_proxy_mutations_until_rotated_credentials_are_committed() { use gateway_store::postgres::{ diff --git a/backend/crates/providers/openai/src/admin.rs b/backend/crates/providers/openai/src/admin.rs index eca52d1aa..f527354e0 100644 --- a/backend/crates/providers/openai/src/admin.rs +++ b/backend/crates/providers/openai/src/admin.rs @@ -287,47 +287,48 @@ impl ProviderAdmin for OpenAiAdminProvider { &self, configuration: &OpaqueProviderData, ) -> Option { - use crate::transport::profile::selection::{ - ClientKind, ClientPlatform, ClientProfileSelection, VersionMode, - }; - let selection = ClientProfileSelection::parse(configuration).ok()?; + use crate::transport::profile::identity::RequestProfileSelection; + use crate::transport::profile::selection::{ClientKind, ClientPlatform, VersionMode}; + let selection = RequestProfileSelection::parse(configuration).ok()?; let profile = selection.resolve(&self.profile).ok()?; - let custom = selection.version_mode == VersionMode::Fixed; - let (checked_at, error) = self.profile.client_release_status( - selection.client, - selection.platform, - selection.architecture(), - ); - let release = if custom { - None - } else if selection.client == ClientKind::Desktop - && selection.platform == ClientPlatform::Macos - { - Some(dashboard_desktop_release( - &profile, - self.desktop_release.snapshot(), - )) - } else { - Some(DashboardDesktopRelease { - status: if error.is_some() { - DesktopReleaseStatus::Failed - } else if checked_at.is_some() { - DesktopReleaseStatus::Current - } else { - DesktopReleaseStatus::Unchecked - }, - checked_at, - latest_version: Some(profile.codex_version.clone()), - latest_build: None, - published_at: None, - minimum_system_version: None, - hardware_requirements: None, - download_url: None, - download_size: None, - signature_present: None, - error, - }) - }; + let custom = selection.version_mode() == VersionMode::Fixed; + let release = selection.preset().and_then(|selection| { + let (checked_at, error) = self.profile.client_release_status( + selection.client, + selection.platform, + selection.architecture(), + ); + if custom { + None + } else if selection.client == ClientKind::Desktop + && selection.platform == ClientPlatform::Macos + { + Some(dashboard_desktop_release( + &profile, + self.desktop_release.snapshot(), + )) + } else { + Some(DashboardDesktopRelease { + status: if error.is_some() { + DesktopReleaseStatus::Failed + } else if checked_at.is_some() { + DesktopReleaseStatus::Current + } else { + DesktopReleaseStatus::Unchecked + }, + checked_at, + latest_version: Some(profile.codex_version.clone()), + latest_build: None, + published_at: None, + minimum_system_version: None, + hardware_requirements: None, + download_url: None, + download_size: None, + signature_present: None, + error, + }) + } + }); Some(DashboardWireProfile { provider: self.provider_kind.as_str().to_owned(), product: profile.originator.clone(), @@ -343,7 +344,7 @@ impl ProviderAdmin for OpenAiAdminProvider { attributes: vec![ DashboardWireAttribute { label: "客户端标识".to_owned(), - value: if selection.client == ClientKind::Desktop { + value: if profile.client_kind == ClientKind::Desktop { format!("{}; {}", profile.originator, profile.desktop_version) } else { profile.originator @@ -352,14 +353,15 @@ impl ProviderAdmin for OpenAiAdminProvider { DashboardWireAttribute { label: "版本策略".to_owned(), value: if custom { - "固定自定义" + "固定身份" } else { "自动最新" } .to_owned(), }, ], - verified_at: (!custom).then_some(profile.verified_at), + verified_at: (!custom && profile.verified_at != chrono::DateTime::UNIX_EPOCH) + .then_some(profile.verified_at), release, }) } @@ -1934,6 +1936,15 @@ fn map_client_profile_error( use crate::transport::profile::selection::ClientProfileError; let message = match error { ClientProfileError::Invalid => "客户端身份字段或版本组合不合法", + ClientProfileError::InvalidUserAgent => { + "User-Agent 必须是 1 至 4096 字节的单行 ASCII 文本,且首尾不能含空白" + } + ClientProfileError::CompanionHeadersRequired => { + "无法识别 User-Agent,请补充 originator 和有效的 Core version" + } + ClientProfileError::CompanionHeadersConflict => { + "originator 或 Core version 与 User-Agent 不一致" + } ClientProfileError::ReleaseUnavailable => { "此客户端、平台与架构尚无已核验发布版本,请选择固定版本或稍后重试" } diff --git a/backend/crates/providers/openai/src/credential/catalog.rs b/backend/crates/providers/openai/src/credential/catalog.rs index 0ff747b48..51f2c5ee4 100644 --- a/backend/crates/providers/openai/src/credential/catalog.rs +++ b/backend/crates/providers/openai/src/credential/catalog.rs @@ -277,7 +277,7 @@ impl CodexCredentialCatalogService { .map_err(|_| CodexCredentialCatalogError::InvalidCredentialData)?; let request_profile = match scope.request_profile(&provider) { Some(configuration) => { - crate::transport::profile::selection::ClientProfileSelection::parse(configuration) + crate::transport::profile::identity::RequestProfileSelection::parse(configuration) .and_then(|selection| selection.resolve(&self.profile)) .map_err(|_| CodexCredentialCatalogError::InvalidCredentialData)? } diff --git a/backend/crates/providers/openai/src/lib.rs b/backend/crates/providers/openai/src/lib.rs index 9bb64b314..099c00c70 100644 --- a/backend/crates/providers/openai/src/lib.rs +++ b/backend/crates/providers/openai/src/lib.rs @@ -69,7 +69,7 @@ pub async fn initialize( .initialize_request_profile(&provider_kind, initial_profile) .await .map_err(|_| OpenAiInitializeError::RuntimePolicy)?; - transport::profile::selection::ClientProfileSelection::parse(&configured_profile) + transport::profile::identity::RequestProfileSelection::parse(&configured_profile) .map_err(|_| OpenAiInitializeError::RuntimePolicy)?; let credential_state = ports.credential_state(); let profile = diff --git a/backend/crates/providers/openai/src/provider/mod.rs b/backend/crates/providers/openai/src/provider/mod.rs index 4bf006e06..83f77c15e 100644 --- a/backend/crates/providers/openai/src/provider/mod.rs +++ b/backend/crates/providers/openai/src/provider/mod.rs @@ -297,7 +297,7 @@ impl Provider for CodexProvider { configuration: &gateway_core::account::OpaqueProviderData, ) -> Result { let selection = - crate::transport::profile::selection::ClientProfileSelection::parse(configuration) + crate::transport::profile::identity::RequestProfileSelection::parse(configuration) .map_err(|_| { provider_error( ProviderErrorKind::InvalidRequest, diff --git a/backend/crates/providers/openai/src/transport/client_sse.rs b/backend/crates/providers/openai/src/transport/client_sse.rs index eff9cf786..cd56b5541 100644 --- a/backend/crates/providers/openai/src/transport/client_sse.rs +++ b/backend/crates/providers/openai/src/transport/client_sse.rs @@ -651,14 +651,19 @@ fn websocket_connection_profile( headers: &HeaderMap, middleware_headers: &[gateway_core::engine::middleware::MiddlewareHeader], ) -> String { - let mut profile = ["originator", "user-agent", X_OPENAI_MEMGEN_REQUEST_HEADER] - .map(|name| { - headers - .get(name) - .and_then(|value| value.to_str().ok()) - .unwrap_or_default() - }) - .join("\0"); + let mut profile = [ + "originator", + "user-agent", + "version", + X_OPENAI_MEMGEN_REQUEST_HEADER, + ] + .map(|name| { + headers + .get(name) + .and_then(|value| value.to_str().ok()) + .unwrap_or_default() + }) + .join("\0"); if !middleware_headers.is_empty() { use sha2::{Digest, Sha256}; diff --git a/backend/crates/providers/openai/src/transport/profile/identity.rs b/backend/crates/providers/openai/src/transport/profile/identity.rs new file mode 100644 index 000000000..ccf7489e4 --- /dev/null +++ b/backend/crates/providers/openai/src/transport/profile/identity.rs @@ -0,0 +1,255 @@ +//! 预设与完整自定义 UA 的统一解析边界,身份配套头由 Provider 所有。 + +use chrono::DateTime; +use gateway_core::account::OpaqueProviderData; +use serde::Deserialize; +use serde_json::{Value, json}; + +use super::selection::{ + ClientKind, ClientProfileError, ClientProfileSelection, VersionMode, object, +}; +use super::{CodexWireProfile, CodexWireProfileState}; + +/// 已解析的配置;没有 mode 的预设配置复用官方版本与平台合同。 +#[derive(Debug, Clone)] +pub struct RequestProfileSelection(ParsedSelection); + +#[derive(Debug, Clone)] +enum ParsedSelection { + Preset(ClientProfileSelection), + Custom(ExactIdentity), +} + +#[derive(Deserialize)] +#[serde(tag = "mode", rename_all = "snake_case", deny_unknown_fields)] +enum SelectionDocument { + Custom { + #[serde(rename = "userAgent")] + user_agent: String, + originator: Option, + #[serde(rename = "codexVersion")] + codex_version: Option, + }, +} + +impl RequestProfileSelection { + pub fn preset(&self) -> Option<&ClientProfileSelection> { + match &self.0 { + ParsedSelection::Preset(selection) => Some(selection), + _ => None, + } + } + + pub fn version_mode(&self) -> VersionMode { + match &self.0 { + ParsedSelection::Preset(selection) => selection.version_mode, + ParsedSelection::Custom(_) => VersionMode::Fixed, + } + } + + pub fn parse(document: &OpaqueProviderData) -> Result { + let fields = document.expose_to_provider(); + if !fields.contains_key("mode") { + return ClientProfileSelection::parse(document) + .map(ParsedSelection::Preset) + .map(Self); + } + let document = serde_json::from_value(Value::Object(fields.clone())) + .map_err(|_| ClientProfileError::Invalid)?; + let selection = match document { + SelectionDocument::Custom { + user_agent, + originator, + codex_version, + } => ParsedSelection::Custom(ExactIdentity::parse( + &user_agent, + originator.as_deref(), + codex_version.as_deref(), + )?), + }; + Ok(Self(selection)) + } + + pub fn resolve( + &self, + state: &CodexWireProfileState, + ) -> Result { + match &self.0 { + ParsedSelection::Preset(selection) => selection.resolve(state), + ParsedSelection::Custom(identity) => Ok(identity.profile(state)), + } + } + + pub(super) fn preview( + &self, + state: &CodexWireProfileState, + ) -> Result { + match &self.0 { + ParsedSelection::Preset(selection) => state.preview_preset_selection(selection), + ParsedSelection::Custom(identity) => identity.preview(state), + } + } +} + +#[derive(Debug, Clone)] +struct ExactIdentity { + user_agent: String, + originator: String, + codex_version: String, + recognized: bool, + environment: Option, + desktop_version: Option, +} + +impl ExactIdentity { + fn parse( + user_agent: &str, + originator: Option<&str>, + codex_version: Option<&str>, + ) -> Result { + if !safe_header(user_agent, 4096) { + return Err(ClientProfileError::InvalidUserAgent); + } + let known = ["Codex Desktop", "codex-tui", "codex_exec", "codex_cli_rs"] + .into_iter() + .find_map(|name| { + user_agent + .strip_prefix(name) + .and_then(|rest| rest.strip_prefix('/')) + .map(|rest| (name, rest)) + }); + let (originator, codex_version, recognized) = if let Some((name, rest)) = known { + let version = rest.split_once(' ').map_or(rest, |(version, _)| version); + parse_core_version(version)?; + if originator.is_some_and(|value| value != name) + || codex_version.is_some_and(|value| value != version) + { + return Err(ClientProfileError::CompanionHeadersConflict); + } + (name.to_owned(), version.to_owned(), true) + } else { + let originator = originator + .filter(|value| safe_header(value, 128)) + .ok_or(ClientProfileError::CompanionHeadersRequired)?; + let version = codex_version.ok_or(ClientProfileError::CompanionHeadersRequired)?; + parse_core_version(version)?; + (originator.to_owned(), version.to_owned(), false) + }; + let environment = known.and_then(|(_, rest)| UaEnvironment::parse(rest)); + let desktop_version = (originator == "Codex Desktop") + .then(|| { + user_agent + .rsplit_once(" (Codex Desktop; ") + .and_then(|(_, version)| version.strip_suffix(')')) + .filter(|version| super::numeric_dotted_version(version)) + .map(ToOwned::to_owned) + }) + .flatten(); + Ok(Self { + user_agent: user_agent.to_owned(), + originator, + codex_version, + recognized, + environment, + desktop_version, + }) + } + + fn profile(&self, state: &CodexWireProfileState) -> CodexWireProfile { + let environment = self.environment.as_ref(); + CodexWireProfile { + client_kind: if self.originator == "Codex Desktop" { + ClientKind::Desktop + } else { + ClientKind::Cli + }, + originator: self.originator.clone(), + codex_version: self.codex_version.clone(), + desktop_version: self.desktop_version.clone().unwrap_or_default(), + desktop_build: String::new(), + os_type: environment + .map(|value| value.os_type.clone()) + .unwrap_or_default(), + os_version: environment + .map(|value| value.os_version.clone()) + .unwrap_or_default(), + arch: environment + .map(|value| value.arch.clone()) + .unwrap_or_default(), + terminal: environment + .map(|value| value.terminal.clone()) + .unwrap_or_default(), + exact_user_agent: Some(self.user_agent.clone()), + residency: state.snapshot().residency, + verified_at: DateTime::UNIX_EPOCH, + } + } + + fn preview( + &self, + state: &CodexWireProfileState, + ) -> Result { + let profile = self.profile(state); + object(&json!({ + "configuration": { + "mode": "custom", "userAgent": self.user_agent, + "originator": self.originator, "codexVersion": self.codex_version, + }, + "originator": profile.originator, + "osType": profile.os_type, + "osVersion": profile.os_version, + "arch": profile.arch, + "terminal": profile.terminal, + "codexVersion": profile.codex_version, + "desktopVersion": self.desktop_version, + "desktopBuild": null, + "userAgent": profile.user_agent(), + "recognized": self.recognized, + "versionSource": "custom", + "verifiedAt": null, + "checkedAt": null, + "error": null, + })) + } +} + +#[derive(Debug, Clone)] +struct UaEnvironment { + os_type: String, + os_version: String, + arch: String, + terminal: String, +} + +impl UaEnvironment { + fn parse(rest: &str) -> Option { + let (_, rest) = rest.split_once(" (")?; + let (environment, tail) = rest.split_once(") ")?; + let (os, arch) = environment.split_once("; ")?; + let (os_type, os_version) = os.rsplit_once(' ')?; + let terminal = tail.split_once(" (").map_or(tail, |(terminal, _)| terminal); + if os_type.is_empty() || os_version.is_empty() || arch.is_empty() || terminal.is_empty() { + return None; + } + Some(Self { + os_type: os_type.to_owned(), + os_version: os_version.to_owned(), + arch: arch.to_owned(), + terminal: terminal.to_owned(), + }) + } +} + +fn safe_header(value: &str, max_bytes: usize) -> bool { + !value.is_empty() + && value.len() <= max_bytes + && value.trim() == value + && value.bytes().all(|byte| (32..=126).contains(&byte)) +} + +fn parse_core_version(value: &str) -> Result { + if value.len() > 64 { + return Err(ClientProfileError::Invalid); + } + semver::Version::parse(value).map_err(|_| ClientProfileError::Invalid) +} diff --git a/backend/crates/providers/openai/src/transport/profile/mod.rs b/backend/crates/providers/openai/src/transport/profile/mod.rs index 7d828274e..a6dbbe211 100644 --- a/backend/crates/providers/openai/src/transport/profile/mod.rs +++ b/backend/crates/providers/openai/src/transport/profile/mod.rs @@ -22,6 +22,7 @@ use self::desktop_artifact::{CodexDesktopArtifactError, fetch_codex_core_version pub mod cli_release; pub mod desktop_artifact; +pub mod identity; pub mod platform_release; pub mod selection; @@ -48,16 +49,16 @@ pub enum CodexResidency { /// 保留已有配置类型入口;位置规则由出口领域统一维护。 pub use gateway_core::account::RequestLocation as CodexRequestLocation; -/// Codex Desktop 上游请求身份。 +/// Codex 上游请求身份快照。 /// -/// 内置基线提供经源码审计的 Core、运行环境和 Desktop 版本。运行时只会使用 -/// 同一个官方 Desktop ZIP 中核验出的 Core、Desktop 版本及构建号原子替换版本字段。 +/// 预设按官方配套版本生成 UA;自定义身份保留完整 UA 及其配套请求头。 +/// 官方制品核验只更新独立的基线与预设资料,不改写已冻结的请求身份。 #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct CodexWireProfile { pub client_kind: ClientKind, /// `originator` 请求头及 User-Agent 产品名。 pub originator: String, - /// Desktop ZIP 内嵌 Core 版本;用于模型请求的 version、client_version 与 UA。 + /// Core 版本,用于模型请求的 version 和 client_version。 pub codex_version: String, /// Desktop 应用版本,用于 app-server `clientInfo.version` 对应的 UA 后缀。 pub desktop_version: String, @@ -71,6 +72,9 @@ pub struct CodexWireProfile { pub arch: String, /// Codex Core UA 中的终端标记。 pub terminal: String, + /// 入口专属或自定义的完整值;存在时不得用默认预设重新拼接。 + #[serde(default, skip_serializing_if = "Option::is_none")] + pub exact_user_agent: Option, /// 未配置时不发送 residency 头;不随制品版本更新而改变。 pub residency: Option, /// 版本元组最后一次经制品核验的时间;不表示 TLS 传输已重新核验。 @@ -89,6 +93,7 @@ impl Default for CodexWireProfile { os_version: "15.7.1".to_owned(), arch: "arm64".to_owned(), terminal: "unknown".to_owned(), + exact_user_agent: None, residency: None, // 制品核验于 2026-09-06T03:26:12.084Z;进程启动不构成重新核验。 verified_at: DateTime::UNIX_EPOCH + chrono::Duration::milliseconds(1_788_665_172_084), @@ -97,8 +102,11 @@ impl Default for CodexWireProfile { } impl CodexWireProfile { - /// 按 bundled Core app-server 的官方格式生成最终 User-Agent。 + /// 原样返回已解析的完整 UA;默认预设按 bundled Core app-server 格式生成。 pub fn user_agent(&self) -> String { + if let Some(user_agent) = &self.exact_user_agent { + return user_agent.clone(); + } if self.client_kind == ClientKind::Cli { return format!( "{}/{} ({} {}; {}) {}", diff --git a/backend/crates/providers/openai/src/transport/profile/selection.rs b/backend/crates/providers/openai/src/transport/profile/selection.rs index 4980117a8..37effe41a 100644 --- a/backend/crates/providers/openai/src/transport/profile/selection.rs +++ b/backend/crates/providers/openai/src/transport/profile/selection.rs @@ -14,6 +14,23 @@ pub enum ClientKind { Cli, } +/// CLI 共用官方发布版本,但不同入口提供各自的客户端标识和 UA 后缀。 +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum CliEntry { + Tui, + Exec, +} + +impl CliEntry { + pub const fn originator(self) -> &'static str { + match self { + Self::Tui => "codex-tui", + Self::Exec => "codex_exec", + } + } +} + #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] pub enum ClientPlatform { @@ -61,7 +78,9 @@ pub struct ClientProfileSelection { pub client: ClientKind, pub platform: ClientPlatform, pub version_mode: VersionMode, + pub cli_entry: Option, pub originator: Option, + pub os_type: Option, pub os_version: Option, pub arch: Option, pub terminal: Option, @@ -76,7 +95,9 @@ impl Default for ClientProfileSelection { client: ClientKind::Desktop, platform: ClientPlatform::Macos, version_mode: VersionMode::Latest, + cli_entry: None, originator: None, + os_type: None, os_version: None, arch: None, terminal: None, @@ -104,9 +125,20 @@ impl ClientProfileSelection { self.arch.as_deref().unwrap_or(self.platform.default_arch()) } + fn default_originator(&self) -> &'static str { + match self.client { + ClientKind::Desktop => "Codex Desktop", + ClientKind::Cli => self.cli_entry.map_or("codex_cli_rs", CliEntry::originator), + } + } + pub(super) fn validate(&self) -> Result<(), ClientProfileError> { + if self.client == ClientKind::Desktop && self.cli_entry.is_some() { + return Err(ClientProfileError::Invalid); + } for value in [ self.originator.as_deref(), + self.os_type.as_deref(), self.os_version.as_deref(), self.arch.as_deref(), self.terminal.as_deref(), @@ -185,19 +217,19 @@ impl ClientProfileSelection { verified_at: None, }, }; - Ok(CodexWireProfile { + let mut profile = CodexWireProfile { client_kind: self.client, originator: self .originator .clone() - .unwrap_or_else(|| match self.client { - ClientKind::Desktop => "Codex Desktop".to_owned(), - ClientKind::Cli => "codex_cli_rs".to_owned(), - }), + .unwrap_or_else(|| self.default_originator().to_owned()), codex_version: release.codex_version, desktop_version: release.desktop_version.unwrap_or_default(), desktop_build: release.desktop_build.unwrap_or_default(), - os_type: self.platform.os_type().to_owned(), + os_type: self + .os_type + .clone() + .unwrap_or_else(|| self.platform.os_type().to_owned()), os_version: self .os_version .clone() @@ -207,9 +239,21 @@ impl ClientProfileSelection { .terminal .clone() .unwrap_or_else(|| "unknown".to_owned()), + exact_user_agent: None, residency: state.snapshot().residency, verified_at: release.verified_at.unwrap_or(DateTime::UNIX_EPOCH), - }) + }; + if let Some(entry) = self.cli_entry { + // 入口后缀使用同一次解析得到的 Core 版本,避免每日更新后头部与后缀混用。 + // originator 可单独覆盖;入口名与官方 clientInfo.name 的语义保持一致。 + profile.exact_user_agent = Some(format!( + "{} ({}; {})", + profile.user_agent(), + entry.originator(), + profile.codex_version, + )); + } + Ok(profile) } } @@ -228,7 +272,13 @@ impl CodexWireProfileState { &self, configuration: &OpaqueProviderData, ) -> Result { - let selection = ClientProfileSelection::parse(configuration)?; + super::identity::RequestProfileSelection::parse(configuration)?.preview(self) + } + + pub(super) fn preview_preset_selection( + &self, + selection: &ClientProfileSelection, + ) -> Result { let profile = selection.resolve(self)?; let status = self.client_release_status( selection.client, @@ -271,7 +321,7 @@ impl CodexWireProfileState { "configuration": configuration, "automaticAvailable": available, "reason": (!available).then_some("暂不支持自动更新"), - "defaults": { "originator": if client == ClientKind::Desktop { "Codex Desktop" } else { "codex_cli_rs" }, "osVersion": platform.default_os_version(), "arch": platform.default_arch(), "terminal": "unknown" }, + "defaults": { "originator": configuration.default_originator(), "osType": platform.os_type(), "osVersion": platform.default_os_version(), "arch": platform.default_arch(), "terminal": "unknown" }, })); } } @@ -290,6 +340,12 @@ pub(crate) fn object(value: &impl Serialize) -> Result CodexWireProfileState { os_version: "6.8".to_owned(), arch: "x86_64".to_owned(), terminal: "catalog-contract".to_owned(), + exact_user_agent: None, residency: None, verified_at: Utc .with_ymd_and_hms(2026, 7, 18, 0, 0, 0) diff --git a/backend/crates/providers/openai/tests/credential/contract.rs b/backend/crates/providers/openai/tests/credential/contract.rs index 17c85894d..b6b9f09fb 100644 --- a/backend/crates/providers/openai/tests/credential/contract.rs +++ b/backend/crates/providers/openai/tests/credential/contract.rs @@ -162,6 +162,7 @@ fn selector_with_runtime( os_version: "6.8".to_owned(), arch: "x86_64".to_owned(), terminal: "selector-contract".to_owned(), + exact_user_agent: None, residency: None, verified_at: chrono::Utc::now(), }); diff --git a/backend/crates/providers/openai/tests/credential/profile_statistics.rs b/backend/crates/providers/openai/tests/credential/profile_statistics.rs index dfdf71b5d..6db4ee460 100644 --- a/backend/crates/providers/openai/tests/credential/profile_statistics.rs +++ b/backend/crates/providers/openai/tests/credential/profile_statistics.rs @@ -22,6 +22,7 @@ fn wire_profile() -> CodexWireProfileState { os_version: "6.8".to_owned(), arch: "x86_64".to_owned(), terminal: "profile-statistics-contract".to_owned(), + exact_user_agent: None, residency: None, verified_at: Utc .with_ymd_and_hms(2026, 8, 28, 0, 0, 0) diff --git a/backend/crates/providers/openai/tests/credential/quota/mod.rs b/backend/crates/providers/openai/tests/credential/quota/mod.rs index 82f7ec1b8..8f5f43dbe 100644 --- a/backend/crates/providers/openai/tests/credential/quota/mod.rs +++ b/backend/crates/providers/openai/tests/credential/quota/mod.rs @@ -40,6 +40,7 @@ fn wire_profile() -> CodexWireProfileState { os_version: "6.8".to_owned(), arch: "x86_64".to_owned(), terminal: "quota-contract".to_owned(), + exact_user_agent: None, residency: None, verified_at: Utc .with_ymd_and_hms(2026, 7, 18, 0, 0, 0) diff --git a/backend/crates/providers/openai/tests/provider/contract.rs b/backend/crates/providers/openai/tests/provider/contract.rs index 97829d563..2a6de4692 100644 --- a/backend/crates/providers/openai/tests/provider/contract.rs +++ b/backend/crates/providers/openai/tests/provider/contract.rs @@ -984,6 +984,7 @@ fn wire_profile() -> CodexWireProfileState { os_version: "6.8".to_owned(), arch: "x86_64".to_owned(), terminal: "provider-contract".to_owned(), + exact_user_agent: None, residency: None, verified_at: Utc::now(), }) diff --git a/backend/crates/providers/openai/tests/transport/catalog.rs b/backend/crates/providers/openai/tests/transport/catalog.rs index 6fc31963d..a3e1804a8 100644 --- a/backend/crates/providers/openai/tests/transport/catalog.rs +++ b/backend/crates/providers/openai/tests/transport/catalog.rs @@ -419,6 +419,7 @@ fn profile() -> CodexWireProfileState { os_version: "6.8".to_owned(), arch: "x86_64".to_owned(), terminal: "xterm".to_owned(), + exact_user_agent: None, residency: None, verified_at: Utc .with_ymd_and_hms(2026, 7, 18, 0, 0, 0) diff --git a/backend/crates/providers/openai/tests/transport/headers.rs b/backend/crates/providers/openai/tests/transport/headers.rs index bfd4c5a0a..2e1f0f98b 100644 --- a/backend/crates/providers/openai/tests/transport/headers.rs +++ b/backend/crates/providers/openai/tests/transport/headers.rs @@ -712,6 +712,96 @@ async fn websocket_should_keep_an_exact_chain_while_new_connections_adopt_the_la assert_ne!(first_user_agent, second_user_agent); } +#[tokio::test] +async fn custom_core_version_change_reconnects_new_chains_and_keeps_exact_continuations() { + use gateway_core::account::OpaqueProviderData; + use provider_openai::transport::profile::identity::RequestProfileSelection; + + const USER_AGENT: &str = "Custom Agent/test"; + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let address = listener.local_addr().unwrap(); + let server = tokio::spawn(async move { + let (stream, _) = listener.accept().await.unwrap(); + let mut first = accept_codex_test_websocket_with(stream, |request, _response| { + assert_eq!(request.headers()["user-agent"], USER_AGENT); + assert_eq!(request.headers()["originator"], "custom-agent"); + assert_eq!(request.headers()["version"], "1.2.3"); + }) + .await; + for response_id in ["resp_custom_first", "resp_custom_continued"] { + timeout(Duration::from_secs(2), first.next()) + .await + .expect("exact continuation uses the original connection") + .unwrap() + .unwrap(); + first + .send(Message::Text( + completed_websocket_response(response_id, 1, 1).into(), + )) + .await + .unwrap(); + } + let (stream, _) = timeout(Duration::from_secs(2), listener.accept()) + .await + .expect("a Core version change requires a new connection for a new chain") + .unwrap(); + let mut second = accept_codex_test_websocket_with(stream, |request, _response| { + assert_eq!(request.headers()["user-agent"], USER_AGENT); + assert_eq!(request.headers()["originator"], "custom-agent"); + assert_eq!(request.headers()["version"], "1.2.4"); + }) + .await; + second.next().await.unwrap().unwrap(); + second + .send(Message::Text( + completed_websocket_response("resp_custom_second", 1, 1).into(), + )) + .await + .unwrap(); + }); + + let state = test_wire_profile(); + let client = CodexBackendClient::new( + reqwest::Client::builder().no_proxy().build().unwrap(), + format!("http://{address}"), + state.clone(), + ) + .with_websocket_pool(Arc::new(CodexWebSocketPool::new(Duration::from_mins(1)))); + let with_version = |version| { + let configuration = json!({ + "mode": "custom", + "userAgent": USER_AGENT, + "originator": "custom-agent", + "codexVersion": version, + }); + let profile = RequestProfileSelection::parse(&OpaqueProviderData::new( + configuration.as_object().unwrap().clone(), + )) + .unwrap() + .resolve(&state) + .unwrap(); + client.clone().with_request_profile(profile) + }; + let mut request = codex_request("gpt-test", "", Vec::new()); + request.use_websocket = true; + request.local_conversation_id = Some("custom-profile-rotation".to_owned()); + let context = request_context("req_custom_profile", Some("acct-custom-profile")); + with_version("1.2.3") + .create_response(&request, context) + .await + .unwrap(); + let updated = with_version("1.2.4"); + let mut continuation = request.clone(); + continuation.set_previous_response_id(Some("resp_custom_first".to_owned())); + continuation.previous_response_scope = Some(PreviousResponseScope::ConnectionLocal); + updated + .create_response(&continuation, context) + .await + .unwrap(); + updated.create_response(&request, context).await.unwrap(); + server.await.unwrap(); +} + #[tokio::test] async fn concurrent_request_profiles_emit_independent_http_identities() { use provider_openai::transport::profile::selection::{ @@ -786,3 +876,79 @@ async fn concurrent_request_profiles_emit_independent_http_identities() { ); } } + +#[tokio::test] +async fn tui_and_exec_send_the_same_complete_identity_over_http_and_websocket() { + use provider_openai::transport::profile::selection::{ + CliEntry, ClientKind, ClientPlatform, ClientProfileSelection, VersionMode, + }; + for (entry, name) in [(CliEntry::Tui, "codex-tui"), (CliEntry::Exec, "codex_exec")] { + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let address = listener.local_addr().unwrap(); + let expected = + format!("{name}/0.157.0 (Alpine Linux 3.24.1; x86_64) unknown ({name}; 0.157.0)"); + let server = tokio::spawn(async move { + let (mut http, _) = listener.accept().await.unwrap(); + let headers = read_http_request(&mut http).await; + assert_eq!( + read_header_value(&headers, "user-agent"), + Some(expected.as_str()) + ); + assert_eq!(read_header_value(&headers, "originator"), Some(name)); + assert_eq!(read_header_value(&headers, "version"), Some("0.157.0")); + write_completed_sse_response(&mut http).await; + let (stream, _) = listener.accept().await.unwrap(); + let mut ws = accept_codex_test_websocket_with(stream, |request, _| { + assert_eq!(request.headers()["user-agent"], expected); + assert_eq!(request.headers()["originator"], name); + assert_eq!(request.headers()["version"], "0.157.0"); + }) + .await; + ws.next().await.unwrap().unwrap(); + ws.send(Message::Text( + completed_websocket_response("resp_cli_entry", 1, 1).into(), + )) + .await + .unwrap(); + }); + let state = + provider_openai::transport::profile::CodexWireProfileState::new(Default::default()); + let profile = ClientProfileSelection { + client: ClientKind::Cli, + platform: ClientPlatform::Linux, + cli_entry: Some(entry), + os_type: Some("Alpine Linux".into()), + os_version: Some("3.24.1".into()), + version_mode: VersionMode::Fixed, + codex_version: Some("0.157.0".into()), + ..Default::default() + } + .resolve(&state) + .unwrap(); + let client = CodexBackendClient::new( + reqwest::Client::builder().no_proxy().build().unwrap(), + format!("http://{address}"), + state, + ) + .with_request_profile(profile); + let mut request = codex_request("gpt-test", "", Vec::new()); + request.force_http_sse = true; + client + .create_response( + &request, + request_context("req_cli_http", Some("acct-fixture")), + ) + .await + .unwrap(); + request.force_http_sse = false; + request.use_websocket = true; + client + .create_response( + &request, + request_context("req_cli_ws", Some("acct-fixture")), + ) + .await + .unwrap(); + server.await.unwrap(); + } +} diff --git a/backend/crates/providers/openai/tests/transport/mod.rs b/backend/crates/providers/openai/tests/transport/mod.rs index 842f3ee55..08e6e5cac 100644 --- a/backend/crates/providers/openai/tests/transport/mod.rs +++ b/backend/crates/providers/openai/tests/transport/mod.rs @@ -133,6 +133,7 @@ fn test_wire_profile() -> CodexWireProfileState { os_version: "6.8".to_owned(), arch: "x86_64".to_owned(), terminal: "transport-test".to_owned(), + exact_user_agent: None, residency: None, verified_at: Utc::now(), }) diff --git a/backend/crates/providers/openai/tests/transport/profile/cli_release.rs b/backend/crates/providers/openai/tests/transport/profile/cli_release.rs index 1a9f3d33c..3203e0d2b 100644 --- a/backend/crates/providers/openai/tests/transport/profile/cli_release.rs +++ b/backend/crates/providers/openai/tests/transport/profile/cli_release.rs @@ -31,3 +31,96 @@ fn cli_latest_requires_matching_stable_platform_dependencies() { assert!(parse_cli_release(&serde_json::to_vec(&manifest).unwrap()).is_err()); } } + +#[tokio::test] +async fn official_cli_cache_updates_entry_headers_together_and_preserves_frozen_identities() { + use std::sync::Arc; + use std::time::Duration; + + use gateway_core::account::OpaqueProviderData; + use gateway_core::provider_ports::{ProviderArtifactProfile, ProviderArtifactProfileCachePort}; + use gateway_core::routing::ProviderKind; + use provider_openai::transport::headers::build_codex_model_headers; + use provider_openai::transport::profile::CodexWireProfileState; + use provider_openai::transport::profile::cli_release::CliReleaseService; + use provider_openai::transport::profile::identity::RequestProfileSelection; + use provider_openai::transport::profile::selection::{ + CliEntry, ClientKind, ClientPlatform, ClientProfileSelection, VersionMode, + }; + + let state = CodexWireProfileState::new(super::wire_profile()); + let cache = Arc::new(super::ArtifactProfiles::default()); + let provider = ProviderKind::new("openai").unwrap(); + let service = CliReleaseService::new(provider.clone(), state.clone(), cache.clone()).unwrap(); + let automatic = ClientProfileSelection { + client: ClientKind::Cli, + platform: ClientPlatform::Linux, + cli_entry: Some(CliEntry::Tui), + os_type: Some("Alpine Linux".into()), + os_version: Some("3.24.1".into()), + terminal: Some("xterm-256color".into()), + ..Default::default() + }; + let frozen = automatic.resolve(&state).unwrap(); + let fixed = ClientProfileSelection { + version_mode: VersionMode::Fixed, + codex_version: Some(frozen.codex_version.clone()), + ..automatic.clone() + }; + let custom = RequestProfileSelection::parse(&OpaqueProviderData::new( + json!({"mode":"custom", "userAgent":frozen.user_agent()}) + .as_object() + .unwrap() + .clone(), + )) + .unwrap(); + + // 复用官方版本服务的恢复入口,模拟每日检查已核验并写入的下一份发布资料。 + cache + .replace_if_newer( + ProviderArtifactProfile::new( + provider.clone(), + "cli-linux-x64".into(), + 157_001, + chrono::Utc::now().into(), + OpaqueProviderData::new(json!({"version":"0.157.0"}).as_object().unwrap().clone()), + ), + Duration::from_secs(86400), + ) + .await + .unwrap(); + service.restore().await; + for (entry, originator) in [(CliEntry::Tui, "codex-tui"), (CliEntry::Exec, "codex_exec")] { + let selection = ClientProfileSelection { + cli_entry: Some(entry), + ..automatic.clone() + }; + let current = selection.resolve(&state).unwrap(); + let headers = build_codex_model_headers(¤t, "Bearer fixture", None).unwrap(); + let expected = format!( + "{originator}/0.157.0 (Alpine Linux 3.24.1; x86_64) xterm-256color ({originator}; 0.157.0)" + ); + assert_eq!(headers["user-agent"], expected); + assert_eq!(headers["originator"], originator); + assert_eq!(headers["version"], "0.157.0"); + } + assert_eq!( + fixed.resolve(&state).unwrap().user_agent(), + frozen.user_agent() + ); + assert_eq!( + custom.resolve(&state).unwrap().user_agent(), + frozen.user_agent() + ); + assert_eq!(frozen.codex_version, "0.155.0"); + + let restarted = CodexWireProfileState::new(super::wire_profile()); + CliReleaseService::new(provider, restarted.clone(), cache) + .unwrap() + .restore() + .await; + assert_eq!( + automatic.resolve(&restarted).unwrap(), + automatic.resolve(&state).unwrap() + ); +} diff --git a/backend/crates/providers/openai/tests/transport/profile/identity.rs b/backend/crates/providers/openai/tests/transport/profile/identity.rs new file mode 100644 index 000000000..4d734dd25 --- /dev/null +++ b/backend/crates/providers/openai/tests/transport/profile/identity.rs @@ -0,0 +1,142 @@ +use gateway_core::account::OpaqueProviderData; +use provider_openai::transport::headers::build_codex_model_headers; +use provider_openai::transport::profile::identity::RequestProfileSelection; +use provider_openai::transport::profile::selection::ClientProfileSelection; +use provider_openai::transport::profile::{ + CodexResidency, CodexWireProfile, CodexWireProfileState, +}; +use serde_json::{Value, json}; + +fn document(value: Value) -> OpaqueProviderData { + OpaqueProviderData::new(value.as_object().unwrap().clone()) +} + +#[test] +fn complete_custom_identities_send_exact_user_agent_and_matching_companion_headers() { + let state = CodexWireProfileState::new(CodexWireProfile { + residency: Some(CodexResidency::Us), + ..Default::default() + }); + for (core, originator, user_agent) in [ + ( + "0.155.0-alpha.16.3", + "Codex Desktop", + "Codex Desktop/0.155.0-alpha.16.3 (Ubuntu 24.4.0; x86_64) unknown (Codex Desktop; 26.917.62051)", + ), + ( + "0.156.1", + "codex-tui", + "codex-tui/0.156.1 (Ubuntu 24.4.0; x86_64) xterm-256color (codex-tui; 0.156.1)", + ), + ( + "0.156.1", + "codex_exec", + "codex_exec/0.156.1 (Ubuntu 24.4.0; x86_64) xterm-256color (codex_exec; 0.156.1)", + ), + ] { + let selection = document(json!({ + "mode":"custom", "userAgent":user_agent + })); + let profile = RequestProfileSelection::parse(&selection) + .unwrap() + .resolve(&state) + .unwrap(); + let headers = build_codex_model_headers(&profile, "Bearer fixture", None).unwrap(); + assert_eq!(headers["user-agent"], user_agent); + assert_eq!(headers["originator"], originator); + assert_eq!(headers["version"], core); + assert_eq!(headers["x-openai-internal-codex-residency"], "us"); + assert!(profile.desktop_build.is_empty()); + let frozen: CodexWireProfile = + serde_json::from_value(serde_json::to_value(&profile).unwrap()).unwrap(); + assert_eq!(frozen.user_agent(), user_agent); + } + assert!(state.snapshot().exact_user_agent.is_none()); +} + +#[test] +fn custom_known_prefix_derives_headers_and_unknown_identity_requires_them() { + let state = CodexWireProfileState::new(CodexWireProfile::default()); + for (user_agent, originator, core, recognized) in [ + ( + "codex_cli_rs/0.156.1 (Linux 6.8.0; x86_64) custom-terminal", + "codex_cli_rs", + "0.156.1", + true, + ), + ( + "My Agent/7 (custom environment)", + "my-agent", + "0.156.0", + false, + ), + ] { + let mut configuration = json!({"mode":"custom","userAgent":user_agent}); + if !recognized { + assert!(RequestProfileSelection::parse(&document(configuration.clone())).is_err()); + configuration["originator"] = json!(originator); + configuration["codexVersion"] = json!(core); + } + let configuration = document(configuration); + let profile = RequestProfileSelection::parse(&configuration) + .unwrap() + .resolve(&state) + .unwrap(); + let headers = build_codex_model_headers(&profile, "Bearer fixture", None).unwrap(); + assert_eq!(headers["user-agent"], user_agent); + assert_eq!(headers["originator"], originator); + assert_eq!(headers["version"], core); + let preview = state.preview_selection(&configuration).unwrap(); + assert_eq!(preview.expose_to_provider()["recognized"], recognized); + assert_eq!(preview.expose_to_provider()["versionSource"], "custom"); + assert!(preview.expose_to_provider()["verifiedAt"].is_null()); + } +} + +#[test] +fn raw_identity_rejects_injection_conflicting_headers_and_unknown_modes() { + for configuration in [ + json!({"mode":"custom","userAgent":"codex-tui/0.156.1\r\nx-injected: yes"}), + json!({"mode":"custom","userAgent":"codex-tui/0.156.1\tterminal"}), + json!({"mode":"custom","userAgent":"自定义UA","originator":"custom","codexVersion":"0.156.1"}), + json!({"mode":"custom","userAgent":"x".repeat(4097),"originator":"custom","codexVersion":"0.156.1"}), + json!({"mode":"custom","userAgent":"codex-tui/0.156.1","originator":"codex_exec"}), + json!({"mode":"custom","userAgent":"codex-tui/0.156.1","codexVersion":"0.155.0"}), + json!({"mode":"custom","userAgent":"codex-tui/latest","originator":"codex-tui","codexVersion":"0.156.1"}), + json!({"mode":"custom","userAgent":"custom","originator":"agent\r\nx-injected: yes","codexVersion":"0.156.1"}), + json!({"mode":"custom","userAgent":"custom","originator":"agent","codexVersion":"latest"}), + json!({"mode":"custom","userAgent":"codex-tui/0.156.1","extraHeader":"anything"}), + json!({"mode":"unknown","client":"cli","platform":"linux","versionMode":"latest"}), + json!({"mode":null,"client":"cli","platform":"linux","versionMode":"latest"}), + ] { + assert!( + RequestProfileSelection::parse(&document(configuration.clone())).is_err(), + "{configuration}" + ); + } +} + +#[test] +fn old_configuration_and_old_frozen_profile_keep_their_existing_identity() { + let state = CodexWireProfileState::new(CodexWireProfile::default()); + let legacy = ClientProfileSelection::default(); + let resolved = legacy.resolve(&state).unwrap(); + assert_eq!( + RequestProfileSelection::parse(&legacy.document().unwrap()) + .unwrap() + .resolve(&state) + .unwrap(), + resolved + ); + let old_document = serde_json::to_value(&resolved).unwrap(); + assert!(old_document.get("exact_user_agent").is_none()); + let restored: CodexWireProfile = serde_json::from_value(old_document).unwrap(); + assert_eq!(restored.user_agent(), resolved.user_agent()); + assert_eq!( + state + .preview_selection(&legacy.document().unwrap()) + .unwrap() + .expose_to_provider()["versionSource"], + "official" + ); +} diff --git a/backend/crates/providers/openai/tests/transport/profile/mod.rs b/backend/crates/providers/openai/tests/transport/profile/mod.rs index de52e95ea..25da1c85b 100644 --- a/backend/crates/providers/openai/tests/transport/profile/mod.rs +++ b/backend/crates/providers/openai/tests/transport/profile/mod.rs @@ -17,6 +17,7 @@ use provider_openai::transport::profile::{ mod cli_release; mod desktop_artifact; +mod identity; mod platform_release; mod selection; @@ -148,6 +149,7 @@ fn wire_profile_should_generate_bundled_core_app_server_user_agent() { os_version: "15.7.1".to_owned(), arch: "arm64".to_owned(), terminal: "unknown".to_owned(), + exact_user_agent: None, residency: None, verified_at: Utc .with_ymd_and_hms(2026, 8, 3, 0, 0, 0) @@ -352,6 +354,7 @@ fn wire_profile() -> CodexWireProfile { os_version: "15.7.1".to_owned(), arch: "arm64".to_owned(), terminal: "unknown".to_owned(), + exact_user_agent: None, residency: None, verified_at: Utc .with_ymd_and_hms(2026, 8, 3, 0, 0, 0) diff --git a/backend/crates/providers/openai/tests/transport/profile/selection.rs b/backend/crates/providers/openai/tests/transport/profile/selection.rs index 7635e68fc..a22f6b841 100644 --- a/backend/crates/providers/openai/tests/transport/profile/selection.rs +++ b/backend/crates/providers/openai/tests/transport/profile/selection.rs @@ -1,6 +1,6 @@ use gateway_core::account::OpaqueProviderData; use provider_openai::transport::profile::selection::{ - ClientKind, ClientPlatform, ClientProfileSelection, VersionMode, + CliEntry, ClientKind, ClientPlatform, ClientProfileSelection, VersionMode, }; use provider_openai::transport::profile::{CodexBundledReleaseProfile, CodexWireProfileState}; use serde_json::json; @@ -92,6 +92,9 @@ fn invalid_headers_and_mixed_version_modes_are_rejected() { for patch in [ json!({"originator":"client\r\nx-injected: yes"}), json!({"terminal":"term; forged"}), + json!({"osType":"Linux; forged"}), + json!({"cliEntry":"unknown"}), + json!({"client":"desktop", "cliEntry":"tui"}), json!({"arch":""}), json!({"codexVersion":"0.155.0"}), json!({"desktopVersion":"26.1.0"}), @@ -106,3 +109,25 @@ fn invalid_headers_and_mixed_version_modes_are_rejected() { assert!(ClientProfileSelection::parse(&OpaqueProviderData::new(fields)).is_err()); } } + +#[test] +fn cli_entry_suffix_keeps_its_name_when_originator_is_overridden() { + let state = CodexWireProfileState::new(super::wire_profile()); + let selection = ClientProfileSelection { + client: ClientKind::Cli, + platform: ClientPlatform::Linux, + cli_entry: Some(CliEntry::Exec), + originator: Some("my-agent".into()), + os_type: Some("Alpine Linux".into()), + os_version: Some("3.24.1".into()), + version_mode: VersionMode::Fixed, + codex_version: Some("0.157.0".into()), + ..Default::default() + }; + let profile = selection.resolve(&state).unwrap(); + assert_eq!( + profile.user_agent(), + "my-agent/0.157.0 (Alpine Linux 3.24.1; x86_64) unknown (codex_exec; 0.157.0)" + ); + assert_eq!(profile.originator, "my-agent"); +} diff --git a/backend/crates/providers/openai/tests/transport/reset_credits.rs b/backend/crates/providers/openai/tests/transport/reset_credits.rs index a3e0ece3c..d57600929 100644 --- a/backend/crates/providers/openai/tests/transport/reset_credits.rs +++ b/backend/crates/providers/openai/tests/transport/reset_credits.rs @@ -28,6 +28,7 @@ fn client(base_url: &str) -> CodexBackendClient { os_version: "15.5.0".to_owned(), arch: "arm64".to_owned(), terminal: "xterm-256color".to_owned(), + exact_user_agent: None, residency: None, verified_at: Utc .with_ymd_and_hms(2026, 8, 20, 0, 0, 0) diff --git a/backend/crates/providers/openai/tests/transport/usage.rs b/backend/crates/providers/openai/tests/transport/usage.rs index 5c2842e6f..043670729 100644 --- a/backend/crates/providers/openai/tests/transport/usage.rs +++ b/backend/crates/providers/openai/tests/transport/usage.rs @@ -823,6 +823,7 @@ fn profile() -> CodexWireProfileState { os_version: "6.8".to_owned(), arch: "x86_64".to_owned(), terminal: "xterm".to_owned(), + exact_user_agent: None, residency: None, verified_at: Utc .with_ymd_and_hms(2026, 7, 18, 0, 0, 0) diff --git a/docs/api.md b/docs/api.md index 39d94923b..02f59faf0 100644 --- a/docs/api.md +++ b/docs/api.md @@ -1261,21 +1261,32 @@ models.dev 同步只导入可表示为当前文本 Token 计价的 OpenAI/xAI 该配置作用于 Client Key 的 OpenAI 模型请求与原生模型目录,适用于 HTTP/SSE、WebSocket、Images 和 Search。 不改变 xAI、入站客户端版本门禁、账号认证或后台 Desktop 专属操作。 -身份对象字段如下,可选字段省略或 `null` 时使用所选预设参数: +自定义配置使用 `{ "mode": "custom", "userAgent": "完整 UA" }`。 +已识别的 `Codex Desktop`、`codex-tui`、`codex_exec`、`codex_cli_rs` 前缀由后端解析 `originator` 和 Core `version`, +显式提供的配套字段必须与识别结果一致。未知前缀须另填 `originator` 和 `codexVersion`,这两个字段与 UA 一起发送。 +UA 须为 1 至 4096 字节的单行可见 ASCII 文本,首尾不能含空白;`originator` 最多 128 字节, +`codexVersion` 最多 64 字节并须符合 SemVer。自定义配置不要求 Desktop 构建号,也不自动更新。 + +没有 `mode` 字段的预设配置按以下合同解析,可选字段省略或 `null` 时使用所选预设参数: | 字段 | 取值与语义 | | --- | --- | | `client` | 必填,`desktop` 或 `cli` | | `platform` | 必填,`macos`、`linux` 或 `windows` | | `versionMode` | 必填,`latest` 或 `fixed` | -| `originator`、`osVersion`、`arch`、`terminal` | 可选自定义参数,非空、最多 128 字节;只接受可见 ASCII,不能包含括号、分号、反斜杠及首尾空白 | +| `cliEntry` | CLI 可选 `tui` 或 `exec`,省略或 `null` 保留 Core 默认身份;Desktop 不接受此字段 | +| `originator`、`osType`、`osVersion`、`arch`、`terminal` | 可选自定义参数,非空、最多 128 字节;只接受可见 ASCII,不能包含括号、分号、反斜杠及首尾空白 | | `codexVersion` | `fixed` 必填的 Core SemVer;`latest` 必须省略或为 `null` | | `desktopVersion`、`desktopBuild` | 仅 Desktop 的 `fixed` 模式必填,分别为数字点分版本和数字构建号;CLI 不接受这些字段 | ```json -{ "client": "cli", "platform": "linux", "versionMode": "latest" } +{ "client": "cli", "platform": "linux", "versionMode": "latest", "cliEntry": "tui", "osType": "Alpine Linux", "osVersion": "3.24.1", "terminal": "xterm-256color" } ``` +TUI 默认标识为 `codex-tui`,Exec 为 `codex_exec`,入口后缀使用同一次解析的 Core 版本。 +`originator` 覆盖只更改产品名前缀和配套头,后缀仍表示所选入口。省略 `osType` 使用平台名称; +自定义运行环境在自动更新时保持不变。旧配置未指定 `cliEntry` 时继续使用 `codex_cli_rs` 默认值且不添加入口后缀。 + 六套预设均支持自动更新:macOS Desktop 支持 arm64,Windows/Linux Desktop 及三套 CLI 支持 arm64、x86_64。 预设接口的 `automaticAvailable`、`reason` 表示当前组合的可用性;自定义架构可能使自动解析不可用。 每 24 小时后台检查官方稳定发布,失败保留同组合上次有效版本;固定值不受后台更新影响。 @@ -1284,7 +1295,9 @@ Windows/Linux 通过 ETag 检查更新,未变化时复用已核验版本;CLI 预览返回 `configuration`、`source`(`global` / `override`)、`userAgent`、解析后的环境和版本字段, 以及 `versionSource`(`official` / `custom`)、`verifiedAt`、`checkedAt`、`error`。 +自定义预览中的 `recognized` 表示是否识别出配套请求头。 `verifiedAt` 只表示版本资料核验,不能代表自定义运行环境或 TLS 已核验;固定版本返回 `null`。 +完整自定义配置不携带官方制品核验时间。 客户端画像配置控制应用层请求字段,不切换操作系统的 TLS 实现。默认 HTTP 使用 native TLS, WebSocket 使用 rustls;配置自定义 CA 时 HTTP 也使用 rustls。TLS 指纹需按实际部署平台与传输路径核验。 未完成本次启动检查时 `checkedAt` 为 `null`。非法或当前不可用的选择返回 `400`,保存失败不提交其他修改。 diff --git a/docs/architecture.md b/docs/architecture.md index 2e375f643..a3baaee25 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -326,7 +326,10 @@ OpenAI 模型目录用于发现,不因目录缺项拒绝请求;管理员配 各 Provider 唯一负责默认值、校验、版本来源及 UA 生成,Admin 提供管理和生效预览。 首次初始化只写入内置默认选择;YAML 不定义客户端身份,也不作为数据库初始化或请求解析的来源。 官方发布资料与用户选择分开:OpenAI 在 Redis 按 Provider、客户端、平台、架构隔离可重建版本缓存, - Desktop 完整制品元组原子更新,固定配置不被刷新覆盖。普通连接按已有身份键匹配,精确续写保留原连接。 + Desktop 完整制品元组原子更新。CLI 的 TUI/Exec 入口共用官方 CLI 版本资料,Provider 在一次身份解析中 + 生成一致的 UA、入口后缀与配套请求头,后台更新不修改用户选择的运行环境。 + 完整自定义 UA 经 Provider 校验后原样传递,配套头统一解析;固定和自定义配置不受后台更新影响。 + 普通连接按已有身份键匹配,精确续写保留原连接。 后台账号和 Desktop 专属操作使用独立官方 Desktop 画像,不接受 Key 覆盖。 xAI 以内置画像为版本检查基线,在进程内更新 Grok CLI 发布资料;模型与压缩请求使用已保存的用户选择, OAuth、后台目录和额度查询使用内置官方画像。两者均不回写 `config.yaml`。 diff --git a/frontend/src/api/modules/client-profiles.ts b/frontend/src/api/modules/client-profiles.ts index 8b3b45644..29ce291b2 100644 --- a/frontend/src/api/modules/client-profiles.ts +++ b/frontend/src/api/modules/client-profiles.ts @@ -4,10 +4,13 @@ export type ProviderRequestProfile = Record export type ProviderRequestProfiles = Record export type ProviderRequestProfileUpdates = Record -export interface ClientProfileSelection { +export interface PresetClientProfileSelection { + mode?: undefined client: 'desktop' | 'cli' platform: 'macos' | 'linux' | 'windows' versionMode: 'latest' | 'fixed' + cliEntry?: 'tui' | 'exec' | null + osType?: string | null originator: string | null osVersion: string | null arch: string | null @@ -17,6 +20,20 @@ export interface ClientProfileSelection { desktopBuild: string | null } +export interface CustomClientProfileSelection { + mode: 'custom' + userAgent: string + originator?: string | null + codexVersion?: string | null +} + +export type ClientProfileSelection = PresetClientProfileSelection | CustomClientProfileSelection + +export interface ClientProfileOptions { + presets: ClientProfilePreset[] + globalConfiguration: ClientProfileSelection +} + export interface ClientProfilePreview { configuration: ClientProfileSelection source: 'global' | 'override' @@ -30,20 +47,21 @@ export interface ClientProfilePreview { desktopBuild: string | null userAgent: string versionSource: 'official' | 'custom' + recognized?: boolean verifiedAt: string | null checkedAt: string | null error: string | null } export interface ClientProfilePreset { - configuration: ClientProfileSelection + configuration: PresetClientProfileSelection automaticAvailable: boolean reason: string | null - defaults: Pick + defaults: Pick } export function getClientProfileOptions() { - return request<{ presets: ClientProfilePreset[], globalConfiguration: ClientProfileSelection }>({ + return request({ url: '/api/admin/settings/client-profiles/openai', method: 'GET', silent: true, diff --git a/frontend/src/components/client-profile/ClientProfileEditor.vue b/frontend/src/components/client-profile/ClientProfileEditor.vue index ff63fa4cc..ab0b2758f 100644 --- a/frontend/src/components/client-profile/ClientProfileEditor.vue +++ b/frontend/src/components/client-profile/ClientProfileEditor.vue @@ -1,9 +1,14 @@ diff --git a/frontend/src/components/client-profile/ClientProfilePresetFields.vue b/frontend/src/components/client-profile/ClientProfilePresetFields.vue new file mode 100644 index 000000000..5916b79ce --- /dev/null +++ b/frontend/src/components/client-profile/ClientProfilePresetFields.vue @@ -0,0 +1,134 @@ + + + diff --git a/frontend/src/components/client-profile/ClientProfilePreviewPanel.vue b/frontend/src/components/client-profile/ClientProfilePreviewPanel.vue index 08df566e8..6f5f0ee8f 100644 --- a/frontend/src/components/client-profile/ClientProfilePreviewPanel.vue +++ b/frontend/src/components/client-profile/ClientProfilePreviewPanel.vue @@ -12,30 +12,35 @@ defineProps<{