Files
codex/codex-rs/app-server-test-client/src/loopback_responses_server.rs
T
jameswt-oai a376781a3c [codex-app-server-test-client & codex-app-server] Plugin Usage Analytics Smoke Test (#27099)
## This PR

The original [combined remote plugin analytics PR
#26281](https://github.com/openai/codex/pull/26281) mixed reusable
analytics test infrastructure, two manual smoke workflows, a metadata
refactor, and the final identity behavior. This PR establishes a
non-mutating end-to-end plugin smoke workflow before any analytics
identity semantics change.

- Add `plugin-analytics-smoke` to the existing app-server test client.
- Exercise plugin disable, enable, and use through production app-server
RPC paths.
- Isolate config writes in a temporary file and use a loopback Responses
API server.
- Capture analytics without sending them to the production analytics
backend.
- Validate the current local `plugin_id`, names, capability metadata,
thread, turn, and model fields.

This is intentionally a baseline smoke workflow. It does not assert
`remote_plugin_id`; the final PR will update it when that field exists.
Review this PR as the net diff against #27093.

## Testing

- The test-client target compiles successfully.
- The combined reference branch exercised the manual smoke against the
live remote plugin service.
- CI is green across the required platform matrix.

## Split Overview

```text
main
├── #27093  Debug analytics capture
│   └── #27099  Non-mutating plugin smoke           ← you are here
│       └── #27100  Remote install/uninstall smoke
└── #27102  Plugin telemetry metadata refactor

After #27093, #27099, #27100, and #27102 merge:
└── Final PR: add remote_plugin_id to plugin analytics
```

Review order and dependencies:

1. [#27093 Add debug-only analytics event
capture](https://github.com/openai/codex/pull/27093) (based on `main`)
2. [#27099 Add a plugin analytics smoke
workflow](https://github.com/openai/codex/pull/27099) **(this PR,
stacked on #27093)**
3. [#27100 Add a remote plugin analytics mutation smoke
workflow](https://github.com/openai/codex/pull/27100) (stacked on this
PR)
4. [#27102 Centralize plugin telemetry metadata
construction](https://github.com/openai/codex/pull/27102) (independent,
based on `main`)
5. Final remote-ID behavior PR (created after PRs 1-4 merge)

The original [#26281](https://github.com/openai/codex/pull/26281)
remains open as the green aggregate reference until the final PR is
published.
2026-06-16 10:11:41 -07:00

146 lines
4.7 KiB
Rust

use anyhow::Context;
use anyhow::Result;
use std::io;
use std::io::Read;
use std::io::Write;
use std::net::TcpListener;
use std::net::TcpStream;
use std::sync::Arc;
use std::sync::atomic::AtomicBool;
use std::sync::atomic::Ordering;
use std::thread;
use std::thread::JoinHandle;
use std::time::Duration;
pub(super) struct LoopbackResponsesServer {
base_url: String,
shutdown: Arc<AtomicBool>,
thread: Option<JoinHandle<()>>,
}
impl LoopbackResponsesServer {
pub(super) fn start() -> Result<Self> {
let listener =
TcpListener::bind("127.0.0.1:0").context("bind loopback Responses API server")?;
listener
.set_nonblocking(true)
.context("set loopback Responses API server nonblocking")?;
let address = listener.local_addr()?;
let shutdown = Arc::new(AtomicBool::new(false));
let thread_shutdown = Arc::clone(&shutdown);
let thread = thread::spawn(move || {
while !thread_shutdown.load(Ordering::Relaxed) {
match listener.accept() {
Ok((stream, _)) => {
if let Err(err) = handle_model_connection(stream) {
eprintln!("loopback Responses API server error: {err}");
}
}
Err(err) if err.kind() == io::ErrorKind::WouldBlock => {
thread::sleep(Duration::from_millis(10));
}
Err(err) => {
eprintln!("loopback Responses API accept error: {err}");
break;
}
}
}
});
Ok(Self {
base_url: format!("http://{address}"),
shutdown,
thread: Some(thread),
})
}
pub(super) fn base_url(&self) -> &str {
&self.base_url
}
}
impl Drop for LoopbackResponsesServer {
fn drop(&mut self) {
self.shutdown.store(true, Ordering::Relaxed);
if let Some(thread) = self.thread.take() {
let _ = thread.join();
}
}
}
fn handle_model_connection(mut stream: TcpStream) -> io::Result<()> {
stream.set_nonblocking(false)?;
stream.set_read_timeout(Some(Duration::from_secs(2)))?;
let request = read_http_request(&mut stream)?;
let request_line = request
.split(|byte| *byte == b'\n')
.next()
.and_then(|line| std::str::from_utf8(line).ok())
.unwrap_or_default();
if request_line.starts_with("POST ") && request_line.contains("/responses ") {
let body = concat!(
"event: response.created\n",
"data: {\"type\":\"response.created\",\"response\":{\"id\":\"resp-plugin-analytics\"}}\n\n",
"event: response.completed\n",
"data: {\"type\":\"response.completed\",\"response\":{\"id\":\"resp-plugin-analytics\",\"usage\":{\"input_tokens\":0,\"input_tokens_details\":null,\"output_tokens\":0,\"output_tokens_details\":null,\"total_tokens\":0}}}\n\n"
);
write_http_response(&mut stream, "200 OK", "text/event-stream", body)
} else {
write_http_response(
&mut stream,
"404 Not Found",
"application/json",
r#"{"error":"not found"}"#,
)
}
}
fn read_http_request(stream: &mut TcpStream) -> io::Result<Vec<u8>> {
let mut request = Vec::new();
let mut buffer = [0_u8; 4096];
let header_end = loop {
let read = stream.read(&mut buffer)?;
if read == 0 {
return Ok(request);
}
request.extend_from_slice(&buffer[..read]);
if let Some(position) = request.windows(4).position(|window| window == b"\r\n\r\n") {
break position + 4;
}
};
let content_length = parse_content_length(&request[..header_end]);
while request.len() < header_end + content_length {
let read = stream.read(&mut buffer)?;
if read == 0 {
break;
}
request.extend_from_slice(&buffer[..read]);
}
Ok(request)
}
fn parse_content_length(headers: &[u8]) -> usize {
String::from_utf8_lossy(headers)
.lines()
.find_map(|line| {
let (name, value) = line.split_once(':')?;
name.eq_ignore_ascii_case("content-length")
.then(|| value.trim().parse().ok())
.flatten()
})
.unwrap_or(0)
}
fn write_http_response(
stream: &mut TcpStream,
status: &str,
content_type: &str,
body: &str,
) -> io::Result<()> {
write!(
stream,
"HTTP/1.1 {status}\r\nContent-Type: {content_type}\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}",
body.len()
)?;
stream.flush()
}