From be5b6d199e0c792ed09ade380e9d00b1aeb29c60 Mon Sep 17 00:00:00 2001 From: Zyy Date: Wed, 28 Oct 2026 19:31:20 +0800 Subject: [PATCH] fix(api): enforce version gates for official Codex clients Recognize official TUI, exec, IDE, SDK and App Server/MCP product names using the Core version in the User-Agent. Keep Desktop app version handling and unknown-client compatibility intact. Compare SemVer precedence without build metadata, cover HTTP/SSE and real WebSocket admission, and align API documentation and localized settings help. Validated 54 targeted and architecture tests, strict Clippy, Rust formatting, frontend lint/build, and localized component previews. Co-authored-by: Codex --- backend/crates/gateway-api/src/openai/auth.rs | 40 +++++---- .../crates/gateway-api/tests/openai/auth.rs | 82 ++++++++++++++----- .../crates/gateway-api/tests/openai/models.rs | 14 ++-- .../tests/openai/responses/http.rs | 63 ++++++++++++++ .../tests/openai/responses/websocket/mod.rs | 44 ++++++++++ .../gateway-core/src/policy/client_version.rs | 3 +- .../crates/gateway-core/tests/policy/mod.rs | 38 ++++++--- docs/api.md | 10 ++- frontend/src/i18n/locales/en/settings.json | 2 +- .../src/i18n/locales/zh-Hans/settings.json | 2 +- .../src/i18n/locales/zh-Hant/settings.json | 2 +- 11 files changed, 236 insertions(+), 64 deletions(-) diff --git a/backend/crates/gateway-api/src/openai/auth.rs b/backend/crates/gateway-api/src/openai/auth.rs index c941fbd28..2339af4e3 100644 --- a/backend/crates/gateway-api/src/openai/auth.rs +++ b/backend/crates/gateway-api/src/openai/auth.rs @@ -153,6 +153,19 @@ pub(crate) fn client_access_error_response(error: ClientAccessError) -> Response #[must_use] pub fn identify_codex_client(headers: &HeaderMap) -> Option { const MAXIMUM_HEADER_LENGTH: usize = 4096; + // 官方 CLI、IDE、SDK 与 App Server 的 UA 产品版本均来自所运行的 Codex Core + const CORE_CLIENT_PRODUCTS: &[&str] = &[ + "codex_cli_rs", + "codex-cli", + "codex-tui", + "codex_exec", + "codex_vscode", + "codex_sdk_ts", + "codex_python_sdk", + "codex-app-server", + "codex-app-server-sdk", + "codex_mcp_server", + ]; let originator = bounded_ascii_header(headers, "originator", MAXIMUM_HEADER_LENGTH); let user_agent = bounded_ascii_header(headers, "user-agent", MAXIMUM_HEADER_LENGTH); @@ -186,22 +199,17 @@ pub fn identify_codex_client(headers: &HeaderMap) -> Option Arc { + pub(super) fn with_cli_min(minimum: &str) -> Arc { Arc::new(Self { - client: authenticated_client_with_min_versions("sk_models_test", None, Some("0.40.0")), + client: authenticated_client_with_min_versions("sk_models_test", None, Some(minimum)), presentation: None, native: None, requested_versions: Mutex::default(), @@ -440,7 +440,7 @@ async fn models_should_reject_recognized_cli_below_configured_min() { .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()) + let response = api_router(ModelsExecution::with_cli_min("0.40.0")) .await .oneshot(request) .await @@ -464,7 +464,7 @@ async fn models_should_reject_recognized_cli_with_invalid_version() { .header("user-agent", "codex-cli/not-a-version") .body(Body::empty()) .expect("build models request"); - let response = api_router(ModelsExecution::with_cli_min()) + let response = api_router(ModelsExecution::with_cli_min("0.40.0")) .await .oneshot(request) .await @@ -513,7 +513,7 @@ async fn models_should_allow_cli_at_its_configured_min() { .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()) + let response = api_router(ModelsExecution::with_cli_min("0.40.0")) .await .oneshot(request) .await @@ -529,7 +529,7 @@ async fn models_should_leave_unknown_clients_unrestricted() { .header("user-agent", "curl/8.14.1") .body(Body::empty()) .expect("build models request"); - let response = api_router(ModelsExecution::with_cli_min()) + let response = api_router(ModelsExecution::with_cli_min("0.40.0")) .await .oneshot(request) .await @@ -685,7 +685,7 @@ async fn model_detail_should_hide_unknown_models() { #[tokio::test] async fn models_should_authenticate_before_applying_the_version_gate() { - let response = api_router(ModelsExecution::with_cli_min()) + let response = api_router(ModelsExecution::with_cli_min("0.40.0")) .await .oneshot( Request::get("/v1/models") diff --git a/backend/crates/gateway-api/tests/openai/responses/http.rs b/backend/crates/gateway-api/tests/openai/responses/http.rs index de4b60b83..6ff6a53f3 100644 --- a/backend/crates/gateway-api/tests/openai/responses/http.rs +++ b/backend/crates/gateway-api/tests/openai/responses/http.rs @@ -2948,6 +2948,69 @@ struct SessionExecution { middleware: Option, } +#[tokio::test] +async fn official_core_clients_should_be_version_checked_before_starting_responses() { + let admin = crate::admin::AdminTestFixture::new().await.services; + for product in [ + "codex-tui", + "codex_exec", + "codex_vscode", + "codex_sdk_ts", + "codex_python_sdk", + ] { + for (version, allowed) in [("0.158.0", false), ("0.162.0", true)] { + let trace = Arc::new(Trace::default()); + let execution = Arc::new(SessionExecution { + client: authenticated_client_with_min_versions( + "sk_version_test", + None, + Some("0.162.0"), + ), + session: Mutex::new(Some(Box::new(FakeSession::streaming( + Arc::clone(&trace), + vec![ + NextStep::Event(delivery( + started(), + CommitRequirement::CommitBeforeDelivery, + )), + NextStep::Event(delivery(completed(), CommitRequirement::AlreadyCommitted)), + NextStep::FinalizeSuccess, + ], + )))), + middleware: None, + }); + let request = Request::post("/v1/responses") + .header(AUTHORIZATION, "Bearer sk_version_test") + .header("user-agent", format!( + "{product}/{version} (Mac OS 26.6.2; arm64) Apple_Terminal ({product}; 0.165.0)" + )) + .body(Body::from(json!({"model": "model-a", "input": "synthetic", "stream": true}).to_string())) + .unwrap(); + let response = crate::openai::api_router_with_admin_and_execution( + admin.clone(), + execution.clone(), + ) + .oneshot(request) + .await + .unwrap(); + let status = response.status(); + let body = to_bytes(response.into_body(), usize::MAX).await.unwrap(); + if allowed { + assert_eq!(status, StatusCode::OK, "{product}/{version}"); + assert!(String::from_utf8_lossy(&body).contains("event: response.completed\n")); + assert!(trace.snapshot().contains(&"commit")); + } else { + assert_eq!(status, StatusCode::UPGRADE_REQUIRED, "{product}/{version}"); + let value: Value = serde_json::from_slice(&body).unwrap(); + assert_eq!(value["error"]["code"], "client_version_too_old"); + assert_eq!(value["error"]["current_version"], version); + assert_eq!(value["error"]["min_version"], "0.162.0"); + assert!(execution.session.lock().unwrap().is_some()); + } + } + } +} + #[tokio::test] async fn chatgpt_remote_responses_should_only_skip_missing_desktop_versions() { let admin = crate::admin::AdminTestFixture::new().await.services; diff --git a/backend/crates/gateway-api/tests/openai/responses/websocket/mod.rs b/backend/crates/gateway-api/tests/openai/responses/websocket/mod.rs index 6f62ec4ac..5e30e2851 100644 --- a/backend/crates/gateway-api/tests/openai/responses/websocket/mod.rs +++ b/backend/crates/gateway-api/tests/openai/responses/websocket/mod.rs @@ -1208,6 +1208,50 @@ fn upgrade_request(authorization: &str) -> Request { .expect("build WebSocket upgrade request") } +#[tokio::test] +async fn official_core_version_gate_should_run_before_websocket_upgrade() { + let app = api_router(ModelsExecution::with_cli_min("0.162.0")).await; + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let address = listener.local_addr().unwrap(); + let server = tokio::spawn(async move { axum::serve(listener, app).await.unwrap() }); + + for version in ["0.158.0", "0.162.0"] { + let mut request = format!("ws://{address}/v1/responses") + .into_client_request() + .unwrap(); + request.headers_mut().insert( + AUTHORIZATION, + HeaderValue::from_static("Bearer sk_models_test"), + ); + request.headers_mut().insert( + "user-agent", + HeaderValue::from_str(&format!( + "codex-tui/{version} (Mac OS 26.6.2; arm64) Apple_Terminal (codex-tui; 0.165.0)" + )) + .unwrap(), + ); + let result = connect_async(request).await; + if version == "0.162.0" { + let (mut socket, response) = result.expect("current Core upgrades successfully"); + assert_eq!(response.status(), StatusCode::SWITCHING_PROTOCOLS); + socket.close(None).await.unwrap(); + } else { + let tokio_tungstenite::tungstenite::Error::Http(response) = + result.expect_err("old Core must not upgrade") + else { + panic!("expected an HTTP version rejection"); + }; + assert_eq!(response.status(), StatusCode::UPGRADE_REQUIRED); + let value: Value = serde_json::from_slice(response.body().as_deref().unwrap()).unwrap(); + assert_eq!(value["error"]["code"], "client_version_too_old"); + assert_eq!(value["error"]["current_version"], "0.158.0"); + assert_eq!(value["error"]["min_version"], "0.162.0"); + } + } + server.abort(); + let _ = server.await; +} + #[tokio::test] async fn get_responses_should_route_to_the_websocket_upgrade_boundary() { let response = api_router(ModelsExecution::new()) diff --git a/backend/crates/gateway-core/src/policy/client_version.rs b/backend/crates/gateway-core/src/policy/client_version.rs index cd27745b9..220747f60 100644 --- a/backend/crates/gateway-core/src/policy/client_version.rs +++ b/backend/crates/gateway-core/src/policy/client_version.rs @@ -100,7 +100,8 @@ impl CodexClientMinVersions { min: min.clone(), }); }; - if current < min { + // 最低版本按 SemVer 优先级比较,构建元数据不影响准入 + if current.0.cmp_precedence(&min.0).is_lt() { return Err(ClientVersionRejection::TooOld { kind, current: current.clone(), diff --git a/backend/crates/gateway-core/tests/policy/mod.rs b/backend/crates/gateway-core/tests/policy/mod.rs index 080e81662..a2c19d1aa 100644 --- a/backend/crates/gateway-core/tests/policy/mod.rs +++ b/backend/crates/gateway-core/tests/policy/mod.rs @@ -119,17 +119,29 @@ fn min_client_versions_should_reject_missing_and_old_recognized_clients() { #[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(())); + for (minimum, current, allowed) in [ + ("1.0.0", "1.0.0-rc.1", false), + ("1.0.0", "1.0.0", true), + ("1.0.0", "1.0.1", true), + ("1.0.0-rc.2", "1.0.0-rc.1", false), + ("1.0.0-rc.2", "1.0.0-rc.10", true), + ("1.0.0+build.2", "1.0.0", true), + ("1.0.0", "1.0.0+build.2", true), + ("1.0.0+build.2", "1.0.0+build.1", true), + ("1.0.0-rc.2+build.2", "1.0.0-rc.2+build.1", true), + ("1.0.0+build.1", "1.0.0-rc.1+build.9", false), + ("1.0.1+build.1", "1.0.0+build.9", false), + ] { + let minimum_version = CodexClientVersion::parse(minimum).unwrap(); + let current_version = CodexClientVersion::parse(current).unwrap(); + let policy = + CodexClientMinVersions::new(Some(minimum_version.clone()), Some(minimum_version)); + for kind in [CodexClientKind::Cli, CodexClientKind::Desktop] { + assert_eq!( + policy.enforce(kind, Some(¤t_version)).is_ok(), + allowed, + "{kind:?}: {current} >= {minimum}" + ); + } + } } diff --git a/docs/api.md b/docs/api.md index fe429d063..560eac7eb 100644 --- a/docs/api.md +++ b/docs/api.md @@ -42,10 +42,16 @@ Client Key 通过账号分组限定路由范围:未绑定分组时可使用全 重放安全边界时跨 Provider fallback 运行设置可以分别配置 `minCodexDesktopVersion` 与 `minCodexCliVersion`。两者只接受 SemVer,`null` -表示不限制。API 在 Client Key 鉴权成功后识别官方 Desktop/CLI 请求头;适用门禁的客户端没有合法版本,或版本 -低于对应门槛时,除只读 `/v1/usage` 外的 `/v1/*` HTTP 请求和新 WebSocket 握手在访问上游前返回 `426 Upgrade Required`。 +表示不限制,比较版本时忽略 `+` 后的构建元数据。API 在 Client Key 鉴权成功后识别官方客户端请求头; +适用门禁的客户端没有合法版本,或版本低于对应门槛时,除只读 `/v1/usage` 外的 `/v1/*` HTTP 请求和新 WebSocket +握手在访问上游前返回 `426 Upgrade Required`。 未知客户端保持兼容,不应用版本门禁 +`minCodexCliVersion` 检查官方客户端 User-Agent 产品段中的 Core 版本,支持 +`codex_cli_rs`、`codex-cli`、`codex-tui`、`codex_exec`、`codex_vscode`、`codex_sdk_ts`、 +`codex_python_sdk`、`codex-app-server`、`codex-app-server-sdk` 与 `codex_mcp_server` +名称按完整标识匹配且不区分大小写,括号后缀中的应用或 SDK 版本不能替代 Core 版本 + Desktop 应用版本优先取 `version` 头,未提供时取 User-Agent 中的 `(Codex Desktop; <版本>)`。 ChatGPT 远程控制使用 `(codex_chatgpt_<平台>_remote; <版本>)` 形式的 User-Agent 后缀,已知平台包括 `android` 和 `ios`。网关按该命名格式识别非空的平台名,后缀须完整,平台名和版本均不能含空白、括号或分号。 diff --git a/frontend/src/i18n/locales/en/settings.json b/frontend/src/i18n/locales/en/settings.json index de1bf39fd..d8299640b 100644 --- a/frontend/src/i18n/locales/en/settings.json +++ b/frontend/src/i18n/locales/en/settings.json @@ -471,7 +471,7 @@ "codexDesktopMinimumVersion": "Codex Desktop minimum version", "onlyCheckDesktopVersionEG": "Only check the desktop version, e.g. 26.825.51511", "codexCliMinimumVersion": "Codex CLI minimum version", - "minimumVersionHelp": "Checks standalone CLI versions only, for example 0.152.0" + "minimumVersionHelp": "Official clients' Core version (CLI, IDE, SDK), e.g. 0.152.0" }, "privacy": { "preview": { diff --git a/frontend/src/i18n/locales/zh-Hans/settings.json b/frontend/src/i18n/locales/zh-Hans/settings.json index 742384189..6b8bbe9e0 100644 --- a/frontend/src/i18n/locales/zh-Hans/settings.json +++ b/frontend/src/i18n/locales/zh-Hans/settings.json @@ -471,7 +471,7 @@ "codexDesktopMinimumVersion": "Codex Desktop 最低版本", "onlyCheckDesktopVersionEG": "只检查桌面端应用版本,例如 26.825.51511", "codexCliMinimumVersion": "Codex CLI 最低版本", - "minimumVersionHelp": "只检查独立终端版本,例如 0.152.0" + "minimumVersionHelp": "官方 CLI、IDE、SDK 等客户端的 Core 版本,例如 0.152.0" }, "privacy": { "preview": { diff --git a/frontend/src/i18n/locales/zh-Hant/settings.json b/frontend/src/i18n/locales/zh-Hant/settings.json index ef0708d61..4810663cb 100644 --- a/frontend/src/i18n/locales/zh-Hant/settings.json +++ b/frontend/src/i18n/locales/zh-Hant/settings.json @@ -471,7 +471,7 @@ "codexDesktopMinimumVersion": "Codex Desktop 最低版本", "onlyCheckDesktopVersionEG": "只檢查桌面端應用版本,例如 26.825.51511", "codexCliMinimumVersion": "Codex CLI 最低版本", - "minimumVersionHelp": "只檢查獨立終端版本,例如 0.152.0" + "minimumVersionHelp": "官方 CLI、IDE、SDK 等客戶端的 Core 版本,例如 0.152.0" }, "privacy": { "preview": {