mirror of
https://github.com/openai/codex.git
synced 2026-05-17 17:53:06 +00:00
Stabilizes the Responses API proxy header test by splitting the coverage at the right boundary: - Core integration test now verifies parent/subagent identity headers directly from captured `/responses` requests. - Proxy dump unit test now verifies those identity headers are preserved in dumped request JSON. - Removes the flaky real proxy process + temp-file dump polling path from the core test.
379 lines
11 KiB
Rust
379 lines
11 KiB
Rust
use std::fs;
|
|
use std::io;
|
|
use std::io::Read;
|
|
use std::path::PathBuf;
|
|
use std::sync::atomic::AtomicU64;
|
|
use std::sync::atomic::Ordering;
|
|
use std::time::SystemTime;
|
|
use std::time::UNIX_EPOCH;
|
|
|
|
use reqwest::header::HeaderMap;
|
|
use serde::Serialize;
|
|
use serde_json::Value;
|
|
use tiny_http::Header;
|
|
use tiny_http::Method;
|
|
|
|
const AUTHORIZATION_HEADER_NAME: &str = "authorization";
|
|
const REDACTED_HEADER_VALUE: &str = "[REDACTED]";
|
|
|
|
pub(crate) struct ExchangeDumper {
|
|
dump_dir: PathBuf,
|
|
next_sequence: AtomicU64,
|
|
}
|
|
|
|
impl ExchangeDumper {
|
|
pub(crate) fn new(dump_dir: PathBuf) -> io::Result<Self> {
|
|
fs::create_dir_all(&dump_dir)?;
|
|
|
|
Ok(Self {
|
|
dump_dir,
|
|
next_sequence: AtomicU64::new(1),
|
|
})
|
|
}
|
|
|
|
pub(crate) fn dump_request(
|
|
&self,
|
|
method: &Method,
|
|
url: &str,
|
|
headers: &[Header],
|
|
body: &[u8],
|
|
) -> io::Result<ExchangeDump> {
|
|
let sequence = self.next_sequence.fetch_add(1, Ordering::Relaxed);
|
|
let timestamp_ms = SystemTime::now()
|
|
.duration_since(UNIX_EPOCH)
|
|
.map_or(0, |duration| duration.as_millis());
|
|
let prefix = format!("{sequence:06}-{timestamp_ms}");
|
|
|
|
let request_path = self.dump_dir.join(format!("{prefix}-request.json"));
|
|
let response_path = self.dump_dir.join(format!("{prefix}-response.json"));
|
|
|
|
let request_dump = RequestDump {
|
|
method: method.as_str().to_string(),
|
|
url: url.to_string(),
|
|
headers: headers.iter().map(HeaderDump::from).collect(),
|
|
body: dump_body(body),
|
|
};
|
|
|
|
write_json_dump(&request_path, &request_dump)?;
|
|
|
|
Ok(ExchangeDump { response_path })
|
|
}
|
|
}
|
|
|
|
pub(crate) struct ExchangeDump {
|
|
response_path: PathBuf,
|
|
}
|
|
|
|
impl ExchangeDump {
|
|
pub(crate) fn tee_response_body<R: Read>(
|
|
self,
|
|
status: u16,
|
|
headers: &HeaderMap,
|
|
response_body: R,
|
|
) -> ResponseBodyDump<R> {
|
|
ResponseBodyDump {
|
|
response_body,
|
|
response_path: self.response_path,
|
|
status,
|
|
headers: headers.iter().map(HeaderDump::from).collect(),
|
|
body: Vec::new(),
|
|
dump_written: false,
|
|
}
|
|
}
|
|
}
|
|
|
|
pub(crate) struct ResponseBodyDump<R> {
|
|
response_body: R,
|
|
response_path: PathBuf,
|
|
status: u16,
|
|
headers: Vec<HeaderDump>,
|
|
body: Vec<u8>,
|
|
dump_written: bool,
|
|
}
|
|
|
|
impl<R> ResponseBodyDump<R> {
|
|
fn write_dump_if_needed(&mut self) {
|
|
if self.dump_written {
|
|
return;
|
|
}
|
|
|
|
self.dump_written = true;
|
|
|
|
let response_dump = ResponseDump {
|
|
status: self.status,
|
|
headers: std::mem::take(&mut self.headers),
|
|
body: dump_body(&self.body),
|
|
};
|
|
|
|
if let Err(err) = write_json_dump(&self.response_path, &response_dump) {
|
|
eprintln!(
|
|
"responses-api-proxy failed to write {}: {err}",
|
|
self.response_path.display()
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
impl<R: Read> Read for ResponseBodyDump<R> {
|
|
fn read(&mut self, buf: &mut [u8]) -> io::Result<usize> {
|
|
let bytes_read = self.response_body.read(buf)?;
|
|
if bytes_read == 0 {
|
|
self.write_dump_if_needed();
|
|
return Ok(0);
|
|
}
|
|
|
|
self.body.extend_from_slice(&buf[..bytes_read]);
|
|
Ok(bytes_read)
|
|
}
|
|
}
|
|
|
|
impl<R> Drop for ResponseBodyDump<R> {
|
|
fn drop(&mut self) {
|
|
self.write_dump_if_needed();
|
|
}
|
|
}
|
|
|
|
#[derive(Serialize)]
|
|
struct RequestDump {
|
|
method: String,
|
|
url: String,
|
|
headers: Vec<HeaderDump>,
|
|
body: Value,
|
|
}
|
|
|
|
#[derive(Serialize)]
|
|
struct ResponseDump {
|
|
status: u16,
|
|
headers: Vec<HeaderDump>,
|
|
body: Value,
|
|
}
|
|
|
|
#[derive(Debug, Serialize)]
|
|
struct HeaderDump {
|
|
name: String,
|
|
value: String,
|
|
}
|
|
|
|
impl From<&Header> for HeaderDump {
|
|
fn from(header: &Header) -> Self {
|
|
let name = header.field.as_str().to_string();
|
|
let value = if should_redact_header(&name) {
|
|
REDACTED_HEADER_VALUE.to_string()
|
|
} else {
|
|
header.value.as_str().to_string()
|
|
};
|
|
|
|
Self { name, value }
|
|
}
|
|
}
|
|
|
|
impl From<(&reqwest::header::HeaderName, &reqwest::header::HeaderValue)> for HeaderDump {
|
|
fn from(header: (&reqwest::header::HeaderName, &reqwest::header::HeaderValue)) -> Self {
|
|
let name = header.0.as_str();
|
|
let value = if should_redact_header(name) {
|
|
REDACTED_HEADER_VALUE.to_string()
|
|
} else {
|
|
String::from_utf8_lossy(header.1.as_bytes()).into_owned()
|
|
};
|
|
|
|
Self {
|
|
name: name.to_string(),
|
|
value,
|
|
}
|
|
}
|
|
}
|
|
|
|
fn should_redact_header(name: &str) -> bool {
|
|
name.eq_ignore_ascii_case(AUTHORIZATION_HEADER_NAME)
|
|
|| name.to_ascii_lowercase().contains("cookie")
|
|
}
|
|
|
|
fn dump_body(body: &[u8]) -> Value {
|
|
serde_json::from_slice(body)
|
|
.unwrap_or_else(|_| Value::String(String::from_utf8_lossy(body).into_owned()))
|
|
}
|
|
|
|
fn write_json_dump(path: &PathBuf, dump: &impl Serialize) -> io::Result<()> {
|
|
let mut bytes = serde_json::to_vec_pretty(dump)
|
|
.map_err(|err| io::Error::new(io::ErrorKind::InvalidData, err))?;
|
|
bytes.push(b'\n');
|
|
fs::write(path, bytes)
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use std::fs;
|
|
use std::io::Cursor;
|
|
use std::io::Read;
|
|
use std::sync::atomic::AtomicU64;
|
|
use std::sync::atomic::Ordering;
|
|
|
|
use pretty_assertions::assert_eq;
|
|
use reqwest::header::AUTHORIZATION;
|
|
use reqwest::header::CONTENT_TYPE;
|
|
use reqwest::header::HeaderMap;
|
|
use reqwest::header::HeaderValue;
|
|
use serde_json::json;
|
|
use tiny_http::Header;
|
|
use tiny_http::Method;
|
|
|
|
use super::ExchangeDumper;
|
|
|
|
static NEXT_TEST_DIR: AtomicU64 = AtomicU64::new(0);
|
|
|
|
#[test]
|
|
fn dump_request_writes_redacted_headers_and_json_body() {
|
|
let dump_dir = test_dump_dir();
|
|
let dumper = ExchangeDumper::new(dump_dir.clone()).expect("create dumper");
|
|
let headers = vec![
|
|
Header::from_bytes(&b"Authorization"[..], &b"Bearer secret"[..])
|
|
.expect("authorization header"),
|
|
Header::from_bytes(&b"Cookie"[..], &b"user-session=secret"[..]).expect("cookie header"),
|
|
Header::from_bytes(&b"Content-Type"[..], &b"application/json"[..])
|
|
.expect("content-type header"),
|
|
Header::from_bytes(&b"x-codex-window-id"[..], &b"thread-1:0"[..])
|
|
.expect("window id header"),
|
|
Header::from_bytes(&b"x-codex-parent-thread-id"[..], &b"parent-thread-1"[..])
|
|
.expect("parent thread id header"),
|
|
Header::from_bytes(&b"x-openai-subagent"[..], &b"collab_spawn"[..])
|
|
.expect("subagent header"),
|
|
];
|
|
|
|
let exchange_dump = dumper
|
|
.dump_request(
|
|
&Method::Post,
|
|
"/v1/responses",
|
|
&headers,
|
|
br#"{"model":"gpt-5.4"}"#,
|
|
)
|
|
.expect("dump request");
|
|
|
|
let request_dump = fs::read_to_string(dump_file_with_suffix(&dump_dir, "-request.json"))
|
|
.expect("read request dump");
|
|
|
|
assert_eq!(
|
|
serde_json::from_str::<serde_json::Value>(&request_dump).expect("parse request dump"),
|
|
json!({
|
|
"method": "POST",
|
|
"url": "/v1/responses",
|
|
"headers": [
|
|
{
|
|
"name": "Authorization",
|
|
"value": "[REDACTED]"
|
|
},
|
|
{
|
|
"name": "Cookie",
|
|
"value": "[REDACTED]"
|
|
},
|
|
{
|
|
"name": "Content-Type",
|
|
"value": "application/json"
|
|
},
|
|
{
|
|
"name": "x-codex-window-id",
|
|
"value": "thread-1:0"
|
|
},
|
|
{
|
|
"name": "x-codex-parent-thread-id",
|
|
"value": "parent-thread-1"
|
|
},
|
|
{
|
|
"name": "x-openai-subagent",
|
|
"value": "collab_spawn"
|
|
}
|
|
],
|
|
"body": {
|
|
"model": "gpt-5.4"
|
|
}
|
|
})
|
|
);
|
|
assert!(
|
|
exchange_dump
|
|
.response_path
|
|
.file_name()
|
|
.expect("response dump file name")
|
|
.to_string_lossy()
|
|
.ends_with("-response.json")
|
|
);
|
|
|
|
fs::remove_dir_all(dump_dir).expect("remove test dump dir");
|
|
}
|
|
|
|
#[test]
|
|
fn response_body_dump_streams_body_and_writes_response_file() {
|
|
let dump_dir = test_dump_dir();
|
|
let dumper = ExchangeDumper::new(dump_dir.clone()).expect("create dumper");
|
|
let exchange_dump = dumper
|
|
.dump_request(&Method::Post, "/v1/responses", &[], b"{}")
|
|
.expect("dump request");
|
|
|
|
let mut headers = HeaderMap::new();
|
|
headers.insert(CONTENT_TYPE, HeaderValue::from_static("text/event-stream"));
|
|
headers.insert(AUTHORIZATION, HeaderValue::from_static("Bearer secret"));
|
|
headers.insert(
|
|
"set-cookie",
|
|
HeaderValue::from_static("user-session=secret"),
|
|
);
|
|
|
|
let mut response_body = String::new();
|
|
exchange_dump
|
|
.tee_response_body(
|
|
/*status*/ 200,
|
|
&headers,
|
|
Cursor::new(b"data: hello\n\n".to_vec()),
|
|
)
|
|
.read_to_string(&mut response_body)
|
|
.expect("read response body");
|
|
|
|
let response_dump = fs::read_to_string(dump_file_with_suffix(&dump_dir, "-response.json"))
|
|
.expect("read response dump");
|
|
|
|
assert_eq!(response_body, "data: hello\n\n");
|
|
assert_eq!(
|
|
serde_json::from_str::<serde_json::Value>(&response_dump).expect("parse response dump"),
|
|
json!({
|
|
"status": 200,
|
|
"headers": [
|
|
{
|
|
"name": "content-type",
|
|
"value": "text/event-stream"
|
|
},
|
|
{
|
|
"name": "authorization",
|
|
"value": "[REDACTED]"
|
|
},
|
|
{
|
|
"name": "set-cookie",
|
|
"value": "[REDACTED]"
|
|
}
|
|
],
|
|
"body": "data: hello\n\n"
|
|
})
|
|
);
|
|
|
|
fs::remove_dir_all(dump_dir).expect("remove test dump dir");
|
|
}
|
|
|
|
fn test_dump_dir() -> std::path::PathBuf {
|
|
let test_id = NEXT_TEST_DIR.fetch_add(1, Ordering::Relaxed);
|
|
let dump_dir = std::env::temp_dir().join(format!(
|
|
"codex-responses-api-proxy-dump-test-{}-{test_id}",
|
|
std::process::id()
|
|
));
|
|
fs::create_dir_all(&dump_dir).expect("create test dump dir");
|
|
dump_dir
|
|
}
|
|
|
|
fn dump_file_with_suffix(dump_dir: &std::path::Path, suffix: &str) -> std::path::PathBuf {
|
|
let mut matches = fs::read_dir(dump_dir)
|
|
.expect("read dump dir")
|
|
.map(|entry| entry.expect("read dump entry").path())
|
|
.filter(|path| path.to_string_lossy().ends_with(suffix))
|
|
.collect::<Vec<_>>();
|
|
matches.sort();
|
|
|
|
assert_eq!(matches.len(), 1);
|
|
matches.pop().expect("single dump file")
|
|
}
|
|
}
|