Agent-First HTTP v0.10.0: The Browser Was Never the Variable
afhttp was launching Chromium with a flag that removed WebGL entirely, forcing a second flag that made the browser announce itself as automated, and shipping an image with two dozen font families. Then it measured which browser cleared bot walls better and blamed the browser. This release fixes all three, retracts the conclusion, and makes takeover backend-agnostic — plus a closed-world CLI registry and the response Content-Type on FetchResult.
A previous post here claimed that Brave draws a non-interactive Cloudflare challenge where Chrome gets a clickable checkbox. That claim was wrong, and the way it was wrong is the story of this release.
Three flags, all ours
The measurement was real. It was taken on a host carrying three defects that afhttp had put there itself.
The GPU flag. The chromium launcher pushed --disable-gpu
unconditionally. That flag does not merely disable hardware acceleration — it
switches off the GPU process wholesale and takes WebGL down with it. A
host-launched browser reported webgl=false, webgl2=false, and zero WebGL
extensions.
No real browser looks like that. Every anti-bot scorer reads a WebGL-less browser as automation, which makes the flag actively hostile to the one job the takeover host exists to do. Measured against a Cloudflare Turnstile wall, the no-WebGL profile never cleared — not even with a human clicking the checkbox.
It now passes --enable-unsafe-swiftshader instead, which only permits the
software fallback. Hardware GL still wins wherever it exists, so this is a
no-op on a machine with a usable GPU; containers without one get full WebGL 1
and 2. The software path does report a SwiftShader renderer string — a weaker
signal of its own — but present-and-software grades far better than absent.
Anyone who wants the old behaviour can still pass
--browser-arg=--disable-gpu, which lands after the defaults and wins.
The automation flag. The takeover preset forced
--disable-blink-features=AutomationControlled, and the install validator
required it, so a caller could not opt out. It bought nothing: the host never
launches Chromium with --enable-automation in the first place, so
navigator.webdriver already reported false — measured identical with the flag
and without it.
What it did buy was Chromium listing it under “unsupported command-line flag” — the browser announcing on screen that something automated launched it, on the one path where a human is looking at the window. A takeover session exists to be looked at by a person. It now ships without the flag.
The fonts. The image resolved about 46 font families. Font enumeration is a standard fingerprinting dimension, and a browser that sees two dozen families reads as a bare server rather than a desktop. The wider set takes it to roughly 486, and stops CJK and emoji from rendering as tofu in captured screenshots.
The retraction
Re-measured against the same site, on a host with all three fixed: Brave draws the ordinary checkbox and clears it, same as Chrome.
The browser was never the variable. The earlier claim is withdrawn.
Brave stays the default takeover backend, but on its original merits rather than a fabricated one — a default install that pulls no proprietary vendor binary, and Shields keeping captured content clear of ads and trackers. Chrome remains available, now described by what actually distinguishes it: the web’s most common fingerprint, and no Shields to break a login flow’s third-party auth scripts.
Takeover stops naming a vendor
Takeover was pinned to Brave in three independent places — the container install preflight, the fetch --takeover capability gate, and the built-image
component probe, which shelled out for brave-browser by name. The assumption
behind the pin was that Linux arm64 had no Chrome; Google shipped native arm64
builds in July 2026, so it no longer holds.
All three now follow the selected --browser through a single
TAKEOVER_BACKENDS set. What takeover actually requires is a headful CDP
browser plus the KasmVNC display, not one vendor. Chrome is added as an opt-in
backend (--with chrome, WITH_CHROME=1), installed from Google’s apt
repository the way Brave already is; being proprietary, it never lands in a
redistributed artifact, only in an image built locally.
Both backends now have tests of their own, which neither had before — pinning
each backend’s family string against the takeover table, and asserting the
browser exposes the WebGL that the --disable-gpu removal was for.
A closed-world CLI
Separately, and breaking: afhttp’s CLI is now compiled from afdata’s
cli-spec-v1 registry. One declaration — the root, four command groups
(container, profile, skill, tabs), 23 leaf commands, 34 legal shapes —
is the single source for argv parsing, typed values, legal combinations, output
contracts, --help, and docs/cli.md. clap is dropped, along with two
hand-rolled scanners that used to walk raw argv before the real parser ran.
Combinations that were runtime errors are now simply absent from the grammar:
fetch --takeover with --render none, --data with --form, host --takeover-provider kasmvnc with --display headless, and container logs --follow without --raw. Two arguments that were previously accepted and
silently ignored — --context without --from-source, and --rebuild with it
— are now rejected rather than quietly doing nothing.
Content-Type, reported rather than guessed
FetchResult now carries the origin’s response Content-Type verbatim.
body_file’s extension was always a lossy derivative of it: types outside the
extension table land in body.bin, so a caller reconstructing the type from
the filename silently loses everything the table does not cover. A downstream
fetch service did exactly that, and dropped the content type of every gif, pdf,
zip and docx it downloaded. Both pipelines already had the value — HTTP reads
it off the response headers, the browser path off the main network entry — and
simply never put it in the result.
Getting it
$ brew install agentfirstkit/tap/afhttp
$ cargo install agent-first-http
Container pins move with this release too: chrome-for-testing 151.0.7922.47 → .71, and KasmVNC 1.4.0 → 1.5.0.