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:
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: trueBun.serve:BUN_HTTP_MAX_HEADER_SIZE=524288(set at module load, line 29 ofproxy.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/conversationsclear 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-chromeis missing or fails its startup smoke test, the agent falls back to Node’s nativenode:http2. That path is observable (in heartbeat telemetry, the variant counter incrementsfallback_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-svcalternate-protocolThese 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.
30.4 Central server LLM API (8787)
Section titled “30.4 Central server LLM API (8787)”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.createServeris 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/completionsstreaming works identically over HTTP/1.1, HTTP/2, and HTTP/3 — thenodeShimresolves itsResponseon the firstres.writeHead()and pipes subsequentres.write()calls through aReadableStreamso 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)
30.5 What this means operationally
Section titled “30.5 What this means operationally”- 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
handleFetchshim - No environment variable is needed to “enable HTTP/3” — it’s on by default. The only toggle is
AGENTWATCH_PROXY_BUNSERVE=0to disable the whole multi-protocol listener and pin to legacy HTTP/1.1.
30.6 Bun runtime pinning
Section titled “30.6 Bun runtime pinning”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.