Repository navigation
Spec: Hairpin gateway — run the Praxis filter pipeline on all laptop traffic, with no upstream changes #1491
Description
Spec: Hairpin gateway — run the Praxis filter pipeline on all laptop traffic, with no upstream changes
Summary
Run the Praxis filter pipeline on all laptop traffic — inference, MCP, A2A and general web egress — without waiting on any upstream Praxis capability. Cortex stays the outermost proxy (CONNECT, TLS bridging, process attribution, sessions, cost, events) and hairpins every bridged request through a local, stock Praxis gateway whose filters run the checks and modifications; a small Cortex-side egress listener performs the actual dial to the real destination. The destination travels in a header Cortex injects, so Praxis needs configuration only: no custom filter and no Praxis change.
This is the companion to #1437. That issue concluded the laptop data plane cannot be replaced by Praxis today: three upstream blockers, and no upstream movement since 2026-08-22 (no #781 proposal filed among the 40 in praxis-proxy/enhancements; #782 still has zero comments). The hairpin is the interim shape that delivers the plugin pipeline value now — the thing the pinned-inference-server composition could not, because a pinned server only sees the sessions routed to it.
Zero upstream dependency. Everything built on this survives the clean shape (destination-from-request forwarding, if it ever lands): only the hairpin plumbing itself — the destination header, the egress listener and the extra traversal — is throwaway.
Grounded against cortex main @ dc69b1a5 and praxis-proxy/praxis as of 2026-10-22. Revised 2026-10-24 after a code check against cortex main @ e455a044, praxis main @ 362ade6 and praxis-proxy/ai @ f60e31c: see Revision 2026-10-24 below.
Background: pinned vs chained vs hairpin
| Shape | Meaning | Status |
|---|---|---|
| Pinned | Per-session redirect to a named server; the server is the destination and dials by its own config | Exists today (agentop server, PRs #1340/#1360). Sees only pinned sessions' traffic |
| Chained (clean shape) | All traffic forwarded through a gateway with the original destination preserved; the gateway dials it | Blocked: Praxis routes configured routes → clusters → endpoints only. No destination-from-request routing (verified in docs/architecture/upstream-binding.md — bindings resolve through a catalog of declared clusters — and by runtime probe, 2026-10-17) |
| Hairpin (this spec) | Chained semantics without the upstream feature: the destination rides in a request header (x-cortex-dest), Praxis's stock catch-all route sends everything to one cluster, and the dial stays in Cortex |
Buildable now |
Design
agent ──► Cortex forward proxy (:47600)
CONNECT, TLS bridge (minted certs), attribution,
session resolution, outbound pipeline, recording
│ transport hop (listener-level, not a redirect):
│ dial 127.0.0.1:<praxis>; path and Host unchanged;
│ inject x-cortex-dest: <scheme>://<host>:<port>, x-cortex-token
▼
Praxis gateway (:<praxis>), stock binary
classification → checks/modifications (filters see the real path and Host)
catch-all route "/" → cluster "cortex-egress"
│
▼
Cortex egress listener (:<egress>)
validates token, reads x-cortex-dest and strips both headers,
dials the real destination (no-proxy client), streaming relay
│
▼
real destination
response: destination → egress listener → praxis response filters
→ forward proxy bridge client → agent
Chaining rule (ordered — first match wins)
- A pinned/redirected session: the pinned target is itself the hairpin destination (
x-cortex-destnames the pin's server, not the host the client named) (see Pins compose by chaining below) — pinned traffic also traverses the gateway's checks. - Bridged (decrypted) HTTP → chain to the gateway.
- Opaque tunnels (passthrough hosts, CA-refusing programs) → stay direct from Cortex. No pipeline anywhere can see them.
listener.skip_hostsmatches → bypass everything, direct.- Cortex's own plugin outbound (Keycloak, JWKS) →
net/httpdirect, never chained (already true today).
The chain step is listener-level, after the pipeline — not a plugin. Under on_error: observe, plugin redirects are dropped; and the composition with the inference-router's own redirect (pin-wins) is a listener concern.
Request mechanics
- The hop is not a redirect. It must not go through
pctx.Redirect. After a redirect,pctx.Hostis where the request went, and the recorded event, settlement, the cost ledger,/v1/usage, the session'sinferenceHostand the inference-router's continuation history all key on it (redirect applied atcore/listener/forwardproxy/server.go:452-462@e455a044; ledgerledger/writer.go:445, usageusage/usage.go:809, settlementsettle/settle.go:526-528). Hairpinning throughpctx.Redirectwould record every request against127.0.0.1: host-scoped rates stop matching (OpenCode Zen's free models, the*.res.ibm.commultiplier, user host rules),/v1/usageby endpoint collapses to one row, and agentop'sSERVERcolumn misreads. Most bundled rates areHost: "*", so most models would still price.pctx.Redirectis also plugin-only: listener code cannot call it (pipeline.go:129,context.go:578). - The hop is a dial-only rewrite. After the pipeline and before
client.Do(server.go:532), set onlyr.URL.Scheme = "http"andr.URL.Host = "127.0.0.1:<praxis>", and leaver.Host,pctx.Hostandpctx.Pathalone. Every recording site then keeps the real destination (the pipeline's redirect target if a plugin set one, else the client's host). Plugins still cannot steer the dial, because the hop never reads anything a plugin writes. Skip it for requestsskip_hostsmatched. - Host stays the real destination (BL1), so praxis-side filters and policies see true host context. Praxis forwards
Hostunchanged unless the cluster setshttp.authority(crates/core/src/config/cluster.rs:89-126). - The path is not touched, so it reaches Praxis and the destination byte for byte. praxis-ai's filters classify by path (
/v1/messages), so they work unmodified. Praxis forwardsx-cortex-*headers unchanged; onlyx-praxis-,x-ext-protocol-andx-ext-agent-prefixes are refused (crates/core/src/reserved_headers.rs:44). It addsVia: 1.1 praxisto the request and the response;x-forwarded-*comes only from the opt-inforwarded_headersfilter. - The egress listener validates the shared-secret token, reads
x-cortex-dest, strips both headers, and dials with the forward proxy's own transport, which has noProxyfield and never reads proxy env.
Praxis configuration (stock binary)
| Setting | Default | Why |
|---|---|---|
route path_prefix: "/" → cluster cortex-egress, endpoint 127.0.0.1:<egress> |
— | one catch-all route |
insecure_options.allow_private_endpoints: true |
loopback refused at config load (crates/core/src/config/validate/cluster/endpoints.rs:140-175) |
the egress listener is on loopback; Praxis logs a WARN at startup. (allow_private_upstreams is a different key: it covers DNS-resolved names only.) |
body_limits.max_request_bytes / max_response_bytes |
10 MiB each way, enforced even when no filter buffers (body_limits.rs:13, filter/src/pipeline/mod.rs:861-871) |
over the limit a request gets a 413 and a response is cut mid-stream; long Claude Code requests and long SSE responses can exceed it. Ceiling 64 MiB; null needs insecure_options.allow_unbounded_body |
cluster read_timeout_ms |
none (cluster.rs:457) |
nothing to change; if one is ever set it must exceed the longest silence mid-SSE |
Already fine by default: no cluster or total-request timeout, so a 15-minute SSE with long silences survives (Pingora's 60 s downstream read timeout covers the request's header and body only); responses stream chunk by chunk; Accept-Encoding is not modified and nothing is recompressed (compression is opt-in); filters can add response headers and reject with a custom status; chunked requests and Expect: 100-continue work; downstream h2c is accepted.
Boundary contract (information flow)
| Direction | Channel | Content |
|---|---|---|
| Push, per-request | Headers, strip-then-inject | x-cortex-session, x-cortex-program (attribution — the one fact praxis cannot derive), agent name |
| Pull, on demand | Cortex's existing APIs (/v1/sessions summaries, /v1/usage) via callout, cached per session |
Session aggregates: tokens, cost-so-far, budget state. Summaries, not events (privacy: full payloads carry raw user messages) |
| Push back, per-response | Response headers (x-praxis-decision: …) recorded into the event's plugins map |
What each check decided — closes the observability gap for gateway decisions |
| Never crosses | — | Parsed protocol context (each side parses its own), raw event content, the cost ledger (Cortex stays the system of record) |
Cortex is the trust root for its headers: strip any client-supplied copy before injecting (precedents: token-exchange replaces Authorization; Envoy's Lua filter injects x-authbridge-direction). Spoofing matters on the praxis side too — its own head_classifier publishes a "client-unspoofable" reserved header for exactly this reason.
Plugin pipeline boundary
Three pipelines coexist: Cortex's (parsing, attribution, recording), cpex/PPE in-process (policy, merged in #1429 — CPEX 0.3.1 embeds PPE 0.5), and praxis's (the main home going forward). The rule: new checks land as praxis filters; Cortex's pipeline owns parsing, attribution and recording; PPE policy stays for the cpex build.
Pins compose by chaining
A pinned session's target is encoded as just another hairpin destination, so pinned traffic (e.g. praxis-ai for translation) also traverses the universal gateway's checks. The naive "pin wins, redirect direct" composition silently opens a policy hole and is rejected. Pin-as-routing-hint inside the gateway (upstream sticky-sessions proposal 00108) is a later refinement, not a requirement.
Blockers (all Cortex-side — that is the point)
BL1 — Dial target vs Host. server.go:429-436 deliberately couples r.URL.Host and r.Host on redirect ("they move together, or the upstream sees one host while the transport dials another"), and reads the target from the validated RedirectTarget copy so a plugin that never declared WritesDestination cannot steer the dial. The hairpin needs dial-gateway/Host-real-destination. Safe (plaintext loopback leg, no SNI divergence). Since the hop is its own dial-only rewrite (see Request mechanics) rather than a redirect, the redirect path keeps its invariant, and no WritesDestination exception is needed. A test still proves a plugin without WritesDestination cannot steer the dial.
BL2 — Path byte-fidelity (largely resolved by the header design). The hop leaves the path untouched, so there is no encode → route → decode round trip. What remains: Praxis rejects any path with .. segments or %2e%2e, NUL or control characters with a 400 (request_filter/validation.rs:88-140), so such a request fails through the hairpin even where the destination would accept it. Rare from agents; covered by the parity suite (W11). %2F passes through escaped; // and trailing slashes are kept. The existing redirect still clears RawPath (server.go:460), which matters only for plugin redirects, not the hop. On the normal path %2F survives through EscapedPath(); no in-tree test pins that yet.
BL3 — The egress listener is a new open-dialer on loopback. Any local process can use it to reach anywhere, including localhost services (the unauthenticated session API). Needs: shared-secret token (strip-then-inject), a destination policy — which cannot blanket-refuse private ranges, because local inference servers (Ollama, vLLM, LiteLLM) are legitimate destinations — audit logging, no double-recording of events, and no mis-attribution of the praxis process's connections.
BL4 — Recursion through praxis callouts. A guardrails callout made by praxis under the laptop's proxy env enters the forward proxy, is chained back to praxis, and can trigger another callout: unbounded recursion under a realistic config. Mandatory: praxis launched with scrubbed proxy env; peerproc self-traffic exemption for the praxis process (precedent: the self-traffic child rule, #1269/#1270); the egress dialer built with an explicit no-proxy client, never http.ProxyFromEnvironment.
Known issues (accepted or to design around)
High:
-
H1 — Config coupling: a praxis config missing the catch-all route,
allow_private_endpointsor the raised body limits breaks all traffic. Mitigation: a synthetic readiness probe through the whole chain before chaining engages; praxis is alpha software, so config churn is a real failure domain. -
H2 — gzip re-negotiation on the egress leg: the exact silent-SSE failure documented in
stripForUpstream's comment, in a second place. The egress dialer must own its encoding negotiation; pinned by test. -
H4 — Connection ballooning: three H1 legs = one connection per in-flight request per leg. Internal legs should speak h2c; downstream h2c support in praxis is unverified (upstream h2 exists — gRPC example, ALPN in cluster config).
-
H5 — Timeout and cancellation semantics across four legs: Cortex never cuts a slow upstream (499
client_canceledphilosophy); Praxis has no cluster read timeout by default, but its 10 MiB response limit cuts a longer SSE mid-stream unlessbody_limitsis raised; agent hangup must propagate to the egress dial (Pingora closes upstream when the downstream drops,proxy_h1.rs; promptness mid-SSE unverified). -
H6 — Cortex itself cuts a stream after 5 minutes with no bytes (
streamReadIdleTimeout,server.go:91), so it is not true that it never times out. Each added leg needs no idle cap, or one of at least 5 minutes. -
H7 — The egress listener takes over checking the destination's certificate. Today the bridge's upstream client does it, honouring
upstream_ca_bundleandupstream_insecure; reusetlsbridge.NewUpstreamClient(tlsbridge/upstream.go:17-35). -
H8 — Plain-HTTP proxied requests (not bridged) also run the pipeline, and the chaining rule above leaves them out. Decide whether they are chained too.
Medium: M1 WebSocket benefit nullified (Cortex strips Upgrade — praxis's upgrade passthrough never sees one); M2 h2 trailers likely lost over H1 legs; M3 durationMs/byte counts now measure the forward leg, not the wire (decide the canonical metric); M5 a client-supplied x-cortex-dest or token must be stripped before injecting (strip-then-inject); M6 memory doubled where praxis filters buffer; M7 fail-closed default with a rollout flag and version pinning; M8 the bridge's pre-forge HEAD probe (server.go:826-838) still goes straight to the real host, outside the gateway.
Decision recorded: fail-closed when the gateway is down (matching the cpex plugin's default; a silent direct-dial fallback is the "quietly allow what policy meant to deny" trap), with the supervisor restarting praxis quickly.
Work breakdown
Each row is a candidate sub-issue. Sizing is coarse (S/M/L) on purpose — per the review of #1437, pre-spike week estimates are not trustworthy; the spike derives the numbers.
| # | Item | Size | Depends on |
|---|---|---|---|
| W1 | Spike: minimal end-to-end — one hardcoded destination, no checks, one SSE stream through the loop; answers H2, H4, H5 and the unverified items below empirically | S | — |
| W2 | Dial-only hop after the pipeline: r.URL scheme and host only, recorded host and pricing unchanged, steering-hole test (BL1) |
S | — |
| W3 | — | — | |
| W4 | Egress listener: reads and strips x-cortex-dest, streaming relay, encoding ownership, no-record, token auth (BL3 part 1) |
M | W1 |
| W5 | Egress listener destination policy + audit (BL3 part 2) | S | W4 |
| W6 | Self-traffic rules: env scrub, peerproc exemption, recursion repro test (BL4) | S | W1 |
| W7 | Chaining rule engine, listener-level, ordered rules, pin composition | M | W2 |
| W8 | praxis config: catch-all route, allow_private_endpoints, raised body_limits |
S | W1 |
| W9 | Boundary contract: strip-then-inject headers, decision headers back into events | S | W7 |
| W10 | Readiness probe, supervision, agentop setup integration, rollout flag |
M | W4, W8 |
| W11 | Black-box parity suite: hairpin vs direct, events/bytes/timing/cost diffed | M | W2-W9 |
| W12 | Pinned-session chaining: x-cortex-dest names the pin's server (H3 resolution) |
S | W7 |
Spike checklist (W1's exit criteria)
- Hop with decoupled Host/dial-target; a non-
WritesDestinationplugin still cannot steer the dial; the recordedhostand the priced cost match a direct request. - A path with
%2F, unicode and empty segments arrives at the destination byte-identical. - gzip-coded SSE through the egress leg — assert frames, not just 200s.
- h2c on the praxis downstream listener — yes/no, and connection count under 10 concurrent streams.
- Hang up mid-SSE at the agent; the egress dial closes; the row says
client_canceled. - Launch praxis with the laptop's proxy env and a callout filter; reproduce the recursion, then prove the scrub + exemption kill it.
- Chain-on with a broken praxis config must fail closed at startup, not per-request.
- A 60 s+ silence mid-SSE survives end to end, across all four legs.
- A request body and an SSE response each over 10 MiB, with the raised limits.
- Whether praxis-ai's
token_countbuffers SSE responses (latency and memory). - Praxis closes the upstream promptly when the agent hangs up mid-SSE.
Revision 2026-10-24
A code check of both sides changed two things in the original design:
- The destination moved from the path to a header. Path encoding needs a Praxis change, not just a custom filter: a filter can set the path sent upstream (
rewritten_path), but the request URI that later filters and conditions read is read-only (crates/filter/src/context.rs:442,condition/request.rs:230-236), so praxis-ai's path-based filters would all see/_egress/.... Stockpath_rewrite/url_rewriteexist but normalize the path (collapse//, resolve dot segments). Withx-cortex-dest, stock Praxis needs configuration only, and BL2 mostly goes away. - The hop is not
pctx.Redirect. A redirect changes the recorded host, and host-scoped pricing, usage by endpoint, agentop'sSERVERcolumn and the router's history follow it.pctx.Redirectis also plugin-only. The hop is a dial-only rewrite ofr.URLafter the pipeline (W2, S).
Also added: the required Praxis configuration, the pinned-session rule, three more spike checks, and four gaps under Known issues (H6–H8, M8). No Praxis change is needed for any of it.
Non-goals
- No upstream work. The build(deps): Update python-dotenv requirement from >=1.2.2 to >=1.2.3 #781 enhancement proposal (with destination-from-request forwarding split out as its own deliverable) remains a separate decision under Proposal: Assess replacing the laptop Go forward proxy with Praxis — blocked on three upstream capabilities #1437.
- WebSockets — Cortex strips
Upgradetoday; that is its own work item if ever wanted. - Kubernetes shapes — laptop only.
- Replacing the eventual clean-shape decision. If destination-from-request lands, the hairpin collapses to it: praxis dials directly, the destination header, the egress listener and the extra traversal disappear, and nothing else — boundary contract, rules, checks — changes.
References
- Proposal: Assess replacing the laptop Go forward proxy with Praxis — blocked on three upstream capabilities #1437 — the assessment this answers; Fred's (araujof's) comment proposing the PPE-native alternative
- Breaking change: Upgrade the cpex plugin to CPEX 0.3.1 #1429 — PPE-as-cgo merged (CPEX 0.3.1 embeds PPE 0.5); the in-process policy half of the same story
- Spike: HTTP CONNECT forward-proxy tunneling praxis-proxy/praxis#781 (CONNECT spike, maintainer-agreed scope "H1 CONNECT plus absolute-form only", no proposal filed since 2026-08-22), build(deps): Bump the codeql-action group with 4 updates #782 (TLS interception, untouched)
core/listener/forwardproxy/server.go— redirect application (452-462 @e455a044),stripForUpstream,client.Do(532)core/pipeline/destination.go—requestedHostsemantics
Metadata
Metadata
Assignees
Labels
Type
Projects
- StatusShow more project fieldsNew/ToDo
Activity
huang195 commentedon Oct 30, 2026
W1 spike extension: one loop, both interim options (2026-10-30)
Per the decision on #1437, W1 now covers the pinned-inference-server option compositionally instead of as a separate spike. The pinned shape has no Cortex design risk (it is the existing
agentop servermechanism); its unknowns are praxis-ai product maturity. Wrapping it inside the hairpin loop answers the one genuinely new thing about it — composition — for near-zero extra cost.Leg A — hairpin loop skeleton + assertions (~1–2 days):
/→ clustercortex-egress→127.0.0.1:<egress>,insecure_options.allow_private_endpoints: true,body_limitsraised to 64 MiB.forwardproxy/server.go(r.URL.Scheme/Host→127.0.0.1:<praxis>, injectx-cortex-dest, strip-then-inject).x-cortex-dest, no-proxy dial (reusetlsbridge.NewUpstreamClient), streaming relay.client_canceled), 6 (callout recursion repro + env-scrub proof), 8 (60 s+ silence), 9 (>10 MiB bodies). Deferred: 2, 4, 11 — add only if the first six go green.Leg B — pinned praxis-ai inside the loop (~0.5 day):
x-cortex-dest(the pins-compose-by-chaining rule, i.e. W12 exercised early in its simplest form).token_count/token_usagebuffers the stream (checklist check 10).Repo strategy for the spike (merge-back rule): spike lives on a fork worktree branch (
spike/hairpin-w1), rebased onupstream/mainat session start. Nothing merges from the spike branch. Real fixes found mid-spike are cherry-picked into their own small PRs againstmainimmediately; graduated pieces (e.g. W2) are rewritten as clean PRs, using the spike branch as reference only. No stacked PRs on the spike branch (cross-repo stacking costs: planted base refs, CI drops to 3 checks on children).Assisted-By: Claude (Anthropic AI) noreply@anthropic.com