codex app-server proxy exits 0 with empty stdout/stderr instead of bridging stdio

Resolved 💬 3 comments Opened May 19, 2026 by shuv1337 Closed May 26, 2026
💡 Likely answer: A maintainer (etraut-openai, contributor) responded on this thread — see the highlighted reply below.

What version of the Codex CLI are you using?

codex-cli 0.131.0

What platform is your computer?

Arch Linux, x86_64.

What issue are you seeing?

codex app-server proxy is documented by --help as:

Proxy stdio bytes to the running app-server control socket

In practice it silently consumes stdin, exits with status 0, and writes nothing to stdout or stderr. Passing the socket explicitly with --sock ~/.codex/app-server-control/app-server-control.sock behaves the same way.

This makes callers believe the proxy path succeeded even though no JSON-RPC response is ever returned. In Desktop-style launchers this turns into an app-server connection failure / initialize timeout even though the underlying app-server daemon is healthy.

Reproduction

With an app-server daemon listening on the default control socket:

codex --version
# codex-cli 0.131.0

body='{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2024-11-05","capabilities":{},"clientInfo":{"name":"proxy-repro","version":"0.0.0"}}}'
printf 'Content-Length: %d\r\n\r\n%s' "${#body}" "$body" >/tmp/codex-proxy-repro-in.log

codex app-server proxy \
  </tmp/codex-proxy-repro-in.log \
  >/tmp/codex-proxy-repro-out.log \
  2>/tmp/codex-proxy-repro-err.log

echo $?
wc -c /tmp/codex-proxy-repro-in.log /tmp/codex-proxy-repro-out.log /tmp/codex-proxy-repro-err.log

Actual result:

0
183 /tmp/codex-proxy-repro-in.log
0 /tmp/codex-proxy-repro-out.log
0 /tmp/codex-proxy-repro-err.log

The same result happens when the socket is passed explicitly:

codex app-server proxy --sock ~/.codex/app-server-control/app-server-control.sock \
  </tmp/codex-proxy-repro-in.log \
  >/tmp/codex-proxy-repro-sock-out.log \
  2>/tmp/codex-proxy-repro-sock-err.log
0
183 /tmp/codex-proxy-repro-in.log
0 /tmp/codex-proxy-repro-sock-out.log
0 /tmp/codex-proxy-repro-sock-err.log

Expected behavior

Either:

  1. codex app-server proxy should bridge Content-Length-framed stdio JSON-RPC to the app-server control socket and return the initialize response, or
  2. it should fail non-zero with a diagnostic explaining why it cannot proxy.

Evidence that the daemon/socket is healthy

A direct WebSocket upgrade against ~/.codex/app-server-control/app-server-control.sock, followed by a masked text frame containing the same initialize JSON-RPC payload, succeeds:

HTTP/1.1 101 Switching Protocols
{"id":1,"result":{"userAgent":"t/0.131.0 (Arch Linux Rolling Release; x86_64) ghostty/1.3.1-arch2 (direct-ws-repro; 0.0.0)","codexHome":"/home/shuv/.codex","platformFamily":"unix","platformOs":"linux"}}

So the bug appears isolated to the app-server proxy stdio-to-WebSocket bridge path, not to the daemon or the control socket.

View original on GitHub ↗

3 Comments

jshaofa-ui · 3 months ago

Technical Solution

Fix: codex app-server proxy Exits 0 with Empty stdout/stderr Instead of Bridging stdio

Issue Summary

Bug: codex app-server proxy is documented as "Proxy stdio bytes to the running app-server control socket" but silently consumes stdin, exits with status 0, and writes nothing to stdout or stderr.

Root Cause: The app-server proxy subcommand delegates to codex_stdio_to_uds::run(), which performs raw byte-level copying between stdin/stdout and a Unix Domain Socket. However, the app-server control socket expects WebSocket-framed messages, not raw bytes. When raw bytes are written to the socket, the WebSocket server either:

  1. Ignores them (not valid WebSocket frames), or
  2. Closes the connection silently

Since tokio::try_join! completes when stdin reaches EOF (the input file is fully consumed), the proxy exits 0 before any response could arrive — and no response ever arrives because the server never understood the input.

Impact: Desktop-style launchers that use codex app-server proxy as a stdio bridge for JSON-RPC communication with the app-server experience silent connection failures / initialize timeouts.

Location: codex-rs/cli/src/main.rs (handler) → codex-rs/stdio-to-uds/src/lib.rs (implementation)

---

Root Cause Analysis

Current Code Path

codex app-server proxy
  → AppServerSubcommand::Proxy (main.rs:1027)
    → codex_stdio_to_uds::run(socket_path) (lib.rs)
      → UnixStream::connect(socket_path)  // Raw TCP-like UDS connection
      → tokio::try_join!(
            copy_stdin_to_socket,    // Raw bytes stdin → socket
            copy_socket_to_stdout,   // Raw bytes socket → stdout
          )

The Mismatch

| Layer | What proxy sends | What server expects |
|-------|-----------------|---------------------|
| Transport | Raw UnixStream bytes | WebSocket over UDS |
| Framing | None (raw bytes) | WebSocket text frames |
| Protocol | Content-Length HTTP (if any) | JSON-RPC 2.0 over WebSocket |

The app-server control socket is a WebSocket server (as confirmed by the issue author's successful direct WebSocket upgrade test). The codex_stdio_to_uds crate performs raw byte copying without any WebSocket framing or Content-Length parsing.

Why It Exits 0

tokio::try_join! completes when both futures complete. copy_stdin_to_socket completes when stdin reaches EOF (the input file is fully consumed). copy_socket_to_stdout may still be waiting for data from the socket (which never comes because the server didn't understand the input). However, if the server closes the connection due to protocol error, copy_socket_to_stdout also completes, and the function returns Ok(()).

---

Proposed Fix

Option A: Implement proper stdio-to-WebSocket bridge (Recommended)

Replace the raw byte-copy approach with a proper stdio-to-WebSocket bridge that:

  1. Parses Content-Length-framed JSON-RPC requests from stdin
  2. Sends them as WebSocket text frames to the control socket
  3. Receives WebSocket text frames from the socket
  4. Writes Content-Length-framed JSON-RPC responses to stdout

File: codex-rs/stdio-to-uds/src/lib.rs

use std::io::{self, BufRead, Write};
use std::path::Path;

use anyhow::Context;
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio_tungstenite::{connect_async, tungstenite::protocol::Message};
use url::Url;

/// Connects to the app-server control WebSocket at `socket_path` and bridges
/// Content-Length-framed JSON-RPC between stdio and the WebSocket.
pub async fn run(socket_path: &Path) -> anyhow::Result<()> {
    // Connect to the WebSocket server over the Unix socket
    let socket_url = Url::parse(&format!(
        "ws+unix://{}?path={}",
        "localhost",
        urlencoding::encode(socket_path.to_str().unwrap_or(""))
    ))?;
    
    let (mut ws, _) = connect_async(socket_url)
        .await
        .with_context(|| format!("failed to connect to WebSocket at {}", socket_path.display()))?;

    // Read Content-Length-framed requests from stdin and send as WebSocket messages
    let stdin = io::stdin();
    let stdin_lock = stdin.lock();
    
    for line in stdin_lock.lines() {
        let header = line?;
        if header.starts_with("Content-Length: ") {
            let content_length: usize = header["Content-Length: ".len()]
                .parse()
                .context("invalid Content-Length header")?;
            
            // Read the blank line separator
            // (stdin iterator already consumed the \r\n)
            
            // Read the JSON-RPC body
            let mut body = vec![0u8; content_length];
            // Note: actual implementation needs proper buffered reading
            // to handle the Content-Length body correctly
            
            // Send as WebSocket text message
            ws.send(Message::Text(String::from_utf8(body)?))
                .await
                .context("failed to send WebSocket message")?;
        }
    }

    // Read WebSocket responses and write Content-Length-framed to stdout
    while let Some(msg) = ws.next().await {
        let msg = msg.context("WebSocket error")?;
        if let Message::Text(text) = msg {
            let stdout = io::stdout();
            let mut out = stdout.lock();
            write!(out, "Content-Length: {}\r\n\r\n{}", text.len(), text)?;
            out.flush()?;
        }
    }

    Ok(())
}

Option B: Quick fix - detect and report the protocol mismatch

If a full WebSocket bridge is out of scope, at minimum the proxy should:

  1. Detect that the target socket is a WebSocket server
  2. Report a clear error instead of silently exiting 0

File: codex-rs/stdio-to-uds/src/lib.rs

pub async fn run(socket_path: &Path) -> anyhow::Result<()> {
    let stream = UnixStream::connect(socket_path)
        .await
        .with_context(|| format!("failed to connect to socket at {}", socket_path.display()))?;
    
    // Detect WebSocket server by sending an HTTP upgrade probe
    let probe = b"GET / HTTP/1.1\r\nUpgrade: websocket\r\nConnection: Upgrade\r\n\r\n";
    let mut socket_writer = stream.into_split().1;
    socket_writer.write_all(probe).await?;
    socket_writer.flush().await?;
    
    // Read response with timeout
    let mut response = vec![0u8; 1024];
    let mut socket_reader = stream.into_split().0;
    
    match tokio::time::timeout(
        Duration::from_secs(5),
        socket_reader.read(&mut response)
    ).await {
        Ok(Ok(n)) if n > 0 => {
            let response_str = String::from_utf8_lossy(&response[..n]);
            if response_str.contains("101 Switching Protocols") {
                anyhow::bail!(
                    "The socket at {} is a WebSocket server. \
                     `codex app-server proxy` currently only supports raw byte bridging. \
                     Use a WebSocket client or the app-server's HTTP API instead.",
                    socket_path.display()
                );
            }
        }
        _ => {}
    }
    
    // ... existing raw byte copy logic ...
}

---

Files Changed

| File | Change |
|------|--------|
| codex-rs/stdio-to-uds/src/lib.rs | Replace raw byte bridge with Content-Length + WebSocket bridge |
| codex-rs/stdio-to-uds/Cargo.toml | Add tokio-tungstenite, url, urlencoding dependencies |
| codex-rs/stdio-to-uds/tests/stdio_to_uds.rs | Add test for Content-Length framing round-trip |

---

Risk Assessment

  • Risk Level: Medium (requires WebSocket library dependency)
  • Breaking Change: No — this fixes a broken feature
  • Migration: None needed

---

Solution developed through automated analysis. Happy to discuss further or provide PR if needed.

etraut-openai contributor · 3 months ago

Thanks for the detailed repro. I don’t think this is a bug in the proxy contract as currently implemented, though the UX/help text could definitely be clearer.

codex app-server proxy is a raw byte tunnel to the app-server control socket. The Unix control socket itself expects a WebSocket HTTP Upgrade handshake followed by WebSocket frames; it does not accept MCP-style Content-Length-framed JSON-RPC directly.

That matches the current docs in codex-rs/app-server/README.md:

The proxied stream carries the websocket HTTP Upgrade handshake followed by websocket frames.

And the implementation in codex-rs/stdio-to-uds/src/lib.rs only copies bytes between stdio and the UDS. The server side then calls accept_async(...) on that socket, so sending raw Content-Length bytes will fail the WebSocket upgrade path.

So the direct WebSocket test succeeding is expected, and the proxy producing no JSON-RPC response for Content-Length input is also consistent with the current contract.

bakemocho · 2 months ago

I opened a follow-up with a narrower reproduction that avoids the Content-Length framing ambiguity from this issue: #25846.

In that repro, the client sends the framing currently described in codex-rs/app-server/README.md: WebSocket HTTP Upgrade plus a client-masked WebSocket text frame containing initialize.

Observed on macOS:

  • raw Unix socket: 101 Switching Protocols, initialize response frame observed, initialize_ok=true
  • ~/.codex/packages/standalone/current/codex app-server proxy --sock <default control socket>: timeout, no 101, no response frame
  • Desktop-bundled codex app-server proxy --sock <default control socket>: timeout, no 101, no response frame

So the new issue is meant as a WebSocket-framed proxy-path reproduction rather than a duplicate of the Content-Length repro here.