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:
Zyy
2026-10-28 19:31:20 +08:00
co-authored by Codex
parent 53e1e9cfb8
commit be5b6d199e
11 changed files with 236 additions and 64 deletions
+24 -16
View File
@@ -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
+60 -22
View File
@@ -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(),
+25 -13
View File
@@ -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(&current_version)).is_ok(),
allowed,
"{kind:?}: {current} >= {minimum}"
);
}
}
}
+8 -2
View File
@@ -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`。网关按该命名格式识别非空的平台名,后缀须完整,平台名和版本均不能含空白、括号或分号。
+1 -1
View File
@@ -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": {