From d01f91ecec0803a35773c9903eda61a73a777b84 Mon Sep 17 00:00:00 2001 From: Michael Bolin Date: Sun, 19 Oct 2025 21:12:45 -0700 Subject: [PATCH] feat: experimental `codex stdio-to-uds` subcommand (#5350) --- codex-rs/Cargo.lock | 12 ++++ codex-rs/Cargo.toml | 5 +- codex-rs/cli/Cargo.toml | 9 +-- codex-rs/cli/src/main.rs | 16 +++++ codex-rs/stdio-to-uds/Cargo.toml | 26 ++++++++ codex-rs/stdio-to-uds/README.md | 20 ++++++ codex-rs/stdio-to-uds/src/lib.rs | 52 ++++++++++++++++ codex-rs/stdio-to-uds/src/main.rs | 19 ++++++ codex-rs/stdio-to-uds/tests/stdio_to_uds.rs | 68 +++++++++++++++++++++ 9 files changed, 222 insertions(+), 5 deletions(-) create mode 100644 codex-rs/stdio-to-uds/Cargo.toml create mode 100644 codex-rs/stdio-to-uds/README.md create mode 100644 codex-rs/stdio-to-uds/src/lib.rs create mode 100644 codex-rs/stdio-to-uds/src/main.rs create mode 100644 codex-rs/stdio-to-uds/tests/stdio_to_uds.rs diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index 9d18361c..0ecada0f 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -968,6 +968,7 @@ dependencies = [ "codex-protocol-ts", "codex-responses-api-proxy", "codex-rmcp-client", + "codex-stdio-to-uds", "codex-tui", "ctor 0.5.0", "owo-colors", @@ -1397,6 +1398,17 @@ dependencies = [ "webbrowser", ] +[[package]] +name = "codex-stdio-to-uds" +version = "0.0.0" +dependencies = [ + "anyhow", + "assert_cmd", + "pretty_assertions", + "tempfile", + "uds_windows", +] + [[package]] name = "codex-tui" version = "0.0.0" diff --git a/codex-rs/Cargo.toml b/codex-rs/Cargo.toml index 64eebb62..5286ef87 100644 --- a/codex-rs/Cargo.toml +++ b/codex-rs/Cargo.toml @@ -29,6 +29,7 @@ members = [ "protocol-ts", "rmcp-client", "responses-api-proxy", + "stdio-to-uds", "otel", "tui", "git-apply", @@ -54,10 +55,10 @@ codex-app-server = { path = "app-server" } codex-app-server-protocol = { path = "app-server-protocol" } codex-apply-patch = { path = "apply-patch" } codex-arg0 = { path = "arg0" } +codex-async-utils = { path = "async-utils" } codex-chatgpt = { path = "chatgpt" } codex-common = { path = "common" } codex-core = { path = "core" } -codex-async-utils = { path = "async-utils" } codex-exec = { path = "exec" } codex-feedback = { path = "feedback" } codex-file-search = { path = "file-search" } @@ -73,6 +74,7 @@ codex-protocol = { path = "protocol" } codex-protocol-ts = { path = "protocol-ts" } codex-responses-api-proxy = { path = "responses-api-proxy" } codex-rmcp-client = { path = "rmcp-client" } +codex-stdio-to-uds = { path = "stdio-to-uds" } codex-tui = { path = "tui" } codex-utils-json-to-toml = { path = "utils/json-to-toml" } codex-utils-readiness = { path = "utils/readiness" } @@ -186,6 +188,7 @@ tree-sitter = "0.25.10" tree-sitter-bash = "0.25" tree-sitter-highlight = "0.25.10" ts-rs = "11" +uds_windows = "1.1.0" unicode-segmentation = "1.12.0" unicode-width = "0.2" url = "2" diff --git a/codex-rs/cli/Cargo.toml b/codex-rs/cli/Cargo.toml index 453ab807..d6667868 100644 --- a/codex-rs/cli/Cargo.toml +++ b/codex-rs/cli/Cargo.toml @@ -19,8 +19,10 @@ anyhow = { workspace = true } clap = { workspace = true, features = ["derive"] } clap_complete = { workspace = true } codex-app-server = { workspace = true } +codex-app-server-protocol = { workspace = true } codex-arg0 = { workspace = true } codex-chatgpt = { workspace = true } +codex-cloud-tasks = { path = "../cloud-tasks" } codex-common = { workspace = true, features = ["cli"] } codex-core = { workspace = true } codex-exec = { workspace = true } @@ -28,12 +30,11 @@ codex-login = { workspace = true } codex-mcp-server = { workspace = true } codex-process-hardening = { workspace = true } codex-protocol = { workspace = true } -codex-app-server-protocol = { workspace = true } codex-protocol-ts = { workspace = true } codex-responses-api-proxy = { workspace = true } -codex-tui = { workspace = true } codex-rmcp-client = { workspace = true } -codex-cloud-tasks = { path = "../cloud-tasks" } +codex-stdio-to-uds = { workspace = true } +codex-tui = { workspace = true } ctor = { workspace = true } owo-colors = { workspace = true } serde_json = { workspace = true } @@ -47,8 +48,8 @@ tokio = { workspace = true, features = [ ] } [dev-dependencies] -assert_matches = { workspace = true } assert_cmd = { workspace = true } +assert_matches = { workspace = true } predicates = { workspace = true } pretty_assertions = { workspace = true } tempfile = { workspace = true } diff --git a/codex-rs/cli/src/main.rs b/codex-rs/cli/src/main.rs index 30f7a91b..39935034 100644 --- a/codex-rs/cli/src/main.rs +++ b/codex-rs/cli/src/main.rs @@ -105,6 +105,10 @@ enum Subcommand { #[clap(hide = true)] ResponsesApiProxy(ResponsesApiProxyArgs), + /// Internal: relay stdio to a Unix domain socket. + #[clap(hide = true, name = "stdio-to-uds")] + StdioToUds(StdioToUdsCommand), + /// Inspect feature flags. Features(FeaturesCli), } @@ -206,6 +210,13 @@ struct GenerateTsCommand { prettier: Option, } +#[derive(Debug, Parser)] +struct StdioToUdsCommand { + /// Path to the Unix domain socket to connect to. + #[arg(value_name = "SOCKET_PATH")] + socket_path: PathBuf, +} + fn format_exit_messages(exit_info: AppExitInfo, color_enabled: bool) -> Vec { let AppExitInfo { token_usage, @@ -463,6 +474,11 @@ async fn cli_main(codex_linux_sandbox_exe: Option) -> anyhow::Result<() tokio::task::spawn_blocking(move || codex_responses_api_proxy::run_main(args)) .await??; } + Some(Subcommand::StdioToUds(cmd)) => { + let socket_path = cmd.socket_path; + tokio::task::spawn_blocking(move || codex_stdio_to_uds::run(socket_path.as_path())) + .await??; + } Some(Subcommand::GenerateTs(gen_cli)) => { codex_protocol_ts::generate_ts(&gen_cli.out_dir, gen_cli.prettier.as_deref())?; } diff --git a/codex-rs/stdio-to-uds/Cargo.toml b/codex-rs/stdio-to-uds/Cargo.toml new file mode 100644 index 00000000..4f713288 --- /dev/null +++ b/codex-rs/stdio-to-uds/Cargo.toml @@ -0,0 +1,26 @@ +[package] +edition = "2024" +name = "codex-stdio-to-uds" +version = { workspace = true } + +[[bin]] +name = "codex-stdio-to-uds" +path = "src/main.rs" + +[lib] +name = "codex_stdio_to_uds" +path = "src/lib.rs" + +[lints] +workspace = true + +[dependencies] +anyhow = { workspace = true } + +[target.'cfg(target_os = "windows")'.dependencies] +uds_windows = { workspace = true } + +[dev-dependencies] +assert_cmd = { workspace = true } +pretty_assertions = { workspace = true } +tempfile = { workspace = true } diff --git a/codex-rs/stdio-to-uds/README.md b/codex-rs/stdio-to-uds/README.md new file mode 100644 index 00000000..96ac6796 --- /dev/null +++ b/codex-rs/stdio-to-uds/README.md @@ -0,0 +1,20 @@ +# codex-stdio-to-uds + +Traditionally, there are two transport mechanisms for an MCP server: stdio and HTTP. + +This crate helps enable a third, which is UNIX domain socket, because it has the advantages that: + +- The UDS can be attached to long-running process, like an HTTP server. +- The UDS can leverage UNIX file permissions to restrict access. + +To that end, this crate provides an adapter between a UDS and stdio. The idea is that someone could start an MCP server that communicates over `/tmp/mcp.sock`. Then the user could specify this on the fly like so: + +``` +codex --config mcp_servers.example={command="codex-stdio-to-uds",args=["/tmp/mcp.sock"]} +``` + +Unfortunately, the Rust standard library does not provide support for UNIX domain sockets on Windows today even though support was added in October 2018 in Windows 10: + +https://github.com/rust-lang/rust/issues/56533 + +As a workaround, this crate leverages https://crates.io/crates/uds_windows as a dependency on Windows. diff --git a/codex-rs/stdio-to-uds/src/lib.rs b/codex-rs/stdio-to-uds/src/lib.rs new file mode 100644 index 00000000..11906888 --- /dev/null +++ b/codex-rs/stdio-to-uds/src/lib.rs @@ -0,0 +1,52 @@ +#![deny(clippy::print_stdout)] + +use std::io; +use std::io::Write; +use std::net::Shutdown; +use std::path::Path; +use std::thread; + +use anyhow::Context; +use anyhow::anyhow; + +#[cfg(unix)] +use std::os::unix::net::UnixStream; + +#[cfg(windows)] +use uds_windows::UnixStream; + +/// Connects to the Unix Domain Socket at `socket_path` and relays data between +/// standard input/output and the socket. +pub fn run(socket_path: &Path) -> anyhow::Result<()> { + let mut stream = UnixStream::connect(socket_path) + .with_context(|| format!("failed to connect to socket at {}", socket_path.display()))?; + + let mut reader = stream + .try_clone() + .context("failed to clone socket for reading")?; + + let stdout_thread = thread::spawn(move || -> io::Result<()> { + let stdout = io::stdout(); + let mut handle = stdout.lock(); + io::copy(&mut reader, &mut handle)?; + handle.flush()?; + Ok(()) + }); + + let stdin = io::stdin(); + { + let mut handle = stdin.lock(); + io::copy(&mut handle, &mut stream).context("failed to copy data from stdin to socket")?; + } + + stream + .shutdown(Shutdown::Write) + .context("failed to shutdown socket writer")?; + + let stdout_result = stdout_thread + .join() + .map_err(|_| anyhow!("thread panicked while copying socket data to stdout"))?; + stdout_result.context("failed to copy data from socket to stdout")?; + + Ok(()) +} diff --git a/codex-rs/stdio-to-uds/src/main.rs b/codex-rs/stdio-to-uds/src/main.rs new file mode 100644 index 00000000..69987c31 --- /dev/null +++ b/codex-rs/stdio-to-uds/src/main.rs @@ -0,0 +1,19 @@ +use std::env; +use std::path::PathBuf; +use std::process; + +fn main() -> anyhow::Result<()> { + let mut args = env::args_os().skip(1); + let Some(socket_path) = args.next() else { + eprintln!("Usage: codex-stdio-to-uds "); + process::exit(1); + }; + + if args.next().is_some() { + eprintln!("Expected exactly one argument: "); + process::exit(1); + } + + let socket_path = PathBuf::from(socket_path); + codex_stdio_to_uds::run(&socket_path) +} diff --git a/codex-rs/stdio-to-uds/tests/stdio_to_uds.rs b/codex-rs/stdio-to-uds/tests/stdio_to_uds.rs new file mode 100644 index 00000000..11888a81 --- /dev/null +++ b/codex-rs/stdio-to-uds/tests/stdio_to_uds.rs @@ -0,0 +1,68 @@ +use std::io::ErrorKind; +use std::io::Read; +use std::io::Write; +use std::sync::mpsc; +use std::thread; +use std::time::Duration; + +use anyhow::Context; +use assert_cmd::Command; +use pretty_assertions::assert_eq; + +#[cfg(unix)] +use std::os::unix::net::UnixListener; + +#[cfg(windows)] +use uds_windows::UnixListener; + +#[test] +fn pipes_stdin_and_stdout_through_socket() -> anyhow::Result<()> { + let dir = tempfile::TempDir::new().context("failed to create temp dir")?; + let socket_path = dir.path().join("socket"); + let listener = match UnixListener::bind(&socket_path) { + Ok(listener) => listener, + Err(err) if err.kind() == ErrorKind::PermissionDenied => { + eprintln!("skipping test: failed to bind unix socket: {err}"); + return Ok(()); + } + Err(err) => { + return Err(err).context("failed to bind test unix socket"); + } + }; + + let (tx, rx) = mpsc::channel(); + let server_thread = thread::spawn(move || -> anyhow::Result<()> { + let (mut connection, _) = listener + .accept() + .context("failed to accept test connection")?; + let mut received = Vec::new(); + connection + .read_to_end(&mut received) + .context("failed to read data from client")?; + tx.send(received) + .map_err(|_| anyhow::anyhow!("failed to send received bytes to test thread"))?; + connection + .write_all(b"response") + .context("failed to write response to client")?; + Ok(()) + }); + + Command::cargo_bin("codex-stdio-to-uds")? + .arg(&socket_path) + .write_stdin("request") + .assert() + .success() + .stdout("response"); + + let received = rx + .recv_timeout(Duration::from_secs(1)) + .context("server did not receive data in time")?; + assert_eq!(received, b"request"); + + let server_result = server_thread + .join() + .map_err(|_| anyhow::anyhow!("server thread panicked"))?; + server_result.context("server failed")?; + + Ok(()) +}