diff --git a/backend/crates/gateway-api/src/openai/auth.rs b/backend/crates/gateway-api/src/openai/auth.rs index 8b89f7b58..7bbe2bc6e 100644 --- a/backend/crates/gateway-api/src/openai/auth.rs +++ b/backend/crates/gateway-api/src/openai/auth.rs @@ -143,7 +143,7 @@ pub(crate) fn client_access_error_response(error: ClientAccessError) -> Response } } -/// Desktop 优先于其内嵌的 CLI/Core 标记;未知客户端保持兼容并返回 `None`。 +/// Desktop 优先于内嵌 CLI/Core;未知客户端及未提供应用版本的手机远程客户端返回 `None`。 #[must_use] pub fn identify_codex_client(headers: &HeaderMap) -> Option { const MAXIMUM_HEADER_LENGTH: usize = 4096; @@ -154,11 +154,21 @@ pub fn identify_codex_client(headers: &HeaderMap) -> Option CodexClientVersion::parse(value).ok(), - None => user_agent - .and_then(desktop_version_from_user_agent) - .and_then(|value| CodexClientVersion::parse(value).ok()), + None => user_agent_version.and_then(|value| CodexClientVersion::parse(value).ok()), }; return Some(IdentifiedCodexClient { kind: CodexClientKind::Desktop, @@ -206,11 +216,34 @@ fn bounded_ascii_header<'a>( 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..] + // 空候选也代表已提供应用版本,不能被手机远程的缺失版本规则放行。 + user_agent[start..] .trim_start() .split([')', ' ', ';', ',']) - .next()?; - (!version.is_empty()).then_some(version) + .next() +} + +fn has_chatgpt_remote_user_agent_suffix(user_agent: &str) -> bool { + let Some((_, suffix)) = user_agent + .strip_suffix(')') + .and_then(|value| value.rsplit_once(" (")) + else { + return false; + }; + let Some((name, version)) = suffix.split_once("; ") else { + return false; + }; + let Some(platform) = name + .strip_prefix("codex_chatgpt_") + .and_then(|value| value.strip_suffix("_remote")) + else { + return false; + }; + [platform, version].into_iter().all(|value| { + !value.is_empty() + && !value.contains(['(', ')', ';']) + && !value.bytes().any(|byte| byte.is_ascii_whitespace()) + }) } fn contains_ascii_case_insensitive(haystack: &str, needle: &str) -> bool { diff --git a/backend/crates/gateway-api/tests/openai/auth.rs b/backend/crates/gateway-api/tests/openai/auth.rs index 7a24938c8..5d31ad170 100644 --- a/backend/crates/gateway-api/tests/openai/auth.rs +++ b/backend/crates/gateway-api/tests/openai/auth.rs @@ -180,3 +180,121 @@ fn cli_user_agent_should_expose_semver_or_recognized_missing_version() { .is_none() ); } + +#[test] +fn chatgpt_remote_desktop_without_app_version_should_not_use_a_version_gate() { + for name in [ + "codex_chatgpt_android_remote", + "codex_chatgpt_ios_remote", + "codex_chatgpt_future_os_remote", + ] { + for remote_version in ["dev", "1.2.3"] { + for (product, originator) in [ + ("Codex Desktop", None), + ("codex_cli_rs", Some("Codex Desktop")), + ] { + let mut headers = HeaderMap::new(); + headers.insert( + "user-agent", + HeaderValue::from_str(&format!( + "{product}/0.154.0-alpha.6.2 (Windows 10.0.26200; x86_64) unknown ({name}; {remote_version})" + )) + .unwrap(), + ); + if let Some(originator) = originator { + headers.insert("originator", HeaderValue::from_static(originator)); + } + assert_eq!(identify_codex_client(&headers), None, "{headers:?}"); + } + } + } +} + +#[test] +fn chatgpt_remote_marker_should_require_a_complete_exact_client_suffix() { + for suffix in [ + "(codex_chatgpt_android_remote_extra; dev)", + "(unofficial_codex_chatgpt_ios_remote; dev)", + "(codex_chatgpt__remote; dev)", + "(codex_chatgpt_remote; dev)", + "(codex_chatgpt_future os_remote; dev)", + "(codex_chatgpt_future;os_remote; dev)", + "codex_chatgpt_android_remote; dev", + "(codex_chatgpt_android_remote; dev", + "(codex_chatgpt_android_remote; dev) trailing", + "(codex_chatgpt_android_remote; dev) (another_client; dev)", + "(codex_chatgpt_android_remote; )", + "(another_client; codex_chatgpt_android_remote)", + "(Codex Desktop; dev)", + "(Codex Desktop; ) (codex_chatgpt_android_remote; dev)", + "(Codex Desktop; invalid) (codex_chatgpt_ios_remote; dev)", + "(Codex Desktop)", + ] { + let mut headers = HeaderMap::new(); + headers.insert( + "user-agent", + HeaderValue::from_str(&format!("Codex Desktop/0.154.0-alpha.6.2 {suffix}")).unwrap(), + ); + let client = identify_codex_client(&headers).expect("Desktop still requires a version"); + assert_eq!(client.kind(), CodexClientKind::Desktop, "{suffix}"); + assert!(client.version().is_none(), "{suffix}"); + } +} + +#[test] +fn chatgpt_remote_desktop_should_preserve_explicit_version_validation() { + for (value, expected) in [ + ( + HeaderValue::from_static("26.908.70816"), + Some("26.908.70816"), + ), + (HeaderValue::from_static("26.1.0"), Some("26.1.0")), + (HeaderValue::from_static("dev"), None), + (HeaderValue::from_static(""), None), + (HeaderValue::from_bytes(&[0xff]).unwrap(), None), + ] { + let mut headers = HeaderMap::new(); + headers.insert("version", value); + headers.insert( + "user-agent", + HeaderValue::from_static( + "Codex Desktop/0.154.0-alpha.6.2 (codex_chatgpt_android_remote; dev)", + ), + ); + let client = identify_codex_client(&headers).expect("explicit Desktop version"); + assert_eq!(client.kind(), CodexClientKind::Desktop); + assert_eq!( + client.version().map(ToString::to_string).as_deref(), + expected + ); + } +} + +#[test] +fn chatgpt_remote_marker_should_not_override_an_existing_desktop_version_suffix() { + let mut headers = HeaderMap::new(); + headers.insert( + "user-agent", + HeaderValue::from_static( + "Codex Desktop/0.154.0-alpha.6.2 (Codex Desktop; 26.1.0) (codex_chatgpt_ios_remote; dev)", + ), + ); + let client = identify_codex_client(&headers).expect("Desktop version remains available"); + assert_eq!(client.version().unwrap().to_string(), "26.1.0"); +} + +#[test] +fn chatgpt_remote_suffix_created_by_header_truncation_should_not_skip_the_gate() { + let suffix = " (codex_chatgpt_android_remote; dev)"; + let mut user_agent = "Codex Desktop/0.154.0-alpha.6.2 ".to_owned(); + user_agent.push_str(&"x".repeat(4096 - user_agent.len() - suffix.len())); + user_agent.push_str(suffix); + user_agent.push_str(" (another_client; dev)"); + let mut headers = HeaderMap::new(); + headers.insert("user-agent", HeaderValue::from_str(&user_agent).unwrap()); + + let client = + identify_codex_client(&headers).expect("incomplete headers still require a version"); + assert_eq!(client.kind(), CodexClientKind::Desktop); + assert!(client.version().is_none()); +} diff --git a/backend/crates/gateway-api/tests/openai/responses/http.rs b/backend/crates/gateway-api/tests/openai/responses/http.rs index 1c0bf6066..9395d7f65 100644 --- a/backend/crates/gateway-api/tests/openai/responses/http.rs +++ b/backend/crates/gateway-api/tests/openai/responses/http.rs @@ -41,7 +41,9 @@ use serde_json::{Value, json}; use gateway_api::openai::responses::{collect_execution_response, stream_execution_response}; use tower::ServiceExt; -use crate::openai::{api_router, authenticated_client_for_provider}; +use crate::openai::{ + api_router, authenticated_client_for_provider, authenticated_client_with_min_versions, +}; #[derive(Default)] struct Trace { @@ -1968,6 +1970,112 @@ struct SessionExecution { session: Mutex>>, } +#[tokio::test] +async fn chatgpt_remote_responses_should_only_skip_missing_desktop_versions() { + for name in [ + "codex_chatgpt_android_remote", + "codex_chatgpt_ios_remote", + "codex_chatgpt_future_os_remote", + ] { + for (version, expected_code) in [ + (None, None), + (Some("26.908.70816"), None), + (Some("26.1.0"), Some("client_version_too_old")), + (Some("dev"), Some("client_version_unavailable")), + ] { + let trace = Arc::new(Trace::default()); + let execution = Arc::new(SessionExecution { + client: authenticated_client_with_min_versions( + "sk_remote_test", + Some("26.908.70816"), + Some("0.154.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, + ], + )))), + }); + let mut request = Request::post("/v1/responses") + .header(AUTHORIZATION, "Bearer sk_remote_test") + .header("user-agent", format!( + "Codex Desktop/0.154.0-alpha.6.2 (Windows 10.0.26200; x86_64) unknown ({name}; dev)" + )); + if let Some(version) = version { + request = request.header("version", version); + } + let response = api_router(execution.clone()) + .await + .oneshot( + request + .body(Body::from( + json!({"model": "model-a", "input": "synthetic", "stream": true}) + .to_string(), + )) + .unwrap(), + ) + .await + .unwrap(); + let status = response.status(); + let body = to_bytes(response.into_body(), usize::MAX).await.unwrap(); + if let Some(code) = expected_code { + let value: Value = serde_json::from_slice(&body).unwrap(); + assert_eq!(status, StatusCode::UPGRADE_REQUIRED, "{name} {version:?}"); + assert_eq!(value["error"]["code"], code); + assert!(execution.session.lock().unwrap().is_some()); + } else { + assert_eq!(status, StatusCode::OK, "{name} {version:?}"); + let body = String::from_utf8(body.to_vec()).unwrap(); + assert!(body.contains("event: response.completed\n")); + assert!(body.ends_with("data: [DONE]\n\n")); + assert!(trace.snapshot().contains(&"commit")); + } + } + } +} + +#[tokio::test] +async fn chatgpt_remote_responses_should_still_require_client_key_authentication() { + let observed = Arc::new(Mutex::new(None)); + let execution = Arc::new(ContextCaptureExecution { + observed: Arc::clone(&observed), + client: authenticated_client_with_min_versions( + "sk_context_test", + Some("26.908.70816"), + None, + ), + }); + let router = api_router(execution).await; + for authorization in [None, Some("Bearer sk_invalid")] { + let mut request = Request::post("/v1/responses").header( + "user-agent", + "Codex Desktop/0.154.0-alpha.6.2 (codex_chatgpt_android_remote; dev)", + ); + if let Some(authorization) = authorization { + request = request.header(AUTHORIZATION, authorization); + } + let response = router + .clone() + .oneshot( + request + .body(Body::from( + json!({"model": "model-a", "input": "synthetic"}).to_string(), + )) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::UNAUTHORIZED); + assert!(observed.lock().unwrap().is_none()); + } +} + impl ExecutionService for SessionExecution { fn authenticate(&self, _: &str) -> Result { Ok(self.client.clone()) diff --git a/deploy/README.md b/deploy/README.md index 7ab03f620..17963b9e6 100644 --- a/deploy/README.md +++ b/deploy/README.md @@ -217,7 +217,8 @@ goals = true 先备份并区分本地真实账号文件与代理密钥文件,不要直接删除全部登录状态。 - 已调用生图但失败:查看服务端账号的凭据、权限、额度和请求错误,不能只凭文本对话成功判断。 - `401`:检查代理密钥是否正确、是否启用;Actor 标记不能代替密钥。 -- `426`:客户端低于管理员设置的最低版本,或版本无法识别,需要更新客户端。 +- `426`:检查客户端版本及反向代理是否保留版本头,低于管理员设置的最低版本时需要更新客户端。 + 手机远程控制的版本识别范围见 [版本门禁规则](../docs/api.md#1-鉴权与公共约定)。 - 地址包含 `5173/dev/v1`:这是开发代理地址,依赖 Vite 服务。日常使用改成后端或 HTTPS 地址; 验证 WebSocket 时直接连接后端,当前 Vite 代理未显式开启 WebSocket 转发。 diff --git a/docs/api.md b/docs/api.md index a2e3ef767..cd6b3cb05 100644 --- a/docs/api.md +++ b/docs/api.md @@ -26,10 +26,17 @@ Client Key 通过账号分组限定路由范围:未绑定分组时可使用全 重放安全边界时跨 Provider fallback。 运行设置可以分别配置 `minCodexDesktopVersion` 与 `minCodexCliVersion`。两者只接受 SemVer,`null` -表示不限制。API 在 Client Key 鉴权成功后识别官方 Desktop/CLI 请求头;已识别客户端没有合法版本,或版本 +表示不限制。API 在 Client Key 鉴权成功后识别官方 Desktop/CLI 请求头;适用门禁的客户端没有合法版本,或版本 低于对应门槛时,所有 `/v1/*` HTTP 请求和新 WebSocket 握手在访问上游前返回 `426 Upgrade Required`。 未知客户端保持兼容,不应用版本门禁。 +Desktop 应用版本优先取 `version` 头,未提供时取 User-Agent 中的 `(Codex Desktop; <版本>)`。 +ChatGPT 远程控制使用 `(codex_chatgpt_<平台>_remote; <版本>)` 形式的 User-Agent 后缀,已知平台包括 +`android` 和 `ios`。网关按该命名格式识别非空的平台名,后缀须完整,平台名和版本均不能含空白、括号或分号。 +这类 Desktop 请求未提供应用版本时不应用版本门禁;Core 版本和 +远程客户端版本不能替代 Desktop 应用版本,因此也无法保证其满足 Desktop 最低版本要求。 +携带 `version` 头或 Desktop 应用版本后缀时仍按上述规则校验,非法版本不会因远程标记而放行。 + 低版本响应使用 OpenAI 风格错误格式: ```json @@ -45,7 +52,7 @@ Client Key 通过账号分组限定路由范围:未绑定分组时可使用全 } ``` -已识别但缺失或携带非法版本时,`code` 为 `client_version_unavailable`,`current_version` 为 `null`。 +因缺失或非法版本被门禁拒绝时,`code` 为 `client_version_unavailable`,`current_version` 为 `null`。 ### 管理接口 diff --git a/docs/architecture.md b/docs/architecture.md index c531f483f..7a992be93 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -167,7 +167,9 @@ sequenceDiagram Client Key 鉴权完成后,API adapter 从有界请求头识别 Codex Desktop/CLI,Core 使用同一请求冻结的 `RuntimeSnapshot` 比较对应最低版本。Desktop 优先于其 User-Agent 内嵌的 CLI/Core 标记;未知客户端不 -应用门禁。低版本或已识别但版本不可用时,在进入 Provider 前返回稳定的 `426` 合同。 +应用门禁。API 识别手机远程 UA 后缀;这类 Desktop 请求缺失应用版本时不应用门禁,不以 Core 或远程 +客户端版本代替应用版本。其余低版本或已识别但版本不可用时,在进入 Provider 前返回稳定的 `426` 合同, +具体请求头规则见 [API 鉴权与公共约定](api.md#1-鉴权与公共约定)。 核心不变量: