fix(api): allow ChatGPT remote Desktop sessions without app versions (#133)

* fix(api): allow ChatGPT remote Desktop sessions without app versions

Recognize the official Android and iOS remote User-Agent suffixes when
Desktop application version metadata is absent. Preserve explicit version
validation and Client Key authentication, cover the streaming request path,
and document the version enforcement boundary.

Refs #129

Co-authored-by: Codex <noreply@openai.com>

* fix(api): match ChatGPT remote client name family

Accept nonempty platforms in codex_chatgpt_*_remote while preserving suffix
token validation and explicit Desktop version enforcement. Cover future
platforms and malformed names in parser and streaming route tests.

Co-authored-by: Codex <noreply@openai.com>

---------

Co-authored-by: Zyy <zyycn@users.noreply.github.com>
Co-authored-by: Codex <noreply@openai.com>
This commit is contained in:
Zyy
2026-09-17 14:54:17 +08:00
committed by GitHub
co-authored by Codex Zyy
parent 590c4a163b
commit 47d08560a7
6 changed files with 281 additions and 12 deletions
+40 -7
View File
@@ -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<IdentifiedCodexClient> {
const MAXIMUM_HEADER_LENGTH: usize = 4096;
@@ -154,11 +154,21 @@ pub fn identify_codex_client(headers: &HeaderMap) -> Option<IdentifiedCodexClien
|| 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 user_agent_version = user_agent.and_then(desktop_version_from_user_agent);
// 手机远程初始化会覆盖进程级 UA 后缀,保留的 Desktop/Core 前缀不代表应用版本。
// 仅缺失版本信息时免于门禁;已提供的版本即使非法,也继续按 Desktop 校验。
if !headers.contains_key("version")
&& user_agent_version.is_none()
&& headers
.get("user-agent")
.is_some_and(|value| value.as_bytes().len() <= MAXIMUM_HEADER_LENGTH)
&& user_agent.is_some_and(has_chatgpt_remote_user_agent_suffix)
{
return None;
}
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()),
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 {
@@ -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());
}
@@ -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<Option<Box<dyn ExecutionSession>>>,
}
#[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<AuthenticatedClient, ClientAuthenticationError> {
Ok(self.client.clone())
+2 -1
View File
@@ -217,7 +217,8 @@ goals = true
先备份并区分本地真实账号文件与代理密钥文件,不要直接删除全部登录状态。
- 已调用生图但失败:查看服务端账号的凭据、权限、额度和请求错误,不能只凭文本对话成功判断。
- `401`:检查代理密钥是否正确、是否启用;Actor 标记不能代替密钥。
- `426`:客户端低于管理员设置的最低版本,或版本无法识别,需要更新客户端。
- `426`:检查客户端版本及反向代理是否保留版本头,低于管理员设置的最低版本时需要更新客户端。
手机远程控制的版本识别范围见 [版本门禁规则](../docs/api.md#1-鉴权与公共约定)。
- 地址包含 `5173/dev/v1`:这是开发代理地址,依赖 Vite 服务。日常使用改成后端或 HTTPS 地址;
验证 WebSocket 时直接连接后端,当前 Vite 代理未显式开启 WebSocket 转发。
+9 -2
View File
@@ -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`。
### 管理接口
+3 -1
View File
@@ -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-鉴权与公共约定)。
核心不变量: