mirror of
https://github.com/zyycn/codex-proxy-rs.git
synced 2026-11-06 16:06:08 +00:00
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:
@@ -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
@@ -217,7 +217,8 @@ goals = true
|
||||
先备份并区分本地真实账号文件与代理密钥文件,不要直接删除全部登录状态。
|
||||
- 已调用生图但失败:查看服务端账号的凭据、权限、额度和请求错误,不能只凭文本对话成功判断。
|
||||
- `401`:检查代理密钥是否正确、是否启用;Actor 标记不能代替密钥。
|
||||
- `426`:客户端低于管理员设置的最低版本,或版本无法识别,需要更新客户端。
|
||||
- `426`:检查客户端版本及反向代理是否保留版本头,低于管理员设置的最低版本时需要更新客户端。
|
||||
手机远程控制的版本识别范围见 [版本门禁规则](../docs/api.md#1-鉴权与公共约定)。
|
||||
- 地址包含 `5173/dev/v1`:这是开发代理地址,依赖 Vite 服务。日常使用改成后端或 HTTPS 地址;
|
||||
验证 WebSocket 时直接连接后端,当前 Vite 代理未显式开启 WebSocket 转发。
|
||||
|
||||
|
||||
+9
-2
@@ -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`。
|
||||
|
||||
### 管理接口
|
||||
|
||||
|
||||
@@ -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-鉴权与公共约定)。
|
||||
|
||||
核心不变量:
|
||||
|
||||
|
||||
Reference in New Issue
Block a user