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 子模块在同一目录开发: