// Gzip the account channel's inference bodies, so the edge WAF stops killing turns.
//
// Render's edge (Cloudflare-fronted, "Powered by Render" on the block page) runs a
// managed WAF we cannot configure, and it scans the BODY of every POST. Two of its rule
// families fire on ordinary coding-agent traffic:
//
// "read ../../etc/hosts" → 403 (path traversal / LFI)
// "foo ; curl http://example.com" → 403 (shell command injection)
//
// while "/etc/passwd", "../../package.json", "" and
// "SELECT … OR 1=1 --" all pass. The 403 is served BEFORE the origin (it carries no
// x-render-origin-server header, unlike every 200), so nothing on our server can answer
// it, and there is no per-service toggle: Render's own "let us disable the Cloudflare
// WAF" request has sat since 2024 with no staff reply.
//
// Why this is a permanent failure and not an occasional one: every turn re-sends the
// whole conversation, so the moment one relative path or one pasted shell command enters
// the context, EVERY later request in that session carries it and the session is 403ed
// for good. Reading src/engine/errors.ts — whose comments contain both patterns — used
// to be enough to do it.
//
// The WAF does not inspect a compressed body, and the origin inflates one (body-parser
// defaults to `inflate: true`). Verified live: the identical payload that 403s as
// plaintext returns a normal completion when gzipped.
//
// What this gives up: the WAF no longer scans these bodies. On this route it was
// scanning PROMPTS — text on its way to a language model, not to a shell or a database —
// so the protection lost is ~nil where the false-positive rate was ~100%. The scope is
// deliberately narrow all the same: POSTs to the account channel's inference path on OUR
// server, nothing else. The relay carries its prompts over a WebSocket, which the WAF
// does not body-scan, so it needs nothing here.
//
// Kill switch: PRIVATEER_NO_GZIP=1 (and the runtime valve below, which disables
// compression for the process if the origin ever stops accepting it).
import { gzipSync } from "node:zlib";
import { serverBaseUrl } from "../auth/privateer.ts";
/** The account channel's OpenAI-shaped inference route — the only path we compress. */
const INFERENCE_PATH = "/api/agent/v1";
/**
* Statuses that read as "this hop would not take a compressed body".
*
* 413 is deliberately absent: a plaintext retry of a body that was already too large
* only makes it bigger. A 403 is absent too — that is the WAF, i.e. the very thing
* compression exists to get past, so retrying it in plaintext would just re-block.
*/
const REJECTS_ENCODING = new Set([400, 411, 415]);
let installed: (typeof globalThis.fetch) | null = null;
let enabled = true;
/** Body shapes we can compress AND cheaply resend uncompressed if the valve trips. */
function compressible(body: unknown): body is string | ArrayBuffer | ArrayBufferView {
return (
typeof body === "string" || body instanceof ArrayBuffer || ArrayBuffer.isView(body)
);
}
function toBuffer(body: string | ArrayBuffer | ArrayBufferView): Buffer {
if (typeof body === "string") return Buffer.from(body, "utf8");
if (body instanceof ArrayBuffer) return Buffer.from(body);
return Buffer.from(body.buffer, body.byteOffset, body.byteLength);
}
/**
* True when this request is the account channel's inference POST.
*
* `serverBaseUrl()` throws on a malformed stored URL and can change across a login, so
* it is read per request inside a try — a bad value means "not ours", never a crash on
* somebody else's fetch.
*/
function targetsInference(url: string): boolean {
try {
const base = new URL(serverBaseUrl());
const u = new URL(url, base);
if (u.origin !== base.origin) return false;
const mount = base.pathname === "/" ? "" : base.pathname.replace(/\/+$/, "");
return u.pathname.startsWith(`${mount}${INFERENCE_PATH}`);
} catch {
return false;
}
}
function urlOf(input: Parameters[0]): string {
if (typeof input === "string") return input;
if (input instanceof URL) return input.href;
return (input as Request).url;
}
/**
* Wrap `globalThis.fetch` once, for the whole process.
*
* A global wrap rather than a per-provider `fetch` option because inference does NOT go
* through our own authedFetch: it rides Pi's HTTP path (provider baseUrl + the bearer
* from getApiKey, see providers/account.ts), whose provider config has no fetch seam.
* Everything not matching targetsInference() is passed straight through to the original
* fetch, so this is inert for every other caller — including the loopback sealed shim,
* which is a different origin and never matches.
*/
export function installGzipRequestBodies(): void {
if (installed) return;
if (process.env.PRIVATEER_NO_GZIP === "1") return;
const inner = globalThis.fetch;
installed = inner;
globalThis.fetch = async (input, init) => {
const body = init?.body;
if (!enabled || !init || !compressible(body) || !targetsInference(urlOf(input))) {
return await inner(input, init);
}
const headers = new Headers(init.headers);
// Never double-encode, and never fight a caller that set its own encoding.
if (headers.has("content-encoding")) return await inner(input, init);
headers.set("content-encoding", "gzip");
// The buffer's length is the wire length now; a stale content-length truncates the
// request. undici recomputes it from the body we hand over.
headers.delete("content-length");
// level 1: this is defeating a plaintext pattern match, not saving bytes, and the
// call is synchronous on the event loop — a megabyte of context should cost
// milliseconds, not tens of them.
const gz = gzipSync(toBuffer(body), { level: 1 });
const res = await inner(input, { ...init, headers, body: gz });
if (!REJECTS_ENCODING.has(res.status)) return res;
// The valve. If some hop stops accepting compressed bodies (a proxy change, an
// origin without inflate), one plaintext retry recovers THIS turn and turns the
// workaround off for the rest of the process rather than failing every prompt.
// The retry is safe: these statuses mean the request was refused, not run.
const plain = await inner(input, init);
if (plain.ok) {
enabled = false;
void res.body?.cancel().catch(() => {});
return plain;
}
// Plaintext did no better — hand back the original response, which is the more
// honest error (a 403 WAF page here means the block is what we were dodging).
void plain.body?.cancel().catch(() => {});
return res;
};
}
/** Restore the pre-install fetch and re-arm the valve. Tests only. */
export function uninstallGzipRequestBodiesForTests(): void {
if (installed) globalThis.fetch = installed;
installed = null;
enabled = true;
}