Summary
When two coding agents run simultaneously (e.g. OpenCode + Codex CLI, both routing through Cortex), CONNECT tunnel-open events from one agent's session appear in the other agent's session timeline in abctl observe. The mis-attribution only affects the opaque tunnel-open row; TLS-bridged (decrypted) inner requests are attributed correctly.
Sample
The following abctl session timeline shows an OpenCode session (rows 383, 386) interleaved with what appears to be unrelated traffic (rows 384, 385). Rows 383 and 386 are inference calls to a local LiteLLM endpoint. Row 384 is a firmware update CDN tunnel. Row 385 — a chatgpt.com GET — is a model-discovery request from a concurrently running Codex CLI process, but it was recorded under this OpenCode session ID.
383 16:06:18.47 out req observe rits/zai-org/glm-… 213,792 litellm.cb.ete.res.…
384 16:06:46.02 out req tunnel cdn.fwupd.org:443
383 16:08:00.86 out resp observe rits/zai-org/glm-… 200 102.45s 7,175 litellm.cb.ete.res.…
385 16:10:18.87 out req — chatgpt.com
385 16:10:19.35 out resp — 200 481ms chatgpt.com
386 16:10:33.66 out req observe rits/zai-org/glm-… 221,075 litellm.cb.ete.res.…
386 16:11:03.15 out resp observe rits/zai-org/glm-… 200 29.57s 1,963 litellm.cb.ete.res.…
The JSON event logged by Cortex confirms the session ID:
{
"at": "2026-09-29T14:49:07.990883-04:00",
"client": {
"raw": "codex-tui/0.159.0 (Mac OS 15.7.7; arm64) Apple_Terminal/455.1 (codex-tui; 0.158.0)"
},
"direction": "outbound",
"host": "chatgpt.com",
"httpMethod": "GET",
"httpPath": "/backend-api/codex/models",
"invocations": {
"outbound": [
{
"action": "skip",
"path": "/backend-api/codex/models",
"phase": "request",
"plugin": "tool-prune",
"reason": "path_not_inference"
}
]
},
"phase": "request",
"requestId": "3ob-388c1a",
"seq": 650,
"sessionId": "ses_f122e67c3ffeJGjewfuKatYTyd"
}
The client.raw field identifies this as Codex CLI (codex-tui/0.159.0), but sessionId is the OpenCode session. The two processes were running simultaneously.
Root Cause
recordTunnelOpened in core/listener/forwardproxy/transparent.go:194 always resolves the session by calling s.Sessions.ActiveSession() — the globally most-recently-updated session — rather than using the session ID that handleConnect already resolved correctly from the CONNECT request headers:
// transparent.go:190
func (s *Server) recordTunnelOpened(pctx *pipeline.Context, reason pipeline.TunnelReason) {
if s.Sessions == nil {
return
}
sid := s.Sessions.ActiveSession() // ← always the global "last writer wins" ID
if sid == "" {
sid = session.DefaultSessionID
}
// ...
s.Sessions.Append(sid, ev)
}
The call chain is:
handleConnect (server.go:1297) receives the CONNECT request.
resolvePluginSessionID(r.Header) (server.go:1348) correctly reads X-Claude-Code-Session-Id → pins a local sessionID.
tunnelRecorderFor(pctx, skipped) (server.go:1860) returns a closure.
- That closure calls
s.recordTunnelOpened(pctx, reason).
recordTunnelOpened ignores pctx for session resolution and calls ActiveSession() instead.
The race: if OpenCode's inference response landed last, ActiveSession() returns OpenCode's ID at the moment Codex's CONNECT tunnel opens → Codex's chatgpt.com event is appended to OpenCode's session.
This is distinct from the fix in #984, which corrected session attribution for HTTP requests through resolvePluginSessionID / serveOutbound. The tunnel-open recording path was not updated at the same time.
Note that TLS-bridged inner requests are not affected: bridgeServe → serveOutbound re-reads the session header from the decrypted inner request (server.go:764), so decrypted rows are attributed correctly. Only the opaque CONNECT tunnel-open event itself is mis-attributed.
Suggested Fix
Pass the pinned sessionID from handleConnect through to recordTunnelOpened instead of re-calling ActiveSession(). The session ID is already available on pctx at the point tunnelRecorderFor is called (line 1425), so one approach is to store it on pctx before handing the context to tunnelRecorderFor, then read it back in recordTunnelOpened:
// recordTunnelOpened: use pctx's session ID instead of ActiveSession()
sid := pctx.OutboundSessionID // already set by handleConnect at server.go:1349
if sid == "" {
sid = s.Sessions.ActiveSession() // keep as fallback for transparent-proxy path
}
if sid == "" {
sid = session.DefaultSessionID
}
The transparent-proxy path (HandleTransparentConn) passes nil headers and has no CONNECT request to read from, so ActiveSession() remains the correct fallback there.
Environment
- OS: Darwin arm64
- Agents running concurrently: OpenCode (LiteLLM endpoint) + Codex CLI 0.159.0
- Affected path: opaque CONNECT tunnel-open events only (non-bridged traffic)
Summary
When two coding agents run simultaneously (e.g. OpenCode + Codex CLI, both routing through Cortex), CONNECT tunnel-open events from one agent's session appear in the other agent's session timeline in
abctl observe. The mis-attribution only affects the opaque tunnel-open row; TLS-bridged (decrypted) inner requests are attributed correctly.Sample
The following
abctlsession timeline shows an OpenCode session (rows 383, 386) interleaved with what appears to be unrelated traffic (rows 384, 385). Rows 383 and 386 are inference calls to a local LiteLLM endpoint. Row 384 is a firmware update CDN tunnel. Row 385 — achatgpt.comGET — is a model-discovery request from a concurrently running Codex CLI process, but it was recorded under this OpenCode session ID.The JSON event logged by Cortex confirms the session ID:
{ "at": "2026-09-29T14:49:07.990883-04:00", "client": { "raw": "codex-tui/0.159.0 (Mac OS 15.7.7; arm64) Apple_Terminal/455.1 (codex-tui; 0.158.0)" }, "direction": "outbound", "host": "chatgpt.com", "httpMethod": "GET", "httpPath": "/backend-api/codex/models", "invocations": { "outbound": [ { "action": "skip", "path": "/backend-api/codex/models", "phase": "request", "plugin": "tool-prune", "reason": "path_not_inference" } ] }, "phase": "request", "requestId": "3ob-388c1a", "seq": 650, "sessionId": "ses_f122e67c3ffeJGjewfuKatYTyd" }The
client.rawfield identifies this as Codex CLI (codex-tui/0.159.0), butsessionIdis the OpenCode session. The two processes were running simultaneously.Root Cause
recordTunnelOpenedincore/listener/forwardproxy/transparent.go:194always resolves the session by callings.Sessions.ActiveSession()— the globally most-recently-updated session — rather than using the session ID thathandleConnectalready resolved correctly from the CONNECT request headers:The call chain is:
handleConnect(server.go:1297) receives the CONNECT request.resolvePluginSessionID(r.Header)(server.go:1348) correctly readsX-Claude-Code-Session-Id→ pins a localsessionID.tunnelRecorderFor(pctx, skipped)(server.go:1860) returns a closure.s.recordTunnelOpened(pctx, reason).recordTunnelOpenedignorespctxfor session resolution and callsActiveSession()instead.The race: if OpenCode's inference response landed last,
ActiveSession()returns OpenCode's ID at the moment Codex's CONNECT tunnel opens → Codex's chatgpt.com event is appended to OpenCode's session.This is distinct from the fix in #984, which corrected session attribution for HTTP requests through
resolvePluginSessionID/serveOutbound. The tunnel-open recording path was not updated at the same time.Note that TLS-bridged inner requests are not affected:
bridgeServe→serveOutboundre-reads the session header from the decrypted inner request (server.go:764), so decrypted rows are attributed correctly. Only the opaque CONNECT tunnel-open event itself is mis-attributed.Suggested Fix
Pass the pinned
sessionIDfromhandleConnectthrough torecordTunnelOpenedinstead of re-callingActiveSession(). The session ID is already available onpctxat the pointtunnelRecorderForis called (line 1425), so one approach is to store it onpctxbefore handing the context totunnelRecorderFor, then read it back inrecordTunnelOpened:The transparent-proxy path (
HandleTransparentConn) passesnilheaders and has no CONNECT request to read from, soActiveSession()remains the correct fallback there.Environment