From 1271fa01863091c6974ac159ca87d00dae861bf8 Mon Sep 17 00:00:00 2001 From: Zyy Date: Wed, 11 Nov 2026 12:41:04 +0800 Subject: [PATCH] test(openai): verify platform TLS against official Codex Compare native HTTP, custom CA HTTP, and WebSocket ClientHello captures with an official Codex executable on the same platform. Document the explicit reference test workflow. Co-authored-by: Codex --- backend/crates/providers/openai/Cargo.toml | 2 +- .../providers/openai/tests/transport/tls.rs | 148 ++++++++++++++++-- docs/development.md | 26 +++ 3 files changed, 166 insertions(+), 10 deletions(-) diff --git a/backend/crates/providers/openai/Cargo.toml b/backend/crates/providers/openai/Cargo.toml index a13fde53b..3f702b4ca 100644 --- a/backend/crates/providers/openai/Cargo.toml +++ b/backend/crates/providers/openai/Cargo.toml @@ -79,7 +79,7 @@ openssl-sys = { version = "=0.9.116", features = ["vendored"] } gateway-host = { path = "../../gateway-host" } allocation-counter = "0.8.1" tempfile = "3.27.0" -tokio = { version = "1.53.3", default-features = false, features = ["rt-multi-thread", "test-util"] } +tokio = { version = "1.53.3", default-features = false, features = ["process", "rt-multi-thread", "test-util"] } tracing-subscriber = { version = "0.3.23", features = ["json"] } wiremock = "0.6.5" diff --git a/backend/crates/providers/openai/tests/transport/tls.rs b/backend/crates/providers/openai/tests/transport/tls.rs index baba51dd2..c32a6aaba 100644 --- a/backend/crates/providers/openai/tests/transport/tls.rs +++ b/backend/crates/providers/openai/tests/transport/tls.rs @@ -1,11 +1,17 @@ //! 验证 HTTP 与 WebSocket 的 TLS 握手特征 use std::collections::BTreeMap; +use std::path::Path; +use std::process::Stdio; use bytes::{Buf as _, Bytes}; use super::*; +const CASE_ENV: &str = "CODEX_PROXY_TEST_HTTP_CLIENT_HELLO"; +const CASE_COMPLETED: &str = "http-tls-client-hello-case-completed"; +const REFERENCE_ENV: &str = "CODEX_PROXY_TEST_REFERENCE_BINARY"; + // 基线来自官方 Codex 823ea830c0 的客户端源码,各路径重复采样五次 // HTTP 另与本机 Core 0.160.0 的采样核对,源码客户端与本项目还通过真实 models 请求验证 // HTTP/native-tls 的扩展顺序固定;WebSocket/rustls 会随机化扩展顺序和临时密钥 @@ -25,9 +31,6 @@ struct ClientHello { #[test] fn http_client_hellos_should_match_official_tls_transports() { - const CASE_ENV: &str = "CODEX_PROXY_TEST_HTTP_CLIENT_HELLO"; - const CASE_COMPLETED: &str = "http-tls-client-hello-case-completed"; - if let Ok(case) = std::env::var(CASE_ENV) { let runtime = tokio::runtime::Builder::new_current_thread() .enable_all() @@ -54,22 +57,81 @@ fn http_client_hellos_should_match_official_tls_transports() { return; } + run_isolated_cases( + "transport::tls::http_client_hellos_should_match_official_tls_transports", + &["native", "custom-ca"], + ); +} + +#[test] +#[ignore = "requires CODEX_PROXY_TEST_REFERENCE_BINARY pointing to the official Codex executable"] +fn client_hellos_should_match_the_official_binary_on_this_platform() { + let reference = std::env::var_os(REFERENCE_ENV) + .map(std::path::PathBuf::from) + .expect("set CODEX_PROXY_TEST_REFERENCE_BINARY to the official Codex executable") + .canonicalize() + .expect("official Codex executable path"); + if let Ok(case) = std::env::var(CASE_ENV) { + let runtime = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .unwrap(); + runtime.block_on(async { + for sample in 0..3 { + let mut expected = capture_official_hello(&reference, case == "websocket").await; + let mut actual = if case == "websocket" { + capture_websocket_hello().await + } else { + capture_http_hello(provider_openai::transport::build_reqwest_client().unwrap()) + .await + }; + // rustls 随机排列扩展,其他字段及各自列表的顺序仍逐项核对 + if case != "native" { + expected.extensions.sort_unstable(); + actual.extensions.sort_unstable(); + } + assert_eq!(actual, expected, "{case} ClientHello sample {sample}"); + } + }); + println!("\n{CASE_COMPLETED}"); + return; + } + + let version = Command::new(&reference) + .arg("--version") + .output() + .expect("read official Codex version"); + assert!(version.status.success()); + println!( + "TLS reference: {} on {}-{}", + String::from_utf8_lossy(&version.stdout).trim(), + std::env::consts::OS, + std::env::consts::ARCH, + ); + run_isolated_cases( + "transport::tls::client_hellos_should_match_the_official_binary_on_this_platform", + &["native", "custom-ca", "websocket"], + ); +} + +fn run_isolated_cases(test_name: &str, cases: &[&str]) { // Cargo 运行测试时可能注入 SSL_CERT_FILE,生产代码会把它视作自定义 CA 并切到 rustls; // 因此在清理相关环境变量的子进程里验证默认路径 let current_exe = std::env::current_exe().expect("current test binary path"); let directory = tempfile::tempdir().unwrap(); let ca_path = directory.path().join("ca.pem"); std::fs::write(&ca_path, TEST_CA_PEM).unwrap(); - for case in ["native", "custom-ca"] { + for case in cases { let mut command = Command::new(¤t_exe); command .arg("--exact") - .arg("transport::tls::http_client_hellos_should_match_official_tls_transports") + .arg(test_name) + .arg("--include-ignored") .arg("--nocapture") .env(CASE_ENV, case) .env_remove(provider_openai::transport::tls::CODEX_CA_CERT_ENV) .env_remove(provider_openai::transport::tls::SSL_CERT_FILE_ENV); - if case == "custom-ca" { + if *case == "custom-ca" { command.env(provider_openai::transport::tls::CODEX_CA_CERT_ENV, &ca_path); } let output = command.output().expect("run isolated TLS ClientHello case"); @@ -91,6 +153,70 @@ fn http_client_hellos_should_match_official_tls_transports() { } } +async fn capture_official_hello(reference: &Path, websocket: bool) -> ClientHello { + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let endpoint = format!( + "https://localhost:{}/v1", + listener.local_addr().unwrap().port() + ); + let directory = tempfile::tempdir().unwrap(); + // 官方进程只访问本机采集端点,握手后即终止,认证使用无效测试值 + let mut command = tokio::process::Command::new(reference); + command + .args([ + "exec", + "--ignore-user-config", + "--ignore-rules", + "--ephemeral", + "--skip-git-repo-check", + "--sandbox", + "read-only", + "--json", + "--model", + "tls-probe", + ]) + .current_dir(directory.path()) + .env("CPR_TLS_PROBE_API_KEY", "test-local-handshake-only") + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .kill_on_drop(true); + for name in [ + "HTTP_PROXY", + "HTTPS_PROXY", + "ALL_PROXY", + "http_proxy", + "https_proxy", + "all_proxy", + ] { + command.env_remove(name); + } + for setting in [ + "model_provider=\"cpr_tls_probe\"".to_owned(), + "model_providers.cpr_tls_probe.name=\"CPR TLS Probe\"".to_owned(), + format!("model_providers.cpr_tls_probe.base_url=\"{endpoint}\""), + "model_providers.cpr_tls_probe.env_key=\"CPR_TLS_PROBE_API_KEY\"".to_owned(), + "model_providers.cpr_tls_probe.wire_api=\"responses\"".to_owned(), + format!("model_providers.cpr_tls_probe.supports_websockets={websocket}"), + "model_providers.cpr_tls_probe.request_max_retries=0".to_owned(), + "model_providers.cpr_tls_probe.stream_max_retries=0".to_owned(), + "project_doc_max_bytes=0".to_owned(), + ] { + command.args(["--config", &setting]); + } + let mut child = command + .arg("TLS handshake capture") + .spawn() + .expect("start official Codex TLS reference"); + let hello = timeout(Duration::from_secs(30), read_client_hello(listener)).await; + let _ = child.kill().await; + child + .wait() + .await + .expect("reap official Codex TLS reference"); + hello.expect("official Codex ClientHello within timeout") +} + async fn capture_http_hello(client: reqwest::Client) -> ClientHello { let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); let url = format!( @@ -111,6 +237,12 @@ async fn capture_http_hello(client: reqwest::Client) -> ClientHello { #[tokio::test] async fn websocket_client_hello_should_match_official_rustls_transport() { + let mut hello = capture_websocket_hello().await; + hello.extensions.sort_unstable(); + assert_eq!(hello, official_websocket_hello()); +} + +async fn capture_websocket_hello() -> ClientHello { let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); let url = format!("wss://localhost:{}/", listener.local_addr().unwrap().port()); let connector = @@ -129,9 +261,7 @@ async fn websocket_client_hello_should_match_official_rustls_transport() { response.is_err(), "capture endpoint rejects TLS after ClientHello" ); - let mut normalized = hello; - normalized.extensions.sort_unstable(); - assert_eq!(normalized, official_websocket_hello()); + hello } #[cfg(target_os = "linux")] diff --git a/docs/development.md b/docs/development.md index 0013fbf25..73372e610 100644 --- a/docs/development.md +++ b/docs/development.md @@ -75,6 +75,32 @@ HTTP 入口位于 `backend/crates/gateway-api/src/openai/messages.rs`, 再核对工具往返、取消、错误和输入计数;请求格式及六种组合见 [模型数据面](api.md#3-模型数据面与模型目录)。 Messages 账号只配置 HTTP;本地计数估算不能替代上游计费核对 +TLS 指纹在发布目标对应的操作系统和架构上核验,参考程序使用同机运行的官方 Codex CLI 可执行文件。 +`CODEX_PROXY_TEST_REFERENCE_BINARY` 指向实际二进制,Windows 使用 `.exe`,npm 安装时使用平台包中的二进制。 +macOS / Linux 可设置: + +```bash +export CODEX_PROXY_TEST_REFERENCE_BINARY=/absolute/path/to/codex +export RUST_MIN_STACK=16777216 +``` + +Windows PowerShell 可设置: + +```powershell +$env:CODEX_PROXY_TEST_REFERENCE_BINARY = 'C:\path\to\codex.exe' +$env:RUST_MIN_STACK = '16777216' +``` + +随后在仓库根目录执行: + +```text +cargo +1.99.1 test --manifest-path backend/Cargo.toml --locked -p provider-openai --test main transport::tls:: -- --include-ignored --nocapture --test-threads=1 +``` + +对照用例记录官方版本及运行平台,分别采样默认 HTTP、自定义 CA 的 HTTP 和 WebSocket,各重复三次。 +默认 HTTP 保留扩展顺序逐项比较;rustls 路径对随机扩展顺序排序后比较,算法列表、密钥交换组和 ALPN 仍核对原有顺序。 +采集在本机 TLS 握手阶段结束,使用测试认证值,无需真实账号。普通测试默认忽略官方二进制对照用例,须以上述命令显式执行 + ## 源码联调 宿主、组件库和官方插件保留独立仓库,通过 Git 子模块在同一目录开发: