mirror of
https://github.com/zyycn/codex-proxy-rs.git
synced 2026-11-06 08:06:19 +00:00
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 <noreply@openai.com>
This commit is contained in:
@@ -153,6 +153,19 @@ pub(crate) fn client_access_error_response(error: ClientAccessError) -> Response
|
||||
#[must_use]
|
||||
pub fn identify_codex_client(headers: &HeaderMap) -> Option<IdentifiedCodexClient> {
|
||||
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<IdentifiedCodexClien
|
||||
for product in user_agent.split(|character: char| {
|
||||
character.is_ascii_whitespace() || matches!(character, '(' | ')' | ';' | ',' | '[' | ']')
|
||||
}) {
|
||||
for prefix in ["codex_cli_rs", "codex-cli"] {
|
||||
if product.eq_ignore_ascii_case(prefix) {
|
||||
return Some(IdentifiedCodexClient {
|
||||
kind: CodexClientKind::Cli,
|
||||
version: None,
|
||||
});
|
||||
}
|
||||
let Some((name, version)) = product.split_once('/') else {
|
||||
continue;
|
||||
};
|
||||
if name.eq_ignore_ascii_case(prefix) {
|
||||
return Some(IdentifiedCodexClient {
|
||||
kind: CodexClientKind::Cli,
|
||||
version: CodexClientVersion::parse(version).ok(),
|
||||
});
|
||||
}
|
||||
let (name, version) = product
|
||||
.split_once('/')
|
||||
.map_or((product, None), |(name, version)| (name, Some(version)));
|
||||
if CORE_CLIENT_PRODUCTS
|
||||
.iter()
|
||||
.any(|known| name.eq_ignore_ascii_case(known))
|
||||
{
|
||||
return Some(IdentifiedCodexClient {
|
||||
kind: CodexClientKind::Cli,
|
||||
version: version.and_then(|value| CodexClientVersion::parse(value).ok()),
|
||||
});
|
||||
}
|
||||
}
|
||||
None
|
||||
|
||||
@@ -229,29 +229,67 @@ fn desktop_headers_should_take_precedence_over_embedded_cli_marker() {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cli_user_agent_should_expose_semver_or_recognized_missing_version() {
|
||||
let mut headers = HeaderMap::new();
|
||||
headers.insert(
|
||||
"user-agent",
|
||||
HeaderValue::from_static("codex_cli_rs/0.39.0 (Linux; x86_64)"),
|
||||
);
|
||||
let identified = identify_codex_client(&headers).expect("recognized CLI");
|
||||
assert_eq!(identified.kind(), CodexClientKind::Cli);
|
||||
assert_eq!(
|
||||
identified.version().map(ToString::to_string).as_deref(),
|
||||
Some("0.39.0")
|
||||
);
|
||||
fn official_core_clients_should_use_the_core_version_instead_of_the_app_suffix() {
|
||||
for product in [
|
||||
"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",
|
||||
"CoDeX-TuI",
|
||||
] {
|
||||
for (version, expected) in [
|
||||
(Some("0.158.0"), Some("0.158.0")),
|
||||
(
|
||||
Some("0.165.0-alpha.1+build.2"),
|
||||
Some("0.165.0-alpha.1+build.2"),
|
||||
),
|
||||
(Some("not-a-version"), None),
|
||||
(Some(""), None),
|
||||
(None, None),
|
||||
] {
|
||||
let prefix = version.map_or_else(|| product.to_owned(), |v| format!("{product}/{v}"));
|
||||
let mut headers = HeaderMap::new();
|
||||
headers.insert(
|
||||
"user-agent",
|
||||
HeaderValue::from_str(&format!(
|
||||
"{prefix} (Mac OS 26.6.2; arm64) Apple_Terminal ({product}; 0.165.0)"
|
||||
))
|
||||
.unwrap(),
|
||||
);
|
||||
let identified = identify_codex_client(&headers).expect("recognized official client");
|
||||
assert_eq!(identified.kind(), CodexClientKind::Cli, "{prefix}");
|
||||
assert_eq!(
|
||||
identified.version().map(ToString::to_string).as_deref(),
|
||||
expected,
|
||||
"{prefix}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
headers.insert(
|
||||
"user-agent",
|
||||
HeaderValue::from_static("codex-cli/not-a-version"),
|
||||
);
|
||||
assert!(
|
||||
identify_codex_client(&headers)
|
||||
.expect("recognized invalid CLI")
|
||||
.version()
|
||||
.is_none()
|
||||
);
|
||||
#[test]
|
||||
fn unknown_codex_like_products_should_remain_unrestricted() {
|
||||
for product in [
|
||||
"custom-codex-tui",
|
||||
"codex-tui-custom",
|
||||
"codex_cli_rs_custom",
|
||||
"codex_python_sdk_custom",
|
||||
"my_app",
|
||||
"deepseek-harness",
|
||||
] {
|
||||
let mut headers = HeaderMap::new();
|
||||
headers.insert(
|
||||
"user-agent",
|
||||
HeaderValue::from_str(&format!("{product}/0.158.0 (Linux; x86_64)")).unwrap(),
|
||||
);
|
||||
assert_eq!(identify_codex_client(&headers), None, "{product}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -78,9 +78,9 @@ impl ModelsExecution {
|
||||
})
|
||||
}
|
||||
|
||||
fn with_cli_min() -> Arc<Self> {
|
||||
pub(super) fn with_cli_min(minimum: &str) -> Arc<Self> {
|
||||
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")
|
||||
|
||||
@@ -2948,6 +2948,69 @@ struct SessionExecution {
|
||||
middleware: Option<FrozenMiddlewarePlan>,
|
||||
}
|
||||
|
||||
#[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;
|
||||
|
||||
@@ -1208,6 +1208,50 @@ fn upgrade_request(authorization: &str) -> Request<Body> {
|
||||
.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())
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+8
-2
@@ -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`。网关按该命名格式识别非空的平台名,后缀须完整,平台名和版本均不能含空白、括号或分号。
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
Reference in New Issue
Block a user