Skip to content

feat(codex): add inbound Responses WebSocket transport - #519

Open
yansigit wants to merge 7 commits into
pleaseai:mainfrom
yansigit:codex/inbound-responses-websocket
Open

yansigit wants to merge 7 commits into
pleaseai:mainfrom
yansigit:codex/inbound-responses-websocket

Conversation

@yansigit

@yansigit yansigit commented Sep 10, 2026

Copy link
Copy Markdown
Contributor

Summary

Adds authenticated WebSocket transport support for the inbound OpenAI Responses (Codex) endpoint (/backend-api/codex/responses, /responses, /v1/responses).

Problem

The inbound Codex endpoint previously accepted only HTTP POST requests. Clients using WebSocket connections (such as Codex CLI / desktop when configured for WebSocket or using wire_api = "responses") could not connect or stream turns over an established socket.

Solution

  • Added GET WebSocket upgrade handler on all registered Responses paths (/backend-api/codex/responses, /responses, /v1/responses).
  • Authentication completes before 101 Switching Protocols, rejecting unauthenticated connections before upgrade.
  • Parsed inbound client frames (response.create, response.processed ack, close, etc.) with support for local generate: false warmups.
  • Reuses the shared forward_turn dispatch machinery: WebSocket turns use the exact same account-pool selection, model routing, and headers allowlist as HTTP POST turns.
  • Reassembles upstream SSE frames lazily and streams events to the client through the first terminal event, applying bounded backpressure and enforcing a 4 MiB frame limit.
  • Cleanly cancels active upstream bodies on socket disconnect or replacement turns.
  • Full end-to-end test suite added in tests/inbound_codex_websocket.rs and unit tests in src/codex_endpoint/frame.rs.
  • Documentation updated across docs/m11-inbound-codex-endpoint.md and localized site reference (en, ja, ko, zh-cn).

Verification

  • cargo fmt --all --check
  • cargo clippy --all-targets -- -D warnings
  • cargo test --test inbound_codex_websocket
  • cargo test --lib codex_endpoint::

Summary by cubic

Adds an authenticated WebSocket transport for the inbound Codex Responses endpoint so socket-based clients (e.g. Codex CLI with wire_api = "responses") can stream turns, where previously only HTTP POST was accepted.

  • The three Responses paths (/backend-api/codex/responses, /responses, /v1/responses) now also accept GET WebSocket upgrades.
  • Authentication completes before 101 Switching Protocols; unauthenticated connections and upgrades from disallowed origins are rejected before the handshake.
  • WebSocket turns reuse the shared forward_turn path, so account-pool selection, model routing, and the headers allowlist match HTTP POST, including stripping reloaded admin headers.
  • Upstream SSE events are reassembled lazily and streamed through the first terminal event, with bounded backpressure, a 4 MiB frame limit, and a concurrency-limit permit like HTTP requests.
  • Error envelopes expose only a safe allowlist of upstream response headers; cookies, credentials, hop-by-hop, and Shunt-owned headers never reach the client.
  • Active upstream bodies are cancelled on socket disconnect or a replacement turn.
  • Adds tokio-tungstenite 0.29 alongside the existing 0.28 dependency and enables the axum ws feature.
  • Adds end-to-end and unit tests, plus documentation updates in docs/ and the localized site reference.

Written for commit 576dd99. Summary will update on new commits.

@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Sep 10, 2026

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review Completed 2026-09-11T18:12:28.243430Z 576dd99 New commits
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request introduces an inbound WebSocket transport for the Codex Responses endpoint, adding WebSocket upgrade handling, control framing, and bounded SSE reassembly. The review feedback highlights several issues: WebSocket handshake headers should be stripped before forwarding upstream to prevent protocol upgrade conflicts; errors from framer.finish() must be handled rather than ignored; send failures during warmup frames should terminate the outer session loop; test environment variables should be managed via a drop guard to avoid flakiness; and concat! for multi-line string literals in tests should be replaced with standard Rust string continuation escapes.

Comment on lines +164 to +167
let mut turn_headers = handshake_headers.clone();
turn_headers.insert(header::CONTENT_TYPE, "application/json".parse().unwrap());
turn_headers.remove(header::CONTENT_LENGTH);
turn_headers.remove(header::CONTENT_ENCODING);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

[HIGH] Forwarding WebSocket handshake headers upstream

Problem: The WebSocket handshake headers (such as Upgrade, Connection, and Sec-WebSocket-*) are cloned from handshake_headers and forwarded upstream to the HTTP/SSE endpoint. This can cause the upstream server (or intermediate proxies/CDNs like Cloudflare) to reject the request or attempt an unexpected protocol upgrade.
Rationale: WebSocket-specific headers should be stripped before forwarding the request as a standard HTTP/SSE turn.
Suggestion: Remove the WebSocket handshake headers from turn_headers before dispatching.

Suggested change
let mut turn_headers = handshake_headers.clone();
turn_headers.insert(header::CONTENT_TYPE, "application/json".parse().unwrap());
turn_headers.remove(header::CONTENT_LENGTH);
turn_headers.remove(header::CONTENT_ENCODING);
let mut turn_headers = handshake_headers.clone();
turn_headers.insert(header::CONTENT_TYPE, "application/json".parse().unwrap());
turn_headers.remove(header::CONTENT_LENGTH);
turn_headers.remove(header::CONTENT_ENCODING);
turn_headers.remove(header::UPGRADE);
turn_headers.remove(header::CONNECTION);
turn_headers.remove("sec-websocket-key");
turn_headers.remove("sec-websocket-version");
turn_headers.remove("sec-websocket-extensions");
turn_headers.remove("sec-websocket-protocol");

Comment on lines +381 to +410
if !terminal_seen && is_current() {
if let Ok(Some(tail)) = framer.finish() {
let s = match std::str::from_utf8(&tail) {
Ok(s) => s,
Err(_) => {
let err_frame = build_ws_error_frame(
502,
"protocol_error",
"websocket_protocol_error",
"Invalid UTF-8 in upstream SSE frame",
None,
);
let _ = out_tx
.send((turn_gen, Message::Text(err_frame.into())))
.await;
return;
}
};
if let Some(payload) = parse_sse_block(s) {
if payload != "[DONE]" {
if let Some(p_type) = parse_payload_type(&payload) {
let _ = out_tx.send((turn_gen, Message::Text(payload.into()))).await;
if terminal_status_from_type(&p_type).is_some() {
terminal_seen = true;
}
}
}
}
}
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

[MEDIUM] Unhandled error from framer.finish()

Problem: If framer.finish() returns an error (e.g., due to size limit or count limit violations on the trailing bytes), the error is silently ignored via if let Ok(Some(tail)) = framer.finish(). This can leave the client hanging or unaware of a protocol violation.
Rationale: Errors on the cleanup path should be handled and reported to the client consistently with the main loop.
Suggestion: Match on the result of framer.finish() and send a protocol error frame if it fails.

    if !terminal_seen && is_current() {
        match framer.finish() {
            Ok(Some(tail)) => {
                let s = match std::str::from_utf8(&tail) {
                    Ok(s) => s,
                    Err(_) => {
                        let err_frame = build_ws_error_frame(
                            502,
                            "protocol_error",
                            "websocket_protocol_error",
                            "Invalid UTF-8 in upstream SSE frame",
                            None,
                        );
                        let _ = out_tx
                            .send((turn_gen, Message::Text(err_frame.into())))
                            .await;
                        return;
                    }
                };
                if let Some(payload) = parse_sse_block(s) {
                    if payload != "[DONE]" {
                        if let Some(p_type) = parse_payload_type(&payload) {
                            let _ = out_tx.send((turn_gen, Message::Text(payload.into()))).await;
                            if terminal_status_from_type(&p_type).is_some() {
                                terminal_seen = true;
                            }
                        } else {
                            let err_frame = build_ws_error_frame(
                                502,
                                "protocol_error",
                                "websocket_protocol_error",
                                "Invalid JSON payload in upstream SSE frame",
                                None,
                            );
                            let _ = out_tx
                                .send((turn_gen, Message::Text(err_frame.into())))
                                .await;
                            return;
                        }
                    }
                }
            }
            Ok(None) => {}
            Err(err) => {
                let err_frame = build_ws_error_frame(
                    502,
                    "protocol_error",
                    "websocket_protocol_error",
                    &err.to_string(),
                    None,
                );
                let _ = out_tx
                    .send((turn_gen, Message::Text(err_frame.into())))
                    .await;
                return;
            }
        }
    }

Comment on lines +141 to +144
if let Err(err) = ws_tx.send(Message::Text(f.into())).await {
tracing::debug!(error = %err, "failed to send warmup frame");
break;
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

[MEDIUM] Incomplete loop break on send failure

Problem: If sending a warmup frame fails, break only exits the inner for f in frames loop, but the outer loop continues to run. This can cause unnecessary iterations or state checks when the connection is already closed.
Rationale: A send failure on a closed connection should immediately terminate the socket session loop.
Suggestion: Return from handle_socket or break the outer loop on send failure.

Suggested change
if let Err(err) = ws_tx.send(Message::Text(f.into())).await {
tracing::debug!(error = %err, "failed to send warmup frame");
break;
}
if let Err(err) = ws_tx.send(Message::Text(f.into())).await {
tracing::debug!(error = %err, "failed to send warmup frame");
return;
}

Comment on lines +281 to +284
fn cleanup(account_env: &str, client_env: &str) {
std::env::remove_var(account_env);
std::env::remove_var(client_env);
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

[MEDIUM] Use drop guard for environment variables in tests

Problem: The tests rely on process-global environment variables and clean them up manually at the end of each test using cleanup. If a test panics, the cleanup is bypassed, leading to test flakiness and cross-test interference.
Rationale: According to the general rules, process-global environment variables in tests should be cleaned up using a drop guard at the end of the test instead of resetting them on entry or manually cleaning them up.
Suggestion: Implement an EnvGuard drop guard and use it to automatically clean up environment variables.

struct EnvGuard {
    vars: Vec<String>,
}

impl Drop for EnvGuard {
    fn drop(&mut self) {
        for var in &self.vars {
            std::env::remove_var(var);
        } 
    }
}
References
  1. When testing code that relies on process-global environment variables, prevent test flakiness and cross-test interference by using unique suffixes (such as std::process::id()) for environment variable names, and clean them up using a drop guard at the end of the test instead of resetting them on entry.

Comment on lines +362 to +367
let body = concat!(
"data: {\"type\":\"response.created\",\"response\":{\"id\":\"r1\"}}\n\n",
"data: {\"type\":\"response.output_text.delta\",\"delta\":\"hi\"}\n\n",
"data: {\"type\":\"response.completed\",\"response\":{\"id\":\"r1\"}}\n\n",
"data: {\"type\":\"response.output_text.delta\",\"delta\":\"stale\"}\n\n"
);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

[MEDIUM] Avoid concat! for multi-line string literals

Problem: The test uses concat! to join multi-line string literals.
Rationale: According to the general rules, a backslash \ at the end of a line in a string literal acts as a string continuation escape, which discards the newline and all leading whitespace on the following line. Multi-line string literals formatted this way do not contain unintended leading spaces, and using concat! is not necessary.
Suggestion: Rewrite the multi-line string literal using backslash escapes.

    let body = "data: {\"type\":\"response.created\",\"response\":{\"id\":\"r1\"}}\n\n\
                data: {\"type\":\"response.output_text.delta\",\"delta\":\"hi\"}\n\n\
                data: {\"type\":\"response.completed\",\"response\":{\"id\":\"r1\"}}\n\n\
                data: {\"type\":\"response.output_text.delta\",\"delta\":\"stale\"}\n\n";
References
  1. In Rust, a backslash \ at the end of a line in a string literal acts as a string continuation escape, which discards the newline and all leading whitespace on the following line. Multi-line string literals formatted this way do not contain unintended leading spaces, and using concat! is not necessary to avoid whitespace.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 6c7bea579a

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +164 to +167
let mut turn_headers = handshake_headers.clone();
turn_headers.insert(header::CONTENT_TYPE, "application/json".parse().unwrap());
turn_headers.remove(header::CONTENT_LENGTH);
turn_headers.remove(header::CONTENT_ENCODING);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Normalize WebSocket headers before the HTTP dispatch

When a real Codex client supplies OpenAI-Beta: responses_websockets=2026-02-06 during the upgrade, cloning the handshake headers here passes that WebSocket-only selector—and the Sec-WebSocket-* handshake fields—into the upstream HTTP POST because the passthrough strip list does not remove them. The repository's HTTP request paths instead send OpenAI-Beta: responses=experimental; rewrite that selector and strip WebSocket handshake metadata before forwarding each turn so the ChatGPT backend receives a valid HTTP Responses request.

Useful? React with 👍 / 👎.

Comment on lines +220 to +224
match type_str {
"response.completed" => Some(TerminalStatus::Completed),
"response.failed" => Some(TerminalStatus::Failed),
"response.incomplete" => Some(TerminalStatus::Incomplete),
_ => None,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Treat upstream error events as terminal

On a 200 OK SSE stream ending with data: {"type":"error",...}, this match returns None, even though the existing Responses translator and outbound WebSocket transport both treat error as terminal. run_turn consequently forwards the real error frame, reaches EOF with terminal_seen == false, and sends a second, misleading 502 websocket_protocol_error; include error in terminal classification so backend rate-limit or policy errors end the turn after the original frame.

Useful? React with 👍 / 👎.

Comment on lines +416 to +420
if index + 1 >= length {
return None;
}
if byte_at(index + 1) != b'\n' {
return Some(0);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Recognize CR-only SSE event delimiters

For a standards-compliant upstream that terminates SSE lines with bare carriage returns, the valid blank-line delimiter \r\r reaches this branch and is treated as ordinary content because only \r\n combinations are recognized. Multiple events are then merged into one block and fail JSON parsing, producing a spurious 502; recognize \r\r alongside the existing LF and CRLF delimiter variants.

Useful? React with 👍 / 👎.

Comment thread docs/m11-inbound-codex-endpoint.md Outdated
a bare base produces `/responses`. Registering all three lets an operator use either CLI setup
style (§ "Codex CLI setup" below) without shunt needing to know which one a given client chose.

### WebSocket transport

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Remove the contradictory HTTP-only follow-up

This new WebSocket section conflicts with the unchanged “Out of scope / follow-up” entry later in the same document, which still says the endpoint is HTTP/SSE-only and that WebSocket support is future work. Remove or update that stale entry so operators are not given mutually exclusive support information.

AGENTS.md reference: AGENTS.md:L47-L53

Useful? React with 👍 / 👎.

@greptile-apps

greptile-apps Bot commented Sep 10, 2026

Copy link
Copy Markdown

RetriggerConfidence Score: 2/5

The PR is not safe to merge until long-lived sockets honor authentication reloads without leaking stale credential headers and every turn enforces the configured request-size limit.

Fix All in Claude CodeFindings

  1. P1 Security Stale Socket Authentication
  2. P1 Request Limit Bypassed
Fix with agent prompt
### Issue 1
src/codex_endpoint/websocket.rs:164
Authentication runs only during the initial WebSocket upgrade. Later `response.create` turns use refreshed runtime state without revalidating the caller, so a socket authenticated with a token that is subsequently rotated or removed can continue submitting provider-funded turns. If a reload changes the configured authentication header name, each turn also retains the original upgrade headers while the refreshed sanitizer removes only the new header name, allowing the old custom credential header to reach the default Codex upstream. Reauthenticate each turn, or preserve and use the original authentication snapshot when sanitizing the stored headers.

**How this was verified:** The upgrade is the only authentication call, while later turns clone its headers and dispatch using refreshed state whose sanitizer recognizes only the current configured credential header.

### Issue 2
src/codex_endpoint/websocket.rs:215
WebSocket turns bypass `server.limits.max_request_bytes`. The upgrade accepts messages up to the hard-coded 4 MiB limit and calls `forward_turn` directly, while the HTTP path applies the configured size checks first. When an operator configures a limit below 4 MiB, an oversized `response.create` is therefore serialized and forwarded upstream instead of being rejected. Apply the configured limit before dispatching each turn.

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

Summary

  • Revalidate authentication or preserve its original sanitization context for every turn on a long-lived socket.
  • Enforce the configured request-body limit on each WebSocket response.create.

Diagram

sequenceDiagram
    participant C as Codex client
    participant W as Inbound WebSocket handler
    participant A as Authentication state
    participant D as Responses dispatcher
    participant U as Upstream provider

    C->>W: Authenticated HTTP upgrade
    W->>A: Authenticate once
    A-->>W: Caller identity
    W-->>C: 101 Switching Protocols

    loop response.create turns
        C->>W: response.create
        W->>A: Refresh runtime state
        Note over W,A: Current code does not reauthenticate<br/>or enforce max_request_bytes
        W->>D: Forward serialized turn
        D->>U: HTTP/SSE request
        U-->>D: SSE events
        D-->>W: Streaming body
        W-->>C: WebSocket text events
    end
Loading

Reviews (1) · Last reviewed commit: "feat(codex): add inbound Responses WebSo..."

}
let body_bytes = Bytes::from(serde_json::to_vec(&raw_json).unwrap_or_default());

let mut turn_headers = handshake_headers.clone();

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 security Stale Socket Authentication

Authentication runs only during the initial WebSocket upgrade. Later response.create turns use refreshed runtime state without revalidating the caller, so a socket authenticated with a token that is subsequently rotated or removed can continue submitting provider-funded turns. If a reload changes the configured authentication header name, each turn also retains the original upgrade headers while the refreshed sanitizer removes only the new header name, allowing the old custom credential header to reach the default Codex upstream. Reauthenticate each turn, or preserve and use the original authentication snapshot when sanitizing the stored headers.

How this was verified: The upgrade is the only authentication call, while later turns clone its headers and dispatch using refreshed state whose sanitizer recognizes only the current configured credential header.

Knowledge Base Used:

Prompt To Fix With AI
This is a comment left during a code review.
Path: src/codex_endpoint/websocket.rs
Line: 164

Comment:
**Stale Socket Authentication**

Authentication runs only during the initial WebSocket upgrade. Later `response.create` turns use refreshed runtime state without revalidating the caller, so a socket authenticated with a token that is subsequently rotated or removed can continue submitting provider-funded turns. If a reload changes the configured authentication header name, each turn also retains the original upgrade headers while the refreshed sanitizer removes only the new header name, allowing the old custom credential header to reach the default Codex upstream. Reauthenticate each turn, or preserve and use the original authentication snapshot when sanitizing the stored headers.

**How this was verified:** The upgrade is the only authentication call, while later turns clone its headers and dispatch using refreshed state whose sanitizer recognizes only the current configured credential header.

**Knowledge Base Used:**
- [Inbound authentication and caller access](https://app.greptile.com/passionfactory/-/custom-context/knowledge-base/pleaseai/shunt/-/docs/inbound-access-control.md)
- [Restore shared credential-slot stripping](https://app.greptile.com/passionfactory/-/custom-context/knowledge-base/pleaseai/shunt/-/reverts/rollback_391-20260818-credential-slot-forwarding-9fe69f8.md)

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

Fix in Claude Code

// one immutable runtime snapshot while later turns observe newer config.
let state = state.refreshed();
let started_at = Instant::now();
let dispatch_res = forward_turn(state, model, pool_key, headers, body, started_at).await;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Request Limit Bypassed

WebSocket turns bypass server.limits.max_request_bytes. The upgrade accepts messages up to the hard-coded 4 MiB limit and calls forward_turn directly, while the HTTP path applies the configured size checks first. When an operator configures a limit below 4 MiB, an oversized response.create is therefore serialized and forwarded upstream instead of being rejected. Apply the configured limit before dispatching each turn.

Knowledge Base Used: Request processing gateway

Prompt To Fix With AI
This is a comment left during a code review.
Path: src/codex_endpoint/websocket.rs
Line: 215

Comment:
**Request Limit Bypassed**

WebSocket turns bypass `server.limits.max_request_bytes`. The upgrade accepts messages up to the hard-coded 4 MiB limit and calls `forward_turn` directly, while the HTTP path applies the configured size checks first. When an operator configures a limit below 4 MiB, an oversized `response.create` is therefore serialized and forwarded upstream instead of being rejected. Apply the configured limit before dispatching each turn.

**Knowledge Base Used:** [Request processing gateway](https://app.greptile.com/passionfactory/-/custom-context/knowledge-base/pleaseai/shunt/-/docs/request-processing.md)

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

Fix in Claude Code

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

2 issues found across 16 files

Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="src/codex_endpoint/websocket.rs">

<violation number="1" location="src/codex_endpoint/websocket.rs:370">
P1: When the upstream emits a `type: "error"` event, this condition does not stop the relay, so later events can be sent after a terminal backend error. Include `error` in the terminal classification and stop processing it.</violation>
</file>

<file name="src/server.rs">

<violation number="1" location="src/server.rs:265">
P2: When `server.max_concurrent_requests` is configured, this WebSocket route releases its permit immediately after the `101` handshake. Active sockets can therefore bypass the documented cap and create more live turn tasks than the configured limit; hold a WebSocket-specific permit until socket closure.</violation>
</file>
Architecture diagram
sequenceDiagram
    participant CLI as Codex CLI
    participant WS as WebSocket Upgrade Handler
    participant Auth as Inbound Auth
    participant Router as Shared Route Dispatcher
    participant Pool as Account Pool
    participant SSE as SSE Reassembler
    participant Upstream as Upstream Provider

    Note over CLI,Upstream: Inbound Responses WebSocket Transport Flow

    CLI->>WS: GET /responses (WebSocket upgrade)
    WS->>Auth: authenticate_inbound (token check)
    alt Valid token
        Auth-->>WS: Client identity
        WS-->>CLI: 101 Switching Protocols
    else Missing/invalid token
        Auth-->>WS: 401 Unauthorized
        WS-->>CLI: HTTP 401 (no upgrade)
    end

    CLI->>WS: response.create frame (generate: true)
    WS->>WS: Parse frame, strip control flags, force stream: true

    alt generate: false warmup
        WS->>CLI: Local response.created + response.completed frames
    else generate: true
        WS->>Router: forward_turn (same as HTTP POST)
        Router->>Pool: Select account (sticky by session + client)
        Pool-->>Router: Account credentials
        Router->>Upstream: HTTP POST (streaming SSE, swapped auth headers)
        Upstream-->>SSE: SSE events (bounded 4 MiB frames)
        SSE-->>WS: Stream events (backpressure, bounded queue)
        WS-->>CLI: Forward frames until terminal event
    end

    alt Socket disconnect or replacement turn
        WS->>WS: Cancel active upstream body
    else Binary frame
        WS-->>CLI: Error frame, close socket
    end
Loading

Reply with feedback, questions, or to request a fix.

Re-trigger cubic


let _ = out_tx.send((turn_gen, Message::Text(payload.into()))).await;

if terminal_status_from_type(&p_type).is_some() {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1: When the upstream emits a type: "error" event, this condition does not stop the relay, so later events can be sent after a terminal backend error. Include error in the terminal classification and stop processing it.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At src/codex_endpoint/websocket.rs, line 370:

<comment>When the upstream emits a `type: "error"` event, this condition does not stop the relay, so later events can be sent after a terminal backend error. Include `error` in the terminal classification and stop processing it.</comment>

<file context>
@@ -0,0 +1,424 @@
+
+            let _ = out_tx.send((turn_gen, Message::Text(payload.into()))).await;
+
+            if terminal_status_from_type(&p_type).is_some() {
+                terminal_seen = true;
+                break;
</file context>

Comment thread src/server.rs
router = router.route(path, post(codex_endpoint::post));
router = router.route(
path,
get(codex_endpoint::websocket::get).post(codex_endpoint::post),

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: When server.max_concurrent_requests is configured, this WebSocket route releases its permit immediately after the 101 handshake. Active sockets can therefore bypass the documented cap and create more live turn tasks than the configured limit; hold a WebSocket-specific permit until socket closure.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At src/server.rs, line 265:

<comment>When `server.max_concurrent_requests` is configured, this WebSocket route releases its permit immediately after the `101` handshake. Active sockets can therefore bypass the documented cap and create more live turn tasks than the configured limit; hold a WebSocket-specific permit until socket closure.</comment>

<file context>
@@ -260,7 +260,10 @@ pub fn build_router(config: Config) -> Result<(Router, SharedState, AppState), C
-            router = router.route(path, post(codex_endpoint::post));
+            router = router.route(
+                path,
+                get(codex_endpoint::websocket::get).post(codex_endpoint::post),
+            );
         }
</file context>

Comment thread src/codex_endpoint.rs
Comment thread src/codex_endpoint/frame.rs Outdated
Comment thread src/codex_endpoint/websocket.rs Outdated
Comment thread src/codex_endpoint/websocket.rs
Comment thread docs/m11-inbound-codex-endpoint.md Outdated
Comment thread src/codex_endpoint/websocket.rs
Comment thread tests/inbound_codex_websocket.rs

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

All reported issues were addressed across 4 files (changes from recent commits).

Reply with feedback, questions, or to request a fix.

Re-trigger cubic

Comment thread src/codex_endpoint/websocket.rs

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 4fb03a0eae

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread src/codex_endpoint/websocket.rs Outdated
.as_ref()
.map(|auth| auth.header().clone());

ws.on_upgrade(move |socket| handle_socket(socket, state, pool_key, headers, auth_header))

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Reject untrusted browser origins on ungated sockets

When [server.auth] is absent—the documented loopback/personal-use mode—this accepts a WebSocket upgrade from any browser Origin. A malicious page can therefore connect to ws://127.0.0.1:3001/v1/responses, submit response.create frames, read model output, and consume the operator's upstream account without encountering the CORS preflight that protects JSON HTTP requests; validate Origin/Host for ungated upgrades or require authentication for this transport.

Useful? React with 👍 / 👎.

Comment on lines +223 to +224
let Some(codex_endpoint) = state.config.server.codex_endpoint.as_ref() else {
return;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Return an error when reload disables the endpoint

When an existing socket survives a config reload that removes [server.codex_endpoint], every subsequent live response.create reaches this branch and silently returns without sending a terminal error or closing the connection. The client consequently waits indefinitely for a response; enqueue a Responses-shaped error frame (and optionally close the socket) instead of abandoning the turn.

AGENTS.md reference: AGENTS.md:L41-L42

Useful? React with 👍 / 👎.

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

All reported issues were addressed across 4 files (changes from recent commits).

Tip: Review your code locally with the cubic CLI to iterate faster.

Re-trigger cubic

Comment thread src/codex_endpoint/frame.rs

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: b8f6fa8297

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +77 to +80
let auth_header = state
.inbound_auth
.as_ref()
.map(|auth| auth.header().clone());

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Strip the handshake-time admin header after reload

When a socket handshake includes a credential in a custom [server.admin].header and that header name changes during hot reload, later turns clone the original handshake headers but sanitize them using only the refreshed admin header; this snapshot preserves only the old [server.auth] header. The former admin header can therefore be forwarded to the upstream despite the guarantee documented in site/src/content/docs/guides/inbound-codex-endpoint.md:83; snapshot and remove the handshake-time admin header as well.

AGENTS.md reference: AGENTS.md:L47-L49

Useful? React with 👍 / 👎.

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

1 issue found across 2 files (changes from recent commits).

Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="src/codex_endpoint/websocket.rs">

<violation number="1" location="src/codex_endpoint/websocket.rs:583">
P2: When an unauthenticated browser connects through an IPv6 literal without an explicit port, `same_origin_or_non_browser` compares `::1` with `[::1]` and rejects the legitimate same-origin upgrade. Parse bracketed `Host` authorities with an IPv6-aware parser before comparing host and port.</violation>
</file>

Tip: Review your code locally with the cubic CLI to iterate faster.

Re-trigger cubic

return false;
};
let origin_port = origin.port_or_known_default();
let host_without_port = host.rsplit_once(':').map_or(host, |(host, port)| {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: When an unauthenticated browser connects through an IPv6 literal without an explicit port, same_origin_or_non_browser compares ::1 with [::1] and rejects the legitimate same-origin upgrade. Parse bracketed Host authorities with an IPv6-aware parser before comparing host and port.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At src/codex_endpoint/websocket.rs, line 583:

<comment>When an unauthenticated browser connects through an IPv6 literal without an explicit port, `same_origin_or_non_browser` compares `::1` with `[::1]` and rejects the legitimate same-origin upgrade. Parse bracketed `Host` authorities with an IPv6-aware parser before comparing host and port.</comment>

<file context>
@@ -525,3 +559,42 @@ async fn run_turn(context: TurnContext) {
+        return false;
+    };
+    let origin_port = origin.port_or_known_default();
+    let host_without_port = host.rsplit_once(':').map_or(host, |(host, port)| {
+        if port.parse::<u16>().is_ok() {
+            host.trim_matches(['[', ']'])
</file context>

Comment thread src/codex_endpoint/websocket.rs

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: eb8877e563

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +85 to +87
## WebSocket transport

The three Responses paths accept both HTTP `POST` and authenticated WebSocket `GET` upgrades. Authentication completes before `101 Switching Protocols`. On a socket, `generate: false` warmups complete locally; live `response.create` frames reuse the HTTP account pool, force upstream streaming, and forward each SSE `data:` payload as a WebSocket text frame through the first terminal event. Replacing a turn or closing the socket cancels the active upstream body. Client frames and SSE events are limited to 4 MiB, sends apply bounded backpressure, and protocol/upstream failures use standalone `type: "error"` frames with only safe response metadata.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Update the Codex CLI connection guide for WebSockets

The new WebSocket section conflicts with site/src/content/docs/guides/connect-codex-cli.mdx:90-111,284-289, which still says shunt has no WebSocket route, instructs users not to enable supports_websockets, and describes the endpoint as HTTP/SSE-only; the ja/ko/zh-cn copies repeat those instructions, and docs/codex-configuration.md:831 retains the same claim. Update these affected guides and translations so users are not directed away from the transport added here.

AGENTS.md reference: AGENTS.md:L47-L53

Useful? React with 👍 / 👎.

Comment on lines +55 to +57
let Some(codex_endpoint) = &state.config.server.codex_endpoint else {
return ShuntError::bad_gateway("codex endpoint is not configured".to_string())
.into_response();

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Normalize the disabled-endpoint upgrade error

When the endpoint was enabled at boot but a reload removes [server.codex_endpoint], its routes remain registered, so a new WebSocket upgrade reaches this branch and receives the raw Anthropic-shaped ShuntError. This bypasses into_openai_error_shape, unlike the other pre-upgrade failures, and prevents Responses clients from parsing this gateway-owned 502 through their expected error path.

AGENTS.md reference: AGENTS.md:L41-L42

Useful? React with 👍 / 👎.

Comment on lines +405 to +411
if index + 2 >= length {
return None;
}
return if byte_at(index + 2) == b'\n' {
Some(3)
} else {
Some(0)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Recognize mixed LF and bare-CR SSE delimiters

When an upstream terminates the data line with LF and the following blank line with bare CR (...\n\r), this branch accepts the delimiter only if the CR is followed by LF. The fresh \r\r handling therefore still misses this standards-valid mixed ending (and similarly \r\n\r), causing adjacent events to be merged and rejected as invalid JSON with a spurious 502; recognize bare CR as the second line ending without requiring a following LF.

Useful? React with 👍 / 👎.

@yansigit
yansigit force-pushed the codex/inbound-responses-websocket branch from eb8877e to 3440be0 Compare September 11, 2026 05:02

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 3440be027d

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +593 to +599
origin_host.eq_ignore_ascii_case(host_without_port)
&& origin_port
== host_port.or_else(|| match origin.scheme() {
"http" => Some(80),
"https" => Some(443),
_ => None,
})

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Derive the target port independently of Origin

When an ungated endpoint is exposed as wss://example.com with Host: example.com, a page from http://example.com passes this check: origin_port is 80, and the missing Host port is incorrectly defaulted from the Origin scheme to the same value, even though the socket's default port is 443. This fresh evidence shows the origin-validation fix still permits a cross-origin browser socket in that deployment; compare the Origin against the effective request scheme and port rather than using the Origin to supply the target's missing port.

Useful? React with 👍 / 👎.

Comment on lines +87 to +89
## WebSocket transport

The three Responses paths accept both HTTP `POST` and authenticated WebSocket `GET` upgrades. Authentication completes before `101 Switching Protocols`. On a socket, `generate: false` warmups complete locally; live `response.create` frames reuse the HTTP account pool, force upstream streaming, and forward each SSE `data:` payload as a WebSocket text frame through the first terminal event. Replacing a turn or closing the socket cancels the active upstream body. Client frames and SSE events are limited to 4 MiB, sends apply bounded backpressure, and protocol/upstream failures use standalone `type: "error"` frames with only safe response metadata.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Update the endpoint and configuration references

This new WebSocket behavior is absent from the user-facing reference surfaces: site/src/content/docs/reference/endpoints.md:42-44 still lists these paths as POST-only, while site/src/content/docs/reference/configuration.md:291 says the table registers only POST routes; the ja/ko/zh-cn copies retain the same descriptions. Update those reference pages and translations so users consulting the endpoint or configuration reference can discover and correctly understand the newly registered GET upgrades.

AGENTS.md reference: AGENTS.md:L47-L53

Useful? React with 👍 / 👎.

@codspeed

codspeed Bot commented Sep 11, 2026

Copy link
Copy Markdown
Contributor

Merging this PR will not alter performance

✅ 83 untouched benchmarks


Comparing yansigit:codex/inbound-responses-websocket (576dd99) with main (c7bc752)

Open in CodSpeed

@codecov

codecov Bot commented Sep 11, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 80.11753% with 203 lines in your changes missing coverage. Please review.

Files with missing lines Patch % Lines
src/codex_endpoint/websocket.rs 60.45% 172 Missing ⚠️
src/codex_endpoint/frame.rs 95.44% 22 Missing ⚠️
src/codex_endpoint.rs 89.65% 9 Missing ⚠️

📢 Thoughts on this report? Let us know!

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 8c789293d8

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +221 to +225
"response.completed" => Some(TerminalStatus::Completed),
"response.failed" => Some(TerminalStatus::Failed),
"response.incomplete" => Some(TerminalStatus::Incomplete),
"error" => Some(TerminalStatus::Failed),
_ => None,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Recognize response.done as terminal

When a Responses-compatible upstream ends its SSE stream with {"type":"response.done"}, this match returns None, although the existing Responses translator and stream observer both classify response.done as a normal terminal event. run_turn therefore forwards the valid terminal frame and then emits a misleading 502 websocket_protocol_error at EOF; include response.done in this terminal set and cover it with a WebSocket regression test.

AGENTS.md reference: src/AGENTS.md:L29-L30

Useful? React with 👍 / 👎.

@yansigit
yansigit force-pushed the codex/inbound-responses-websocket branch from 8c78929 to 576dd99 Compare September 11, 2026 18:07

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 576dd99e96

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".


let mut ws = match ws {
Ok(ws) => ws,
Err(rejection) => return rejection.into_response(),

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Normalize WebSocket upgrade rejections

When an authenticated client sends a malformed upgrade—for example, an unsupported Sec-WebSocket-Version or missing Connection: upgrade—this returns Axum's raw WebSocketUpgradeRejection response rather than the OpenAI Responses error envelope used by every other gateway-owned failure on these paths. Responses clients therefore cannot parse or surface the handshake error through their normal error path; reshape this rejection before returning it.

AGENTS.md reference: AGENTS.md:L42-L42

Useful? React with 👍 / 👎.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant