From c5494815139d564c72d819aa7ea652373e6499d0 Mon Sep 17 00:00:00 2001 From: Michael Bolin Date: Fri, 26 Sep 2025 08:19:00 -0700 Subject: [PATCH] feat: introduce responses-api-proxy (#4246) Details are in `responses-api-proxy/README.md`, but the key contribution of this PR is a new subcommand, `codex responses-api-proxy`, which reads the auth token for use with the OpenAI Responses API from `stdin` at startup and then proxies `POST` requests to `/v1/responses` over to `https://api.openai.com/v1/responses`, injecting the auth token as part of the `Authorization` header. The expectation is that `codex responses-api-proxy` is launched by a privileged user who has access to the auth token so that it can be used by unprivileged users of the Codex CLI on the same host. If the client only has one user account with `sudo`, one option is to: - run `sudo codex responses-api-proxy --http-shutdown --server-info /tmp/server-info.json` to start the server - record the port written to `/tmp/server-info.json` - relinquish their `sudo` privileges (which is irreversible!) like so: ``` sudo deluser $USER sudo || sudo gpasswd -d $USER sudo || true ``` - use `codex` with the proxy (see `README.md`) - when done, make a `GET` request to the server using the `PORT` from `server-info.json` to shut it down: ```shell curl --fail --silent --show-error "http://127.0.0.1:$PORT/shutdown" ``` To protect the auth token, we: - allocate a 1024 byte buffer on the stack and write `"Bearer "` into it to start - we then read from `stdin`, copying to the contents into the buffer after the prefix - after verifying the input looks good, we create a `String` from that buffer (so the data is now on the heap) - we zero out the stack-allocated buffer using https://crates.io/crates/zeroize so it is not optimized away by the compiler - we invoke `.leak()` on the `String` so we can treat its contents as a `&'static str`, as it will live for the rest of the processs - on UNIX, we `mlock(2)` the memory backing the `&'static str` - when using the `&'static str` when building an HTTP request, we use `HeaderValue::from_static()` to avoid copying the `&str` - we also invoke `.set_sensitive(true)` on the `HeaderValue`, which in theory indicates to other parts of the HTTP stack that the header should be treated with "special care" to avoid leakage: https://github.com/hyperium/http/blob/439d1c50d71e3be3204b6c4a1bf2255ed78e1f93/src/header/value.rs#L346-L376 --- codex-rs/Cargo.lock | 137 +++++++++++- codex-rs/Cargo.toml | 3 + codex-rs/cli/Cargo.toml | 1 + codex-rs/cli/src/main.rs | 11 + codex-rs/responses-api-proxy/Cargo.toml | 27 +++ codex-rs/responses-api-proxy/README.md | 53 +++++ codex-rs/responses-api-proxy/src/lib.rs | 202 ++++++++++++++++++ codex-rs/responses-api-proxy/src/main.rs | 14 ++ .../responses-api-proxy/src/read_api_key.rs | 185 ++++++++++++++++ 9 files changed, 632 insertions(+), 1 deletion(-) create mode 100644 codex-rs/responses-api-proxy/Cargo.toml create mode 100644 codex-rs/responses-api-proxy/README.md create mode 100644 codex-rs/responses-api-proxy/src/lib.rs create mode 100644 codex-rs/responses-api-proxy/src/main.rs create mode 100644 codex-rs/responses-api-proxy/src/read_api_key.rs 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")); + } +}