Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 12 additions & 6 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -604,16 +604,22 @@ Every event on `/v1/sessions/{id}` and `/v1/events` carries:
`api.openai.com`; other hosts keep the strip), because the origin
would only answer the stripped GET with a 405, which Codex retries for ~6s, while a
426 sends it straight to HTTPS. Nothing is forwarded, the Finisher sees an allow with
status 426, and `/v1/usage` does not count it as an error. On by default;
`listener.decline_responses_websockets: false` forwards the upgrade instead.
status 426, and `/v1/usage` does not count it as an error. It is what
`listener.responses_websockets: decline` does to every such socket, and what the
default, `parse`, does to one it cannot carry (not bridged, or a `WritesResponseBody`
or `WritesDestination` plugin in the chain; an HTTP/2 client is refused 400 by Go's
HTTP/2 server before the handler runs); a carried socket records instead a `GET`
open and close with `tunnelReason: "websocket"` and one ordinary `POST`
request/response pair per turn on it (`core/listener/forwardproxy/websocket.go`).
`forward` restores the stripped GET and the 405.
**A 403 with `error.kind: "destination_private"` is the egress guard's
answer, not the upstream's** (`listener.egress_guard`, off by default): the
destination resolved into loopback, link-local, private or this host's own
address space and no permit covered it. The Finisher sees an allow with
status 403, like the 426, and `/v1/usage` does not count it as an error.
**A request whose body could not be buffered is the other exception:** it never
reached the pipeline, so its request row carries no
parse and no Finisher runs. Over the forward proxy's `maxRequestBodySize` (32 MiB)
**A request whose body could not be
buffered is the other exception:** it never reached the pipeline, so its request row
carries no parse and no Finisher runs. Over the forward proxy's `maxRequestBodySize` (32 MiB)
it is a 413 `proxy_error` with an empty `error.code`; a client that hung up
mid-upload gets a 499 `client_canceled`, which here does log a WARN (`request body
unreadable`); anything else unreadable is a 400 `request_unreadable`. Before these
Expand Down Expand Up @@ -641,7 +647,7 @@ Every event on `/v1/sessions/{id}` and `/v1/events` carries:
- `requestedHost` — present only when a plugin redirected the request (`pctx.Redirect`): the host the client asked for. `host` is where the request actually went, and usage, the cost ledger and pricing all follow `host`. agentop's detail pane shows both on a `redirected:` line.
- `inference.requestedModel` — present only when a plugin changed the model (`pctx.SetRequestModel`): the model the client asked for. `inference.model` is the model the request was sent for, which settlement prices; agentop's detail pane shows both on a `model:` line.
- `bytesUp`, `bytesDown` — **body** bytes, one direction per row: a request row carries `bytesUp`, the body **as forwarded** (so a `tool-prune`-style rewrite is reflected, since the count is taken after the pipeline ran), and a response row carries `bytesDown`, the body **as it came back from the destination** — not as delivered, because extproc records the row before it emits a response mutator's replacement. Headers are not counted. A tunnel close is the one row carrying both. **Zero means the listener counted nothing, not that the body was empty**, and there is no third value for "unknown": every listener buffers bodies only when a plugin asks, so an unbuffered request reports zero. A response relayed straight through is counted, not zero — both proxies record the row after the copy (#1251's fix), so the bytes they relayed through their own process are on it. The one arm that still reports ↓0 is extproc with a body-free pipeline: it asked Envoy for body mode NONE, so the bytes it never saw are bytes it asked not to see — `core/listener/parity`'s `TestDivergence_InboundResponseBodyCountWhenNoPluginReadsIt` pins that disagreement with its reason. agentop renders a zero side blank rather than as `0B`, and that is why its BYTES column can be trusted as a size when it shows one. Everywhere else the three listeners count the same thing for the same response, which took some doing: extproc sums the chunks Envoy hands it, while the proxies count off the upstream body at one choke point each, so every arm — buffered, block-by-block SSE, or chunk copy — reports the same figure. `core/listener/internal/bodycount` and `core/listener/parity`'s bytes fixtures hold that line.
- `tunnel`, `tunnelReason` — an opaque CONNECT (or transparent-redirect) tunnel records two rows sharing a `requestId`: the open (`phase: "request"`, `tunnelReason` saying why the bytes stayed opaque) and, when the tunnel ends, the close (`phase: "response"`). The close carries the CONNECT's own `statusCode` — 200, or 502 with the dial error in `error` when the destination could not be reached (`tunnelReason: "dial-failed"`) — plus `durationMs` for how long the tunnel stayed open and, in `bytesUp`/`bytesDown`, the bytes it carried each way (up = client to destination; opaque bytes have no body/header split, so these are everything that crossed). It is not the destination's status: that travels inside the client's end-to-end TLS. A bridged tunnel's open is recorded with its first decrypted request — in that request's session, directly before it, stamped with its time — and records no close, because the request carries its own response; a bridged tunnel that recorded no request gets its open and a close when it ends. Tunnel rows are kept out of `/v1/usage`: a tunnel's lifetime is not a request latency. `tunnelReason: "dest-private"` with a 403 close and `error.kind: "destination_private"` is the egress guard refusing the destination before any dial — never confused with `dial-failed`, which attempted.
- `tunnel`, `tunnelReason` — an opaque CONNECT (or transparent-redirect) tunnel records two rows sharing a `requestId`: the open (`phase: "request"`, `tunnelReason` saying why the bytes stayed opaque) and, when the tunnel ends, the close (`phase: "response"`). The close carries the CONNECT's own `statusCode` — 200, or 502 with the dial error in `error` when the destination could not be reached (`tunnelReason: "dial-failed"`) — plus `durationMs` for how long the tunnel stayed open and, in `bytesUp`/`bytesDown`, the bytes it carried each way (up = client to destination; opaque bytes have no body/header split, so these are everything that crossed). It is not the destination's status: that travels inside the client's end-to-end TLS. A bridged tunnel's open is recorded with its first decrypted request — in that request's session, directly before it, stamped with its time — and records no close, because the request carries its own response; a bridged tunnel that recorded no request gets its open and a close when it ends. Tunnel rows are kept out of `/v1/usage`: a tunnel's lifetime is not a request latency. **`tunnelReason: "websocket"` is the one that is not opaque:** a Responses API WebSocket the forward proxy carries (`listener.responses_websockets`, `core/listener/forwardproxy/websocket.go`). Its open is the handshake `GET`, its close is status 101 with the whole socket's duration and bytes, and each turn on it records its own ordinary `POST` request/response pair between them, parsed and priced like an HTTPS turn. A turn runs on the pipeline it began on, so a hot reload never splits one. `tunnelReason: "dest-private"` with a 403 close and `error.kind: "destination_private"` is the egress guard refusing the destination before any dial — never confused with `dial-failed`, which attempted.
- `dialAddr` — the address the egress guard checked and the proxy dialed, `"ip:port"`, on a tunnel's open row when `listener.egress_guard` is on. With `host` it is the audit trail against DNS rebinding: under the guard the proxy resolves once, checks that answer, and dials exactly it, so the two fields together say "asked for the name, reached this address". Absent when the guard is off and on rows that dialed nothing.
- `httpMethod`, `httpPath` — the HTTP verb and path, so a request no parser recognized is still identifiable rather than showing only a host. Distinct from the `method` inside `a2a` / `mcp`, which is a protocol method name. On an opaque tunnel `httpMethod` is `CONNECT` and `httpPath` is absent — opaque bytes carry no request line. The path is query-stripped and percent-decoded, so query-borne credentials never reach the timeline, but a secret in a path *segment* (a bot token, a webhook path) does survive on this unauthenticated surface — worth knowing before exporting events off-box.
- `subagent` — present on rows from a Claude Code subagent or agent-team teammate: the `X-Claude-Code-Agent-Id` the request carried. For a subagent that is also the `<id>` in `~/.claude/projects/<dir>/<session>/subagents/agent-<id>.meta.json`; for a teammate the join is unverified. Resolved by `Context.Subagent`, pinned with the User-Agent by `ResolveClient` so no plugin can re-file a row, and **refused rather than sanitised** when unusable (control runes, over 128 bytes), because it is a join key and a cleaned-up id joins nothing. It does not move the row: a subagent's requests stay in their parent's session, so the header must not be added to `session.id_headers`. **It names an agent, it does not classify the row:** `inference.agentRole` stays the field that says whether a row is a subagent's, and a teammate's row is `agentRole: main` with `subagent` set — Claude Code writes `cc_is_subagent=true` only for `agentType: subagent`.
Expand Down
2 changes: 1 addition & 1 deletion cmd/agentop/tui/detail_pane.go
Original file line number Diff line number Diff line change
Expand Up @@ -150,7 +150,7 @@ func redirectHeader(e *pipeline.SessionEvent) string {
// user blames Cortex. Cortex must not forward Upgrade (RFC 9110 makes it hop-by-hop),
// so the request either reaches the origin as a bare GET — which an endpoint wanting
// a POST or an upgrade answers 405 — or, on a Responses API path, is answered 426 by
// the proxy itself (listener.decline_responses_websockets). Codex falls back to
// the proxy itself (listener.responses_websockets). Codex falls back to
// HTTPS after either.
//
// The wording holds for both because this line is drawn from the REQUEST row, which
Expand Down
8 changes: 4 additions & 4 deletions cmd/cortex/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -1016,10 +1016,10 @@ func main() {
fpSrv.SkipHosts = skipHosts
fpSrv.TLSBridge = bridge
fpSrv.Shared = sharedStore
// A Responses API WebSocket is answered 426 so Codex falls back to HTTPS at once,
// rather than after ~6s of retrying the origin's 405. On unless
// listener.decline_responses_websockets: false.
fpSrv.DeclineResponsesWebSockets = cfg.Listener.DeclinesResponsesWebSockets()
// A Responses API WebSocket — Codex's transport — is carried and parsed turn by
// turn by default; listener.responses_websockets: decline answers it 426 so Codex
// uses HTTPS at once, and forward restores the stripped GET.
fpSrv.ResponsesWebSockets = cfg.Listener.ResponsesWebSocketsMode()
// Per-session bucketing. Without it every coding-agent session on the
// machine records into one shared bucket: two Claude Code windows
// interleave and per-session cost cannot be computed at all. Defaults to
Expand Down
64 changes: 43 additions & 21 deletions core/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -935,15 +935,18 @@ type ListenerConfig struct {
// in #496.
SkipHosts []string `yaml:"skip_hosts" json:"skip_hosts"`

// DeclineResponsesWebSockets: whether the forward proxy answers a WebSocket
// upgrade on an OpenAI Responses API path (one ending /responses, on chatgpt.com
// or api.openai.com) with 426 itself, instead of forwarding the request with its Upgrade stripped. nil means
// unset, and the default is on; explicit false restores the forwarding. See
// DeclinesResponsesWebSockets for why on is the default.
//
// A pointer for the reason SessionConfig.Enabled is one: with a plain bool,
// "unset" and "false" are the same value, and the default could not be on.
DeclineResponsesWebSockets *bool `yaml:"decline_responses_websockets,omitempty" json:"decline_responses_websockets,omitempty"`
// ResponsesWebSockets is what the forward proxy does with a WebSocket upgrade on
// an OpenAI Responses API path (a GET with Upgrade: websocket on a path ending
// /responses — Codex's transport): one of the ResponsesWebSockets* values below.
// Empty means the default, ResponsesWebSocketsParse. See
// ResponsesWebSocketsMode.
ResponsesWebSockets string `yaml:"responses_websockets,omitempty" json:"responses_websockets,omitempty"`

// LegacyDeclineResponsesWebSockets is the boolean responses_websockets replaced,
// read only so a config that still sets it is refused with the new key named. It
// merged in #1462 and never shipped in a release, but someone who set it to false
// to forward the upgrade would otherwise silently get parse. Never written.
LegacyDeclineResponsesWebSockets *bool `yaml:"decline_responses_websockets,omitempty" json:"-"`

// EgressGuard arms the SSRF dial guard on the forward proxy's every
// outbound dial (#1484): each destination is resolved once, refused with
Expand All @@ -970,7 +973,7 @@ type ListenerConfig struct {
// protect.
//
// A pointer so "absent" (off) stays distinguishable from an explicit
// enabled: false, mirroring DeclineResponsesWebSockets. Listener.* field:
// enabled: false, as SessionConfig.Enabled is. Listener.* field:
// changing it requires a restart, like every other listener knob.
EgressGuard *EgressGuardConfig `yaml:"egress_guard,omitempty" json:"egress_guard,omitempty"`
}
Expand All @@ -989,18 +992,37 @@ type EgressGuardConfig struct {
PermitCIDRs []string `yaml:"permit_cidrs,omitempty" json:"permit_cidrs,omitempty"`
}

// DeclinesResponsesWebSockets reports whether the forward proxy answers a WebSocket
// upgrade on a Responses API path with 426 rather than forwarding it.
// Valid ListenerConfig.ResponsesWebSockets values.
const (
// ResponsesWebSocketsParse carries the WebSocket and parses every turn on it the
// way an HTTPS Responses request is parsed. Where the proxy cannot do that — the
// request was not decrypted, or a plugin rewrites response bodies or redirects
// requests — it falls back to ResponsesWebSocketsDecline for that socket.
ResponsesWebSocketsParse = "parse"
// ResponsesWebSocketsDecline answers the upgrade with 426 Upgrade Required and
// contacts no origin; Codex reads that as "use HTTPS" and switches at once.
ResponsesWebSocketsDecline = "decline"
// ResponsesWebSocketsForward forwards the upgrade with Upgrade stripped, as
// every WebSocket was before: the origin sees a bare GET and answers 405, which
// Codex retries for about six seconds before falling back to HTTPS.
ResponsesWebSocketsForward = "forward"
)

// ResponsesWebSocketsMode is ListenerConfig.ResponsesWebSockets with the default
// applied: parse, in every deployment.
//
// On unless set false, in every deployment. The proxy must strip Upgrade (RFC 9110
// makes it hop-by-hop), so a forwarded upgrade reaches the origin as a bare GET, and
// OpenAI's Responses endpoint answers that 405. Codex reads a 405 as transient and
// retries for about six seconds before falling back to HTTPS; it reads a 426 as "use
// HTTP" and falls back at once. The forwarded request can never become a WebSocket, so
// nothing is lost by answering it here. False is the escape hatch for a client that
// reads 426 some other way.
func (l ListenerConfig) DeclinesResponsesWebSockets() bool {
return l.DeclineResponsesWebSockets == nil || *l.DeclineResponsesWebSockets
// Parse is the default because it is the transport Codex asks for and the one it
// performs best on: one socket for a whole session, with later turns sending only new
// input instead of the whole conversation. Every turn on it is still parsed — model,
// tokens, cost, tool calls — so carrying it costs Cortex no visibility, and where it
// cannot be carried the socket is declined rather than broken. Decline and forward are
// the escape hatches: decline keeps Codex on HTTPS without delay, and forward is the
// behaviour before either existed.
func (l ListenerConfig) ResponsesWebSocketsMode() string {
if l.ResponsesWebSockets == "" {
return ResponsesWebSocketsParse
}
return l.ResponsesWebSockets
}

// Proxy roles selectable via ListenerConfig.Roles in proxy-sidecar mode.
Expand Down
Loading