diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index a54222a8..a50a82b4 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -488,6 +488,12 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fd16c4719339c4530435d38e511904438d07cce7950afa3718a84ac36c10e89e" +[[package]] +name = "cfg_aliases" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" + [[package]] name = "chrono" version = "0.4.42" @@ -640,6 +646,7 @@ dependencies = [ "codex-mcp-server", "codex-protocol", "codex-protocol-ts", + "codex-responses-api-proxy", "codex-tui", "ctor 0.5.0", "libc", @@ -926,6 +933,22 @@ dependencies = [ "ts-rs", ] +[[package]] +name = "codex-responses-api-proxy" +version = "0.0.0" +dependencies = [ + "anyhow", + "clap", + "codex-arg0", + "libc", + "reqwest", + "serde", + "serde_json", + "tiny_http", + "tokio", + "zeroize", +] + [[package]] name = "codex-tui" version = "0.0.0" @@ -1949,8 +1972,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "335ff9f135e4384c8150d6f27c6daed433577f86b4750418338c01a1a2528592" dependencies = [ "cfg-if", + "js-sys", "libc", "wasi 0.11.1+wasi-snapshot-preview1", + "wasm-bindgen", ] [[package]] @@ -1960,9 +1985,11 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "26145e563e54f2cadc477553f1ec5ee650b00862f0a58bcd12cbdc5f0ea2d2f4" dependencies = [ "cfg-if", + "js-sys", "libc", "r-efi", "wasi 0.14.2+wasi-0.2.4", + "wasm-bindgen", ] [[package]] @@ -2159,6 +2186,7 @@ dependencies = [ "tokio", "tokio-rustls", "tower-service", + "webpki-roots", ] [[package]] @@ -2759,6 +2787,12 @@ dependencies = [ "hashbrown 0.15.4", ] +[[package]] +name = "lru-slab" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" + [[package]] name = "lsp-types" version = "0.94.1" @@ -2938,7 +2972,7 @@ checksum = "ab2156c4fce2f8df6c499cc1c763e4394b7482525bf2a9701c9d79d215f519e4" dependencies = [ "bitflags 2.9.1", "cfg-if", - "cfg_aliases", + "cfg_aliases 0.1.1", "libc", ] @@ -3495,6 +3529,61 @@ dependencies = [ "memchr", ] +[[package]] +name = "quinn" +version = "0.11.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e20a958963c291dc322d98411f541009df2ced7b5a4f2bd52337638cfccf20" +dependencies = [ + "bytes", + "cfg_aliases 0.2.1", + "pin-project-lite", + "quinn-proto", + "quinn-udp", + "rustc-hash", + "rustls", + "socket2", + "thiserror 2.0.16", + "tokio", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-proto" +version = "0.11.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1906b49b0c3bc04b5fe5d86a77925ae6524a19b816ae38ce1e426255f1d8a31" +dependencies = [ + "bytes", + "getrandom 0.3.3", + "lru-slab", + "rand", + "ring", + "rustc-hash", + "rustls", + "rustls-pki-types", + "slab", + "thiserror 2.0.16", + "tinyvec", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-udp" +version = "0.5.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "addec6a0dcad8a8d96a771f815f0eaf55f9d1805756410b39f5fa81332574cbd" +dependencies = [ + "cfg_aliases 0.2.1", + "libc", + "once_cell", + "socket2", + "tracing", + "windows-sys 0.52.0", +] + [[package]] name = "quote" version = "1.0.40" @@ -3687,6 +3776,8 @@ dependencies = [ "native-tls", "percent-encoding", "pin-project-lite", + "quinn", + "rustls", "rustls-pki-types", "serde", "serde_json", @@ -3694,6 +3785,7 @@ dependencies = [ "sync_wrapper", "tokio", "tokio-native-tls", + "tokio-rustls", "tokio-util", "tower", "tower-http", @@ -3703,6 +3795,7 @@ dependencies = [ "wasm-bindgen-futures", "wasm-streams", "web-sys", + "webpki-roots", ] [[package]] @@ -3725,6 +3818,12 @@ version = "0.1.25" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "989e6739f80c4ad5b13e0fd7fe89531180375b18520cc8c82080e4dc4035b84f" +[[package]] +name = "rustc-hash" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "357703d41365b4b27c590e3ed91eabb1b663f07c4c084095e60cbed4362dff0d" + [[package]] name = "rustix" version = "0.38.44" @@ -3758,6 +3857,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2491382039b29b9b11ff08b76ff6c97cf287671dbb74f0be44bda389fffe9bd1" dependencies = [ "once_cell", + "ring", "rustls-pki-types", "rustls-webpki", "subtle", @@ -3770,6 +3870,7 @@ version = "1.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "229a4a4c221013e7e1f1a043678c5cc39fe5171437c88fb47151a21e6f5b5c79" dependencies = [ + "web-time", "zeroize", ] @@ -4684,6 +4785,21 @@ dependencies = [ "zerovec", ] +[[package]] +name = "tinyvec" +version = "1.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa5fdc3bce6191a1dbc8c02d5c8bffcf557bafa17c124c5264a458f1b0613fa" +dependencies = [ + "tinyvec_macros", +] + +[[package]] +name = "tinyvec_macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" + [[package]] name = "tokio" version = "1.47.1" @@ -5296,6 +5412,16 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "web-time" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + [[package]] name = "webbrowser" version = "1.0.5" @@ -5312,6 +5438,15 @@ dependencies = [ "web-sys", ] +[[package]] +name = "webpki-roots" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e8983c3ab33d6fb807cfcdad2491c4ea8cbc8ed839181c7dfd9c67c83e261b2" +dependencies = [ + "rustls-pki-types", +] + [[package]] name = "weezl" version = "0.1.10" diff --git a/codex-rs/Cargo.toml b/codex-rs/Cargo.toml index 237f5ea0..33b1b303 100644 --- a/codex-rs/Cargo.toml +++ b/codex-rs/Cargo.toml @@ -18,6 +18,7 @@ members = [ "ollama", "protocol", "protocol-ts", + "responses-api-proxy", "tui", "utils/readiness", ] @@ -49,6 +50,7 @@ codex-mcp-server = { path = "mcp-server" } codex-ollama = { path = "ollama" } codex-protocol = { path = "protocol" } codex-protocol-ts = { path = "protocol-ts" } +codex-responses-api-proxy = { path = "responses-api-proxy" } codex-tui = { path = "tui" } codex-utils-readiness = { path = "utils/readiness" } core_test_support = { path = "core/tests/common" } @@ -152,6 +154,7 @@ webbrowser = "1.0" which = "6" wildmatch = "2.5.0" wiremock = "0.6" +zeroize = "1.8.1" [workspace.lints] rust = {} diff --git a/codex-rs/cli/Cargo.toml b/codex-rs/cli/Cargo.toml index 44217891..e61285c8 100644 --- a/codex-rs/cli/Cargo.toml +++ b/codex-rs/cli/Cargo.toml @@ -27,6 +27,7 @@ codex-login = { workspace = true } codex-mcp-server = { workspace = true } codex-protocol = { workspace = true } codex-protocol-ts = { workspace = true } +codex-responses-api-proxy = { workspace = true } codex-tui = { workspace = true } ctor = { workspace = true } owo-colors = { workspace = true } diff --git a/codex-rs/cli/src/main.rs b/codex-rs/cli/src/main.rs index b1e9601c..871966b0 100644 --- a/codex-rs/cli/src/main.rs +++ b/codex-rs/cli/src/main.rs @@ -1,3 +1,4 @@ +use anyhow::Context; use clap::CommandFactory; use clap::Parser; use clap_complete::Shell; @@ -14,6 +15,7 @@ use codex_cli::login::run_logout; use codex_cli::proto; use codex_common::CliConfigOverrides; use codex_exec::Cli as ExecCli; +use codex_responses_api_proxy::Args as ResponsesApiProxyArgs; use codex_tui::AppExitInfo; use codex_tui::Cli as TuiCli; use owo_colors::OwoColorize; @@ -86,6 +88,10 @@ enum Subcommand { /// Internal: generate TypeScript protocol bindings. #[clap(hide = true)] GenerateTs(GenerateTsCommand), + + /// Internal: run the responses API proxy. + #[clap(hide = true)] + ResponsesApiProxy(ResponsesApiProxyArgs), } #[derive(Debug, Parser)] @@ -341,6 +347,11 @@ async fn cli_main(codex_linux_sandbox_exe: Option) -> anyhow::Result<() Some(Subcommand::GenerateTs(gen_cli)) => { codex_protocol_ts::generate_ts(&gen_cli.out_dir, gen_cli.prettier.as_deref())?; } + Some(Subcommand::ResponsesApiProxy(args)) => { + tokio::task::spawn_blocking(move || codex_responses_api_proxy::run_main(args)) + .await + .context("responses-api-proxy blocking task panicked")??; + } } Ok(()) diff --git a/codex-rs/responses-api-proxy/Cargo.toml b/codex-rs/responses-api-proxy/Cargo.toml new file mode 100644 index 00000000..b66473b1 --- /dev/null +++ b/codex-rs/responses-api-proxy/Cargo.toml @@ -0,0 +1,27 @@ +[package] +edition = "2024" +name = "codex-responses-api-proxy" +version = { workspace = true } + +[lib] +name = "codex_responses_api_proxy" +path = "src/lib.rs" + +[[bin]] +name = "responses-api-proxy" +path = "src/main.rs" + +[lints] +workspace = true + +[dependencies] +anyhow = { workspace = true } +clap = { workspace = true, features = ["derive"] } +codex-arg0 = { workspace = true } +libc = { workspace = true } +reqwest = { workspace = true, features = ["blocking", "json", "rustls-tls"] } +serde = { workspace = true, features = ["derive"] } +serde_json = { workspace = true } +tiny_http = { workspace = true } +tokio = { workspace = true } +zeroize = { workspace = true } diff --git a/codex-rs/responses-api-proxy/README.md b/codex-rs/responses-api-proxy/README.md new file mode 100644 index 00000000..4f5304a5 --- /dev/null +++ b/codex-rs/responses-api-proxy/README.md @@ -0,0 +1,53 @@ +# codex-responses-api-proxy + +A strict HTTP proxy that only forwards `POST` requests to `/v1/responses` to the OpenAI API (`https://api.openai.com`), injecting the `Authorization: Bearer $OPENAI_API_KEY` header. Everything else is rejected with `403 Forbidden`. + +## Expected Usage + +**IMPORTANT:** This is designed to be used with `CODEX_SECURE_MODE=1` so that an unprivileged user cannot inspect or tamper with this process. Though if `--http-shutdown` is specified, an unprivileged user _can_ shutdown the server. + +A privileged user (i.e., `root` or a user with `sudo`) who has access to `OPENAI_API_KEY` would run the following to start the server: + +```shell +printenv OPENAI_API_KEY | CODEX_SECURE_MODE=1 codex responses-api-proxy --http-shutdown --server-info /tmp/server-info.json +``` + +A non-privileged user would then run Codex as follows, specifying the `model_provider` dynamically: + +```shell +PROXY_PORT=$(jq .port /tmp/server-info.json) +PROXY_BASE_URL="http://127.0.0.1:${PROXY_PORT}" +codex exec -c "model_providers.openai-proxy={ name = 'OpenAI Proxy', base_url = '${PROXY_BASE_URL}/v1', wire_api='responses' }" \ + -c model_provider="openai-proxy" \ + 'Your prompt here' +``` + +When the unprivileged user was finished, they could shutdown the server using `curl` (since `kill -9` is not an option): + +```shell +curl --fail --silent --show-error "${PROXY_BASE_URL}/shutdown" +``` + +## Behavior + +- Reads the API key from `stdin`. All callers should pipe the key in (for example, `printenv OPENAI_API_KEY | codex responses-api-proxy`). +- Formats the header value as `Bearer ` and attempts to `mlock(2)` the memory holding that header so it is not swapped to disk. +- Listens on the provided port or an ephemeral port if `--port` is not specified. +- Accepts exactly `POST /v1/responses` (no query string). The request body is forwarded to `https://api.openai.com/v1/responses` with `Authorization: Bearer ` set. All original request headers (except any incoming `Authorization`) are forwarded upstream. For other requests, it responds with `403`. +- Optionally writes a single-line JSON file with server info, currently `{ "port": }`. +- Optional `--http-shutdown` enables `GET /shutdown` to terminate the process with exit code 0. This allows one user (e.g., root) to start the proxy and another unprivileged user on the host to shut it down. + +## CLI + +``` +responses-api-proxy [--port ] [--server-info ] [--http-shutdown] +``` + +- `--port `: Port to bind on `127.0.0.1`. If omitted, an ephemeral port is chosen. +- `--server-info `: If set, the proxy writes a single line of JSON with `{ "port": }` once listening. +- `--http-shutdown`: If set, enables `GET /shutdown` to exit the process with code `0`. + +## Notes + +- Only `POST /v1/responses` is permitted. No query strings are allowed. +- All request headers are forwarded to the upstream call (aside from overriding `Authorization`). Response status and content-type are mirrored from upstream. diff --git a/codex-rs/responses-api-proxy/src/lib.rs b/codex-rs/responses-api-proxy/src/lib.rs new file mode 100644 index 00000000..600a8d6c --- /dev/null +++ b/codex-rs/responses-api-proxy/src/lib.rs @@ -0,0 +1,202 @@ +use std::fs::File; +use std::fs::{self}; +use std::io::Write; +use std::net::SocketAddr; +use std::net::TcpListener; +use std::path::Path; +use std::path::PathBuf; +use std::sync::Arc; + +use anyhow::Context; +use anyhow::Result; +use anyhow::anyhow; +use clap::Parser; +use reqwest::blocking::Client; +use reqwest::header::AUTHORIZATION; +use reqwest::header::HOST; +use reqwest::header::HeaderMap; +use reqwest::header::HeaderName; +use reqwest::header::HeaderValue; +use serde::Serialize; +use tiny_http::Header; +use tiny_http::Method; +use tiny_http::Request; +use tiny_http::Response; +use tiny_http::Server; +use tiny_http::StatusCode; + +mod read_api_key; +use read_api_key::read_auth_header_from_stdin; + +/// CLI arguments for the proxy. +#[derive(Debug, Clone, Parser)] +#[command(name = "responses-api-proxy", about = "Minimal OpenAI responses proxy")] +pub struct Args { + /// Port to listen on. If not set, an ephemeral port is used. + #[arg(long)] + pub port: Option, + + /// Path to a JSON file to write startup info (single line). Includes {"port": }. + #[arg(long, value_name = "FILE")] + pub server_info: Option, + + /// Enable HTTP shutdown endpoint at GET /shutdown + #[arg(long)] + pub http_shutdown: bool, +} + +#[derive(Serialize)] +struct ServerInfo { + port: u16, +} + +/// Entry point for the library main, for parity with other crates. +pub fn run_main(args: Args) -> Result<()> { + let auth_header = read_auth_header_from_stdin()?; + + let (listener, bound_addr) = bind_listener(args.port)?; + if let Some(path) = args.server_info.as_ref() { + write_server_info(path, bound_addr.port())?; + } + let server = Server::from_listener(listener, None) + .map_err(|err| anyhow!("creating HTTP server: {err}"))?; + let client = Arc::new( + Client::builder() + .build() + .context("building reqwest client")?, + ); + + eprintln!("responses-api-proxy listening on {bound_addr}"); + + let http_shutdown = args.http_shutdown; + for request in server.incoming_requests() { + let client = client.clone(); + std::thread::spawn(move || { + if http_shutdown && request.method() == &Method::Get && request.url() == "/shutdown" { + let _ = request.respond(Response::new_empty(StatusCode(200))); + std::process::exit(0); + } + + if let Err(e) = forward_request(&client, auth_header, request) { + eprintln!("forwarding error: {e}"); + } + }); + } + + Err(anyhow!("server stopped unexpectedly")) +} + +fn bind_listener(port: Option) -> Result<(TcpListener, SocketAddr)> { + let addr = SocketAddr::from(([127, 0, 0, 1], port.unwrap_or(0))); + let listener = TcpListener::bind(addr).with_context(|| format!("failed to bind {addr}"))?; + let bound = listener.local_addr().context("failed to read local_addr")?; + Ok((listener, bound)) +} + +fn write_server_info(path: &Path, port: u16) -> Result<()> { + if let Some(parent) = path.parent() + && !parent.as_os_str().is_empty() + { + let parent_display = parent.display(); + fs::create_dir_all(parent).with_context(|| format!("create_dir_all {parent_display}"))?; + } + let info = ServerInfo { port }; + let data = serde_json::to_vec(&info).context("serialize startup info")?; + let p = path.display(); + let mut f = File::create(path).with_context(|| format!("create {p}"))?; + f.write_all(&data).with_context(|| format!("write {p}"))?; + f.write_all(b"\n").with_context(|| format!("newline {p}"))?; + Ok(()) +} + +fn forward_request(client: &Client, auth_header: &'static str, mut req: Request) -> Result<()> { + // Only allow POST /v1/responses exactly, no query string. + let method = req.method().clone(); + let url_path = req.url().to_string(); + let allow = method == Method::Post && url_path == "/v1/responses"; + + if !allow { + let resp = Response::new_empty(StatusCode(403)); + let _ = req.respond(resp); + return Ok(()); + } + + // Read request body + let mut body = Vec::new(); + let mut reader = req.as_reader(); + std::io::Read::read_to_end(&mut reader, &mut body)?; + + // Build headers for upstream, forwarding everything from the incoming + // request except Authorization (we replace it below). + let mut headers = HeaderMap::new(); + for header in req.headers() { + let name_ascii = header.field.as_str(); + let lower = name_ascii.to_ascii_lowercase(); + if lower.as_str() == "authorization" || lower.as_str() == "host" { + continue; + } + + let header_name = match HeaderName::from_bytes(lower.as_bytes()) { + Ok(name) => name, + Err(_) => continue, + }; + if let Ok(value) = HeaderValue::from_bytes(header.value.as_bytes()) { + headers.append(header_name, value); + } + } + + // As part of our effort to to keep `auth_header` secret, we use a + // combination of `from_static()` and `set_sensitive(true)`. + let mut auth_header_value = HeaderValue::from_static(auth_header); + auth_header_value.set_sensitive(true); + headers.insert(AUTHORIZATION, auth_header_value); + + headers.insert(HOST, HeaderValue::from_static("api.openai.com")); + + let upstream = "https://api.openai.com/v1/responses"; + let upstream_resp = client + .post(upstream) + .headers(headers) + .body(body) + .send() + .context("forwarding request to upstream")?; + + // We have to create an adapter between a `reqwest::blocking::Response` + // and a `tiny_http::Response`. Fortunately, `reqwest::blocking::Response` + // implements `Read`, so we can use it directly as the body of the + // `tiny_http::Response`. + let status = upstream_resp.status(); + let mut response_headers = Vec::new(); + for (name, value) in upstream_resp.headers().iter() { + // Skip headers that tiny_http manages itself. + if matches!( + name.as_str(), + "content-length" | "transfer-encoding" | "connection" | "trailer" | "upgrade" + ) { + continue; + } + + if let Ok(header) = Header::from_bytes(name.as_str().as_bytes(), value.as_bytes()) { + response_headers.push(header); + } + } + + let content_length = upstream_resp.content_length().and_then(|len| { + if len <= usize::MAX as u64 { + Some(len as usize) + } else { + None + } + }); + + let response = Response::new( + StatusCode(status.as_u16()), + response_headers, + upstream_resp, + content_length, + None, + ); + + let _ = req.respond(response); + Ok(()) +} diff --git a/codex-rs/responses-api-proxy/src/main.rs b/codex-rs/responses-api-proxy/src/main.rs new file mode 100644 index 00000000..eaa534d9 --- /dev/null +++ b/codex-rs/responses-api-proxy/src/main.rs @@ -0,0 +1,14 @@ +use anyhow::Context; +use clap::Parser; +use codex_arg0::arg0_dispatch_or_else; +use codex_responses_api_proxy::Args as ResponsesApiProxyArgs; + +pub fn main() -> anyhow::Result<()> { + arg0_dispatch_or_else(|_codex_linux_sandbox_exe| async move { + let args = ResponsesApiProxyArgs::parse(); + tokio::task::spawn_blocking(move || codex_responses_api_proxy::run_main(args)) + .await + .context("responses-api-proxy blocking task panicked")??; + Ok(()) + }) +} diff --git a/codex-rs/responses-api-proxy/src/read_api_key.rs b/codex-rs/responses-api-proxy/src/read_api_key.rs new file mode 100644 index 00000000..8ffad2aa --- /dev/null +++ b/codex-rs/responses-api-proxy/src/read_api_key.rs @@ -0,0 +1,185 @@ +use anyhow::Context; +use anyhow::Result; +use anyhow::anyhow; +use std::io::Read; +use zeroize::Zeroize; + +/// Use a generous buffer size to avoid truncation and to allow for longer API +/// keys in the future. +const BUFFER_SIZE: usize = 1024; +const AUTH_HEADER_PREFIX: &[u8] = b"Bearer "; + +/// Reads the auth token from stdin and returns a static `Authorization` header +/// value with the auth token used with `Bearer`. The header value is returned +/// as a `&'static str` whose bytes are locked in memory to avoid accidental +/// exposure. +pub(crate) fn read_auth_header_from_stdin() -> Result<&'static str> { + read_auth_header_with(|buffer| std::io::stdin().read(buffer)) +} + +fn read_auth_header_with(read_fn: F) -> Result<&'static str> +where + F: FnOnce(&mut [u8]) -> std::io::Result, +{ + // TAKE CARE WHEN MODIFYING THIS CODE!!! + // + // This function goes to great lengths to avoid leaving the API key in + // memory longer than necessary and to avoid copying it around. We read + // directly into a stack buffer so the only heap allocation should be the + // one to create the String (with the exact size) for the header value, + // which we then immediately protect with mlock(2). + let mut buf = [0u8; BUFFER_SIZE]; + buf[..AUTH_HEADER_PREFIX.len()].copy_from_slice(AUTH_HEADER_PREFIX); + + let read = read_fn(&mut buf[AUTH_HEADER_PREFIX.len()..]).inspect_err(|_err| { + buf.zeroize(); + })?; + + if read == buf.len() - AUTH_HEADER_PREFIX.len() { + buf.zeroize(); + return Err(anyhow!( + "OPENAI_API_KEY is too large to fit in the 512-byte buffer" + )); + } + + let mut total = AUTH_HEADER_PREFIX.len() + read; + while total > AUTH_HEADER_PREFIX.len() && (buf[total - 1] == b'\n' || buf[total - 1] == b'\r') { + total -= 1; + } + + if total == AUTH_HEADER_PREFIX.len() { + buf.zeroize(); + return Err(anyhow!( + "OPENAI_API_KEY must be provided via stdin (e.g. printenv OPENAI_API_KEY | codex responses-api-proxy)" + )); + } + + let header_str = match std::str::from_utf8(&buf[..total]) { + Ok(value) => value, + Err(err) => { + buf.zeroize(); + return Err(err).context("reading Authorization header from stdin as UTF-8"); + } + }; + + let header_value = String::from(header_str); + buf.zeroize(); + + let leaked: &'static mut str = header_value.leak(); + mlock_str(leaked); + + Ok(leaked) +} + +#[cfg(unix)] +fn mlock_str(value: &str) { + use libc::_SC_PAGESIZE; + use libc::c_void; + use libc::mlock; + use libc::sysconf; + + if value.is_empty() { + return; + } + + let page_size = unsafe { sysconf(_SC_PAGESIZE) }; + if page_size <= 0 { + return; + } + let page_size = page_size as usize; + if page_size == 0 { + return; + } + + let addr = value.as_ptr() as usize; + let len = value.len(); + let start = addr & !(page_size - 1); + let addr_end = match addr.checked_add(len) { + Some(v) => match v.checked_add(page_size - 1) { + Some(total) => total, + None => return, + }, + None => return, + }; + let end = addr_end & !(page_size - 1); + let size = end.saturating_sub(start); + if size == 0 { + return; + } + + let _ = unsafe { mlock(start as *const c_void, size) }; +} + +#[cfg(not(unix))] +fn mlock_str(_value: &str) {} + +#[cfg(test)] +mod tests { + use super::*; + use std::io; + + #[test] + fn reads_key_with_no_newlines() { + let result = read_auth_header_with(|buf| { + let data = b"sk-abc123"; + buf[..data.len()].copy_from_slice(data); + Ok(data.len()) + }) + .unwrap(); + + assert_eq!(result, "Bearer sk-abc123"); + } + + #[test] + fn reads_key_and_trims_newlines() { + let result = read_auth_header_with(|buf| { + let data = b"sk-abc123\r\n"; + buf[..data.len()].copy_from_slice(data); + Ok(data.len()) + }) + .unwrap(); + + assert_eq!(result, "Bearer sk-abc123"); + } + + #[test] + fn errors_when_no_input_provided() { + let err = read_auth_header_with(|_| Ok(0)).unwrap_err(); + let message = format!("{err:#}"); + assert!(message.contains("must be provided")); + } + + #[test] + fn errors_when_buffer_filled() { + let err = read_auth_header_with(|buf| { + let data = vec![b'a'; BUFFER_SIZE - AUTH_HEADER_PREFIX.len()]; + buf[..data.len()].copy_from_slice(&data); + Ok(data.len()) + }) + .unwrap_err(); + let message = format!("{err:#}"); + assert!(message.contains("too large")); + } + + #[test] + fn propagates_io_error() { + let err = read_auth_header_with(|_| Err(io::Error::other("boom"))).unwrap_err(); + + let io_error = err.downcast_ref::().unwrap(); + assert_eq!(io_error.kind(), io::ErrorKind::Other); + assert_eq!(io_error.to_string(), "boom"); + } + + #[test] + fn errors_on_invalid_utf8() { + let err = read_auth_header_with(|buf| { + let data = b"sk-abc\xff"; + buf[..data.len()].copy_from_slice(data); + Ok(data.len()) + }) + .unwrap_err(); + + let message = format!("{err:#}"); + assert!(message.contains("UTF-8")); + } +}