Skip to content

Fix: CONNECT tunnel-open events mis-attributed to wrong session when multiple agents run concurrently #1187

Description

@msteinder

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:

  1. handleConnect (server.go:1297) receives the CONNECT request.
  2. resolvePluginSessionID(r.Header) (server.go:1348) correctly reads X-Claude-Code-Session-Id → pins a local sessionID.
  3. tunnelRecorderFor(pctx, skipped) (server.go:1860) returns a closure.
  4. That closure calls s.recordTunnelOpened(pctx, reason).
  5. 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)

Activity

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

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions