Skip to content
Docs

HTTP/3, QUIC, and Multi-Protocol Listener

AgentWatch is a multi-protocol product on every hop. The interesting transport story lives in three places: (1) the endpoint agent’s local TLS listener, (2) the upstream egress path through curl-impersonate-chrome, and (3) the central-server response-rewriting logic that prevents browsers from defecting to QUIC and bypassing the TCP-only MITM. All three are wired together to keep managed traffic on a single observable channel — including under modern HTTP/3-eager clients like Chrome and Edge.

30.1 Local TLS listener (endpoint agent, 127.0.0.1:443)

Section titled “30.1 Local TLS listener (endpoint agent, 127.0.0.1:443)”

Since client-agent v1.0.75+, the local TLS listener is Bun.serve by default (commit 01f9bb7e, client-agent/src/proxy.ts ~line 712).

Protocol matrix:

Protocol Transport Port Status
HTTP/1.1 TCP 443 Always on (ALPN: http/1.1)
HTTP/2 TCP 443 Wired structurally; ALPN negotiation pending Bun upstream fix
HTTP/3 (QUIC) UDP 443 On by default — http3: true in Bun.serve()
WebSocket TCP 443 server.upgrade() path; runs alongside fetch

Bun.serve binds TCP/443 and UDP/443 on the same port in a single listener call, then negotiates the protocol per connection based on ALPN (TCP) or QUIC handshake (UDP). Existing HTTP/1.1 callers (curl smoke tests, WebSocket upgrades) keep working without code changes.

Why HTTP/3 matters here: chatgpt.com and claude.ai web clients fan out 20–60 concurrent requests on page load (token mints, conversation polls, sentinel pings, message streams). Over HTTP/1.1 those queue against the 6-per-origin cap. HTTP/2 multiplexes them on one TCP stream; HTTP/3 does the same over QUIC and avoids head-of-line blocking when packets reorder. Chrome’s default behaviour is to prefer HTTP/3 once it has seen an Alt-Svc advertisement — so the listener has to be ready for QUIC the first time, not after a TCP round-trip.

Why not Node’s http2.createSecureServer: commit 45b75335 documents the issue — under Bun, Node’s http2.createSecureServer({ allowHTTP1: true }) silently refuses the HTTP/1.1 fallback, breaking WebSocket upgrades and curl --http1.1 smoke tests. Bun.serve negotiates ALPN correctly. The structural readiness for HTTP/2 ALPN on Bun.serve is in place; it ships as soon as Bun upstream lands the fix.

Opt-out:

Terminal window
AGENTWATCH_PROXY_BUNSERVE=0 # falls back to https.createServer (HTTP/1.1 only, no QUIC)

The agent also auto-falls-back to https.createServer if launched under Node (no Bun global). The fallback is logged at startup: [Proxy] Listening on 127.0.0.1:<port> (Bun.serve, HTTP/1.1 + HTTP/2 + HTTP/3) vs. https.createServer mode.

Header-size handling: ChatGPT pages can ship 20–40 KB of cookies + JWTs + sentinel tokens per request. Both backends bump the parser cap:

  • https.createServer: maxHeaderSize: 512 * 1024, insecureHTTPParser: true
  • Bun.serve: BUN_HTTP_MAX_HEADER_SIZE=524288 (set at module load, line 29 of proxy.ts)

30.2 Upstream egress (agent → real web origin)

Section titled “30.2 Upstream egress (agent → real web origin)”

The egress path is deliberately HTTP/2-pinned, not HTTP/3. curl-impersonate-chrome is invoked with --http2 (client-agent/src/curl-impersonate.ts line 864). Reasoning:

  • The dominant Chrome network fingerprint that Cloudflare Bot Management trains on is the HTTP/2 SETTINGS + HEADERS frame layout. Matching that fingerprint exactly is what lets /backend-api/me, /backend-api/memories, and /backend-api/conversations clear Cloudflare Managed Challenge.
  • The bundled BoringSSL-backed curl-impersonate-chrome (v1.5.6) doesn’t ship an HTTP/3 fingerprint that’s stable against Cloudflare’s H3 detection — yet. The H2 path is the one with field-tested coverage.
  • If curl-impersonate-chrome is missing or fails its startup smoke test, the agent falls back to Node’s native node:http2. That path is observable (in heartbeat telemetry, the variant counter increments fallback_http2).

30.3 HTTP/3 hardening (response rewriting)

Section titled “30.3 HTTP/3 hardening (response rewriting)”

This is the non-obvious part. Even though the local listener terminates HTTP/3, every hop after that point intentionally strips QUIC-advertisement headers so the browser doesn’t cache an Alt-Svc entry pointing at the real upstream and start routing the next request over UDP — bypassing the AgentWatch Connect proxy entirely.

The following response headers are dropped from upstream responses on every hot path:

alt-svc
alternate-protocol

These joins the standard hop-by-hop set (transfer-encoding, connection, keep-alive). Locations:

File Approx. line What it covers
client-agent/src/proxy.ts ~1331 Streaming + buffered response from upstream to browser
client-agent/src/proxy.ts ~2273 forwardRequest response path (central-server response forwarded to browser)
client-agent/src/proxy.ts ~2357 Corporate-proxy tunneled response path
src/enhanced-index.ts ~3774 /agent/proxy central-server endpoint — strips Alt-Svc from the upstream-fetched body
src/enhanced-index.ts ~3884 Companion central response path
src/enhanced/api-routes.ts ~12089, ~12190 Egress-tunnel respond + identity-policy paths

Without this hardening, Chrome sees an Alt-Svc: h3=":443"; ma=86400 header in any upstream response, caches it, and the next page reload connects directly to the real chatgpt.com over UDP/443 — at which point the AgentWatch Connect TLS-MITM (TCP-only) sees nothing.

Updated in v2.0.45: the central server now also defaults to a Bun.serve listener with the same multi-protocol semantics as the endpoint agent — HTTP/1.1 + HTTP/3 (QUIC) on the same port, HTTP/2 ALPN structurally wired. The 510+ existing Express handlers continue to run unchanged via a nodeShim() that translates Web Request → Node IncomingMessage + ServerResponse (see src/enhanced/bun-server.ts).

Listener selection at boot:

Condition Listener HTTP/3
Bun runtime + AGENTWATCH_TLS_KEY + AGENTWATCH_TLS_CERT set Bun.serve({ http3: true, tls }) ✅
Bun runtime, no TLS env vars Bun.serve({ … }) plain HTTP ❌ (H3 requires TLS)
AGENTWATCH_BUNSERVE=0 (opt-out) http.createServer ❌
Plain Node runtime (no Bun global) http.createServer (auto-fallback) ❌
Bun.serve fails to bind http.createServer (auto-fallback) ❌

The startup log line "Central server listening (bun-serve)" includes http3Enabled: true|false and tlsTermination: true|false so operators can verify which path is active.

Known limitation — WebSocket-upgrade routes: four central-server routes use Node-native raw-socket piping or http-proxy-middleware for WebSocket upgrades:

Route Purpose
/agent/ws-proxy Endpoint-agent WebSocket relay for chatgpt.com / claude.ai real-time chat
/ws Browser ↔ MCP-server (port 4000) WebSocket transport
/sse Browser ↔ MCP-server SSE transport
/mcp/* http-proxy-middleware bridge to MCP server

These routes do not work under Bun.serve because Bun’s server.upgrade() API doesn’t expose raw sockets compatible with http-proxy-middleware or the existing tls.connect()-based pipe. Deployments that rely on these routes must set AGENTWATCH_BUNSERVE=0 to fall back to http.createServer. The startup log emits a startup-bunserve-websocket-warning event listing the affected routes.

Why most production deployments still benefit from this even with the WebSocket caveat:

  • Tier-2 enterprise deployments behind HAProxy / ALB / NGINX terminate TLS at the LB anyway — HTTP/3 at the LB → app-tier HTTP/1.1 hop works fine
  • For single-VM and air-gapped deployments where the central server terminates TLS directly, Bun.serve enables end-to-end HTTP/3 for browser admin-UI traffic
  • The fallback to http.createServer is automatic and zero-config when the runtime is plain Node (e.g., when running the compiled binary that doesn’t bundle Bun)

Streaming behaviour stays consistent across protocols:

  • SSE for /v1/chat/completions streaming works identically over HTTP/1.1, HTTP/2, and HTTP/3 — the nodeShim resolves its Response on the first res.writeHead() and pipes subsequent res.write() calls through a ReadableStream so SSE frames flush in real time
  • TTFT measurement is captured before the first SSE event, regardless of protocol
  • Token counting on streams is protocol-agnostic
  • WebSocket for /agent/ws-proxy / /ws / /sse / /mcp/* requires the http.createServer fallback (AGENTWATCH_BUNSERVE=0)
  • Browser users on Chrome/Edge/Firefox get the best protocol their browser supports against the local agent (typically HTTP/2 on TCP today, HTTP/3 on QUIC the moment Bun lands ALPN-H2 — same code path either way)
  • API clients (OpenAI SDK, Anthropic SDK, curl, Postman) hit the central server on HTTP/1.1 or HTTP/2 over TCP — no QUIC dependencies anywhere in the SDK fleet
  • Network operators can keep their UDP/443 egress policy as-is: the agent’s outbound traffic to the real web is HTTP/2 over TCP/443 (curl-impersonate-chrome), not QUIC
  • DLP scanning, audit logging, and budget tracking see every request regardless of which protocol the browser used — because all three protocols funnel into the same handleFetch shim
  • No environment variable is needed to “enable HTTP/3” — it’s on by default. The only toggle is AGENTWATCH_PROXY_BUNSERVE=0 to disable the whole multi-protocol listener and pin to legacy HTTP/1.1.

The HTTP/3 stack depends on Bun’s bundled usockets/uSockets-quic backend. The repo pins Bun at 1.4.0. Earlier 1.3.x versions had an HTTP/2 client regression that broke the curl-impersonate fallback path, and every Bun up to and including 1.3.14 failed to surface client disconnect on a node:http server response — which made it impossible to cancel an upstream LLM stream when the caller hung up. 1.4.0 fixes that (and lowers HTTP-server memory 13-48%). The floor is declared once in build.sh as BUN_MIN_VERSION, enforced against the Dockerfile pins by assert_bun_pins_match_floor at build preflight, and mirrored in the client-agent build pipeline.