mirror of
https://github.com/openai/codex.git
synced 2026-10-28 18:58:46 +00:00
306 lines
9.5 KiB
Rust
306 lines
9.5 KiB
Rust
use std::io::Read as _;
|
|
use std::io::Write as _;
|
|
use std::net::TcpListener;
|
|
use std::path::Path;
|
|
use std::sync::mpsc;
|
|
use std::thread;
|
|
use std::time::Duration;
|
|
use std::time::Instant;
|
|
|
|
use anyhow::Result;
|
|
use predicates::prelude::PredicateBooleanExt;
|
|
use predicates::str::contains;
|
|
use tempfile::TempDir;
|
|
|
|
fn codex_command(codex_home: &Path) -> Result<assert_cmd::Command> {
|
|
let mut cmd = assert_cmd::Command::new(codex_utils_cargo_bin::cargo_bin("codex")?);
|
|
cmd.env("CODEX_HOME", codex_home);
|
|
Ok(cmd)
|
|
}
|
|
|
|
#[test]
|
|
fn strict_config_rejects_unknown_config_fields_for_exec_server() -> Result<()> {
|
|
let codex_home = TempDir::new()?;
|
|
std::fs::write(
|
|
codex_home.path().join("config.toml"),
|
|
r#"
|
|
foo = "bar"
|
|
"#,
|
|
)?;
|
|
|
|
let mut cmd = codex_command(codex_home.path())?;
|
|
cmd.args([
|
|
"exec-server",
|
|
"--strict-config",
|
|
"--listen",
|
|
"http://127.0.0.1:0",
|
|
])
|
|
.assert()
|
|
.failure()
|
|
.stderr(contains("unknown configuration field"));
|
|
|
|
Ok(())
|
|
}
|
|
|
|
#[test]
|
|
fn local_exec_server_ignores_invalid_config_without_strict_config() -> Result<()> {
|
|
let codex_home = TempDir::new()?;
|
|
std::fs::write(codex_home.path().join("config.toml"), "not valid toml = [")?;
|
|
|
|
let mut cmd = codex_command(codex_home.path())?;
|
|
cmd.args(["exec-server", "--listen", "stdio"])
|
|
.assert()
|
|
.success()
|
|
.stderr(contains("not valid toml").not());
|
|
|
|
Ok(())
|
|
}
|
|
|
|
#[test]
|
|
fn local_exec_server_exports_real_otel_metrics() -> Result<()> {
|
|
let collector = TestCollector::start()?;
|
|
let codex_home = TempDir::new()?;
|
|
let base_url = &collector.base_url;
|
|
std::fs::write(
|
|
codex_home.path().join("config.toml"),
|
|
format!(
|
|
r#"
|
|
[analytics]
|
|
enabled = true
|
|
|
|
[otel]
|
|
environment = "test"
|
|
metrics_exporter = {{ otlp-http = {{ endpoint = "{base_url}/v1/metrics", protocol = "json" }} }}
|
|
"#
|
|
),
|
|
)?;
|
|
|
|
let mut cmd = codex_command(codex_home.path())?;
|
|
cmd.args(["exec-server", "--listen", "stdio"])
|
|
.write_stdin(
|
|
r#"{"id":1,"method":"initialize","params":{"clientName":"otel-test","resumeSessionId":null}}"#,
|
|
)
|
|
.assert()
|
|
.success();
|
|
|
|
let requests = collector.finish()?;
|
|
let metrics = requests
|
|
.iter()
|
|
.filter(|request| request.path == "/v1/metrics")
|
|
.map(|request| request.body.as_str())
|
|
.collect::<Vec<_>>()
|
|
.join("\n");
|
|
assert!(
|
|
metrics.contains("exec_server_connections_active"),
|
|
"{metrics}"
|
|
);
|
|
assert!(metrics.contains("exec_server_requests_total"), "{metrics}");
|
|
assert!(metrics.contains("initialize"), "{metrics}");
|
|
assert!(
|
|
metrics.contains("success") || metrics.contains("disconnected"),
|
|
"{metrics}"
|
|
);
|
|
Ok(())
|
|
}
|
|
|
|
#[test]
|
|
fn remote_exec_server_preserves_websocket_error_in_stderr() -> Result<()> {
|
|
let failed_websocket_listener = TcpListener::bind("127.0.0.1:0")?;
|
|
let failed_websocket_url = format!("ws://{}", failed_websocket_listener.local_addr()?);
|
|
drop(failed_websocket_listener);
|
|
|
|
let registry = TestRegistry::start(&failed_websocket_url)?;
|
|
let codex_home = TempDir::new()?;
|
|
let stderr_path = codex_home.path().join("remote.stderr");
|
|
let stderr_file = std::fs::File::create(&stderr_path)?;
|
|
let mut cmd = std::process::Command::new(codex_utils_cargo_bin::cargo_bin("codex")?);
|
|
cmd.env("CODEX_HOME", codex_home.path())
|
|
.env("CODEX_API_KEY", "test-key")
|
|
.env("RUST_LOG", "codex_exec_server=warn")
|
|
.args([
|
|
"exec-server",
|
|
"--remote",
|
|
®istry.base_url,
|
|
"--environment-id",
|
|
"env-test",
|
|
])
|
|
.stdout(std::process::Stdio::null())
|
|
.stderr(stderr_file);
|
|
|
|
let mut child = cmd.spawn()?;
|
|
let deadline = Instant::now() + Duration::from_secs(15);
|
|
let stderr = loop {
|
|
let stderr = std::fs::read_to_string(&stderr_path)?;
|
|
if stderr.contains("failed to connect remote exec-server websocket")
|
|
|| Instant::now() >= deadline
|
|
{
|
|
break stderr;
|
|
}
|
|
thread::sleep(Duration::from_millis(50));
|
|
};
|
|
let _ = child.kill();
|
|
child.wait()?;
|
|
registry.finish()?;
|
|
|
|
assert!(
|
|
stderr.contains("failed to connect remote exec-server websocket"),
|
|
"{stderr}"
|
|
);
|
|
assert!(stderr.contains("environment_id env-test"), "{stderr}");
|
|
assert!(stderr.contains("IO error"), "{stderr}");
|
|
|
|
Ok(())
|
|
}
|
|
|
|
struct CapturedRequest {
|
|
path: String,
|
|
body: String,
|
|
}
|
|
|
|
struct TestCollector {
|
|
base_url: String,
|
|
requests: mpsc::Receiver<Vec<CapturedRequest>>,
|
|
stop: mpsc::Sender<()>,
|
|
server: thread::JoinHandle<()>,
|
|
}
|
|
|
|
impl TestCollector {
|
|
fn start() -> Result<Self> {
|
|
let listener = TcpListener::bind("127.0.0.1:0")?;
|
|
let addr = listener.local_addr()?;
|
|
listener.set_nonblocking(true)?;
|
|
let (tx, requests) = mpsc::channel();
|
|
let (stop, stop_rx) = mpsc::channel();
|
|
let server = thread::spawn(move || {
|
|
let mut captured = Vec::new();
|
|
loop {
|
|
match listener.accept() {
|
|
Ok((mut stream, _)) => {
|
|
if let Ok(request) = read_http_request(&mut stream) {
|
|
captured.push(request);
|
|
}
|
|
let _ = stream.write_all(
|
|
b"HTTP/1.1 202 Accepted\r\nContent-Length: 0\r\nConnection: close\r\n\r\n",
|
|
);
|
|
}
|
|
Err(err) if err.kind() == std::io::ErrorKind::WouldBlock => {
|
|
if stop_rx.try_recv().is_ok() {
|
|
break;
|
|
}
|
|
thread::sleep(Duration::from_millis(10));
|
|
}
|
|
Err(_) => break,
|
|
}
|
|
}
|
|
let _ = tx.send(captured);
|
|
});
|
|
Ok(Self {
|
|
base_url: format!("http://{addr}"),
|
|
requests,
|
|
stop,
|
|
server,
|
|
})
|
|
}
|
|
|
|
fn finish(self) -> Result<Vec<CapturedRequest>> {
|
|
let _ = self.stop.send(());
|
|
self.server
|
|
.join()
|
|
.map_err(|_| anyhow::anyhow!("collector thread panicked"))?;
|
|
Ok(self.requests.recv_timeout(Duration::from_secs(1))?)
|
|
}
|
|
}
|
|
|
|
struct TestRegistry {
|
|
base_url: String,
|
|
server: thread::JoinHandle<Result<()>>,
|
|
}
|
|
|
|
impl TestRegistry {
|
|
fn start(websocket_url: &str) -> Result<Self> {
|
|
let listener = TcpListener::bind("127.0.0.1:0")?;
|
|
let addr = listener.local_addr()?;
|
|
listener.set_nonblocking(true)?;
|
|
let websocket_url = websocket_url.to_string();
|
|
let server = thread::spawn(move || {
|
|
let deadline = Instant::now() + Duration::from_secs(20);
|
|
let (mut stream, _) = loop {
|
|
match listener.accept() {
|
|
Ok(connection) => break connection,
|
|
Err(err)
|
|
if err.kind() == std::io::ErrorKind::WouldBlock
|
|
&& Instant::now() < deadline =>
|
|
{
|
|
thread::sleep(Duration::from_millis(50));
|
|
}
|
|
Err(err) => return Err(err.into()),
|
|
}
|
|
};
|
|
let request = read_http_request(&mut stream)?;
|
|
assert_eq!(request.path, "/cloud/environment/env-test/register");
|
|
assert!(request.body.is_empty());
|
|
let body = format!(r#"{{"environment_id":"env-test","url":"{websocket_url}"}}"#);
|
|
let response = format!(
|
|
"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}",
|
|
body.len()
|
|
);
|
|
stream.write_all(response.as_bytes())?;
|
|
Ok(())
|
|
});
|
|
Ok(Self {
|
|
base_url: format!("http://{addr}"),
|
|
server,
|
|
})
|
|
}
|
|
|
|
fn finish(self) -> Result<()> {
|
|
self.server
|
|
.join()
|
|
.map_err(|_| anyhow::anyhow!("registry thread panicked"))?
|
|
}
|
|
}
|
|
|
|
fn read_http_request(stream: &mut std::net::TcpStream) -> std::io::Result<CapturedRequest> {
|
|
stream.set_read_timeout(Some(Duration::from_secs(2)))?;
|
|
let mut bytes = Vec::new();
|
|
let mut scratch = [0_u8; 8192];
|
|
let header_end = loop {
|
|
let read = stream.read(&mut scratch)?;
|
|
if read == 0 {
|
|
return Err(std::io::Error::new(
|
|
std::io::ErrorKind::UnexpectedEof,
|
|
"request closed before headers",
|
|
));
|
|
}
|
|
bytes.extend_from_slice(&scratch[..read]);
|
|
if let Some(header_end) = bytes.windows(4).position(|window| window == b"\r\n\r\n") {
|
|
break header_end;
|
|
}
|
|
};
|
|
let headers = String::from_utf8_lossy(&bytes[..header_end]);
|
|
let mut lines = headers.split("\r\n");
|
|
let path = lines
|
|
.next()
|
|
.and_then(|line| line.split_whitespace().nth(1))
|
|
.unwrap_or_default()
|
|
.to_string();
|
|
let content_length = lines
|
|
.filter_map(|line| line.split_once(':'))
|
|
.find(|(key, _)| key.eq_ignore_ascii_case("content-length"))
|
|
.and_then(|(_, value)| value.trim().parse::<usize>().ok())
|
|
.unwrap_or_default();
|
|
let mut body = bytes[header_end + 4..].to_vec();
|
|
while body.len() < content_length {
|
|
let read = stream.read(&mut scratch)?;
|
|
if read == 0 {
|
|
break;
|
|
}
|
|
body.extend_from_slice(&scratch[..read]);
|
|
}
|
|
body.truncate(content_length);
|
|
Ok(CapturedRequest {
|
|
path,
|
|
body: String::from_utf8_lossy(&body).into_owned(),
|
|
})
|
|
}
|