Skip to content

fix(http): name yolocoder in User-Agent and show a refused preselection (ENG-3022) - #11

Open
lucas-koontz wants to merge 2 commits into
mainfrom
fix/eng-3022-yolocoder-user-agent
Open

lucas-koontz wants to merge 2 commits into
mainfrom
fix/eng-3022-yolocoder-user-agent

Conversation

@lucas-koontz

@lucas-koontz lucas-koontz commented Sep 27, 2026 •

Copy link
Copy Markdown

User story

As a MindsHub on-call engineer blocking the Jev flood at the Cloudflare edge
I want yolocoder to name itself in every request it sends
So that the edge rule that blocks Go's default user agent on api.mindshub.ai/v1/decisions stops the fleet without breaking our own coding agent

Why this matters

A bot fleet flooded api.mindshub.ai/v1/decisions on 2026-09-26 and took the shared prod database down. mindsdb/terraform#227 blocked the fleet by its exact user agent, Go-http-client/1.1, and about 70 minutes later the fleet switched to Go-http-client/2.0 and the floods came back. mindsdb/terraform#231 widened the rule to every Go-http-client/ agent on /v1/decisions. It merged on 2026-09-27 at 11:17 UTC and is live: a Go-http-client/2.0 request to that path gets a 403 at the edge.

yolocoder's file chooser never set a User-Agent, so it sends Go-http-client/2.0 too, and the edge now refuses it. The chooser also swallowed every failure. So right now every installed build with /preselect on loses its file preselection on every turn, with nothing on screen. This PR makes yolocoder name itself, which ends that at each user's next launch, and makes a refused, stalled or unreachable call say so on the trail.

What happens today

sequenceDiagram
    participant Fleet as Flood fleet
    participant Yolo as yolocoder
    participant Edge as Cloudflare edge
    participant Jev as /v1/decisions
    Fleet->>Edge: POST with User-Agent Go-http-client/2.0
    Edge-->>Fleet: 403 from the Go-http-client/ prefix rule
    Yolo->>Edge: POST with User-Agent Go-http-client/2.0
    Edge-->>Yolo: 403 from the same rule
    Note over Jev: neither request arrives
    Note over Yolo: the chooser returns nil<br/>and prints nothing
Loading

What should happen

sequenceDiagram
    participant Fleet as Flood fleet
    participant Yolo as yolocoder
    participant Edge as Cloudflare edge
    participant Jev as /v1/decisions
    Fleet->>Edge: POST with User-Agent Go-http-client/2.0
    Edge-->>Fleet: 403 from the Go-http-client/ prefix rule
    Yolo->>Edge: POST with User-Agent yolocoder/main
    Note over Edge: the rule stops Go's default agents,<br/>the fleet included, and not yolocoder
    Edge->>Jev: yolocoder passes
    Note over Yolo: a refused, stalled or unreachable call<br/>prints file preselection skipped
Loading

Acceptance criteria

  • The file chooser, the edit router, the Responses and chat-completions calls, model listing, the dialect probe, both sign-in calls (token exchange and API key) and the self-update check all send User-Agent: yolocoder/<version> (+https://github.com/mindsdb/yolocoder), where <version> is what the build stamped. Pinned by TestEveryProviderCallNamesYoloCoder, TestSignInCallsNameYoloCoder and TestTheLaunchCheckNamesYoloCoder.
  • Over HTTP/2 the request carries the yolocoder agent, not Go-http-client/2.0 (TestTheAgentRidesOnHTTP2).
  • No non-test Go file outside the client itself and the --web preview proxy uses http.DefaultClient, http.DefaultTransport, http.Get/Head/Post/PostForm, or makes its own http.Client or http.Transport value, including through an aliased or dot import (TestNoCodeBypassesTheSharedClient). Each form, and the comments, strings and http-named fields it must not flag, is pinned by TestTheGuardSeesEveryBypassAndNothingElse.
  • With /preselect on, a non-2xx reply from /v1/decisions prints exactly one trail line, file preselection skipped: decisions returned <code> <name>, and the turn still finishes (TestABlockedDecisionsCallIsOnTheTrailAndTheTurnCarriesOn). A code Go has no name for, such as Cloudflare's 522, prints as the bare code (TestAnythingGoingWrongJustChoosesNothing).
  • A call that runs past the 6-second limit prints file preselection skipped: decisions timed out after 6s, a call that cannot connect prints file preselection skipped: decisions unreachable, and a call whose turn context has already ended prints nothing (TestAStalledOrUnreachableDecisionsCallIsOnTheTrail).
  • A 2xx reply prints no skip line, including a shrug, one whose body fails to parse, and one cut off mid-body (TestPreselectionRemovesTheCallThatOnlyPicked, TestAShrugChoosesNothing, TestAnythingGoingWrongJustChoosesNothing).
  • The transport leaves the caller's own request unchanged, a redirect hop carries the header too (TestEveryHopCarriesTheAgentAndTheCallersRequestIsUntouched), and the caller's deadline still applies (TestTheCallersDeadlineStillApplies).

How to test

  1. In a clone of this branch, run go test ./.... Every package reports ok.
  2. Build with the version the Release workflow stamps: go build -ldflags "-X github.com/mindsdb/yolocoder/internal/version.Version=main" -o /tmp/yolocoder ./cmd/yolocoder. The agent string uses only the version, and a build with no stamped commit never self-updates, so a re-run cannot swap the binary for the published one.
  3. Start the local server in the block below on port 18765. It logs each request's User-Agent to /tmp/ua.log, returns a Cloudflare-style 403 on /v1/decisions, and returns a finished reply everywhere else.
  4. Make a throwaway home: mkdir -p /tmp/yh/.config/yolocoder /tmp/yw && echo 'const x = 1;' > /tmp/yw/a.ts, then write {"version":1,"provider":"openai-compatible","base_url":"http://127.0.0.1:18765","model":"m","api":"responses","preselect":true} to /tmp/yh/.config/yolocoder/config.json and {"api_key":"not-a-real-key"} to credentials.json beside it.
  5. Run cd /tmp/yw && HOME=/tmp/yh YOLOCODER_NO_AUTOUPDATE=1 /tmp/yolocoder "look at x". The trail shows file preselection skipped: decisions returned 403 Forbidden, then file choice, then the reply Nothing to change.
  6. Read /tmp/ua.log. Both POST /v1/decisions and POST /v1/responses show UA=yolocoder/main (+https://github.com/mindsdb/yolocoder).
  7. After merge, once the Release run on main is green, start yolocoder once so it self-updates, turn on /preselect, and run a task against MindsHub. In Cloudflare Security Events or the HTTP request logs for api.mindshub.ai, the /v1/decisions request from your IP shows yolocoder/main (+https://github.com/mindsdb/yolocoder) and no Go-http-client/2.0. This is the check most likely to expose a partial fix: any call path that still sends Go's default shows up here.
Local server for steps 3 to 6
import http.server, json
log = open('/tmp/ua.log', 'a')
class H(http.server.BaseHTTPRequestHandler):
    def do_POST(self):
        self.rfile.read(int(self.headers.get('Content-Length') or 0))
        log.write(f"{self.command} {self.path} UA={self.headers.get('User-Agent')}\n"); log.flush()
        if self.path.endswith('/decisions'):
            body, status, kind = b'<html><title>Attention Required! | Cloudflare</title></html>', 403, 'text/html'
        else:
            body = json.dumps({"id": "r", "output": [{"type": "message", "content": [{"type": "output_text", "text": "Nothing to change."}]}]}).encode()
            status, kind = 200, 'application/json'
        self.send_response(status); self.send_header('Content-Type', kind)
        self.send_header('Content-Length', str(len(body))); self.end_headers(); self.wfile.write(body)
    do_GET = do_POST
    def log_message(self, *args): pass
http.server.HTTPServer(('127.0.0.1', 18765), H).serve_forever()

Notes for the reviewer

  • One transport owns the header. internal/httpclient.Client wraps http.DefaultTransport and sets User-Agent on a clone of every request, redirects included. Every call site uses it. The edit router's per-call YoloCoder/experimental-edit-router header is gone because the transport would overwrite it anyway. The transport sets the header unconditionally rather than only when empty, since no caller has a reason to send anything else.
  • Sign-in and the self-updater use it too. They call auth.mindshub.ai and github.com, not api.mindshub.ai. Putting them on the same client keeps the guard's exempt list to two files: the client itself, and the --web preview proxy in internal/web/proxy.go, which forwards the browser's own requests to the local dev server.
  • Two sends sit outside the client. The CONNECT that opens a tunnel through an HTTPS_PROXY carries Go-http-client/1.1, because net/http writes it below any RoundTripper. The request inside the tunnel carries the yolocoder agent, so the edge never sees Go's default. The --web preview proxy forwards the browser's agent to localhost. The README says both.
  • The version is main on every rolling build. The Release workflow stamps main on pushes and the tag name on v* tags. I left the commit out of the header: version.Display() renders main (abc1234), which is not a valid product token, and the edge only needs to tell yolocoder apart from Go's default.
  • What goes on the trail. A non-2xx reply, a call past the 6-second limit, and a call that cannot connect each print one file preselection skipped: line. No turn can be cancelled today, because every turn runs on context.Background() (cmd/yolocoder/main.go, internal/web/server.go), so those errors are always the call's own. A call whose turn context has already ended still prints nothing, so a cancel added later stays quiet. A 2xx that fails to read or parse stays in the YOLOCODER_DEBUG_LOG trace. The status comes from the code and Go's name for it, not the server's reason phrase.
  • A custom provider with no /v1/decisions and /preselect on now prints decisions returned 404 Not Found every turn. That line is accurate. Typing /preselect in a terminal session turns it off. The --web window does not take commands, and a running --web server keeps the setting it started with, so it picks up the change when it restarts.
  • The header is forgeable. The fleet can copy yolocoder/main (+https://github.com/mindsdb/yolocoder) from this public repo with one line of code. Treat the prefix block as a speed bump, not as proof that a request is ours. Per-key and per-IP limits are the durable signal.
  • Merging to main cuts the release. .github/workflows/release.yml rebuilds the six binaries with Version=main and republishes the rolling latest GitHub release. Installed release builds check latest on every launch (throttleChecks = false in internal/update/update.go) and replace themselves, so most users get this the next time they start yolocoder. A v* tag would make a permanent release, but none exist and the self-updater never reads them. The update check goes to GitHub, so a block on api.mindshub.ai cannot stop the upgrade itself.
  • Older builds are failing soft now, until they upgrade. Since #231 went live, three groups keep the old agent: sessions already running (interactive or --web) until they restart, anyone with YOLOCODER_NO_AUTOUPDATE=1, and source builds with no stamped commit (go run, plain go build), which never self-update. For them, /preselect stops picking files and the turn runs as if it were off, with nothing on screen. /preselect is off by default, so only users who turned it on see a difference. Coding calls are untouched while the rule stays scoped to /v1/decisions.
  • Missing models, left alone. selectEditFiles builds its payload and questions as map[string]any, while decide.go uses typed decisionRequest and question structs. It also builds its decisions URL inline instead of calling decisionsEndpoint. Both predate this PR and stay out of scope.
  • yolocoder is not MindsDB's only Go client of MindsHub. mindsdb/setfree is public Go code that sends through http.DefaultClient, so it also sends Go's default agent. It calls GET /v1/models on api.mindshub.ai (internal/catalog/catalog.go, internal/adapters/modelprobe.go) and the usage and sign-in routes on auth.mindshub.ai (internal/usage/usage.go, internal/auth/auth.go). It never calls /v1/decisions, so a Go-http-client/ rule scoped to that path leaves it alone. A rule that widens past /v1/decisions would break it, and this PR does nothing for it.
  • The scaffold's own Jev calls run on Node. internal/web/template/scratch/backend/decisions.ts runs inside the user's generated app, so it sends Node's agent, and a Go-http-client/ rule does not touch it.

Verified locally

Check Result
go build, go vet, go test ./..., gofmt -l . on origin/main before the change all clean, every package ok
go build, go vet, go test ./..., gofmt -l . on this branch all clean, every package ok
go test -race on the new and changed tests ok
GOOS=windows go vet ./... ok
9 mutations, each switching one call site back to http.DefaultClient (NewClient, chooser, edit router, coding call, ListModels, DetectAPI, CreateAPIKey, exchangeCode, updater default) the matching test fails with User-Agent = "Go-http-client/1.1", and TestNoCodeBypassesTheSharedClient fails for each
Mutation: transport sets no header every User-Agent test fails; the HTTP/2 test sees Go-http-client/2.0
Mutation: transport edits the caller's request TestEveryHopCarriesTheAgentAndTheCallersRequestIsUntouched fails
Mutation: no trail line on a non-2xx both chooser log tests fail
Mutation: trail line on a 200 that fails to parse TestAnythingGoingWrongJustChoosesNothing fails
Release-style binary (Version=main) against the local 403 server server logs yolocoder/main (+https://github.com/mindsdb/yolocoder) on /v1/decisions and /v1/responses; trail shows file preselection skipped: decisions returned 403 Forbidden; the turn finishes
origin/main binary against the same server server logs Go-http-client/1.1 on both routes; no trail line
Review: GET https://api.mindshub.ai/cdn-cgi/trace and https://auth.mindshub.ai/cdn-cgi/trace through httpclient.Client with Version=main. The Cloudflare edge answers this path itself and never forwards it to the origin both report uag=yolocoder/main (+https://github.com/mindsdb/yolocoder) and http=http/2, so the edge sees the new agent over HTTP/2
Review: the same trace request through http.DefaultClient uag=Go-http-client/2.0, the agent the widened rule would match
Review: 10 mutations re-run from a fresh clone (header not set, no Clone, http.DefaultClient back in NewClient, ListModels, DetectAPI, CreateAPIKey, exchangeCode and the updater default, no trail line on a non-2xx, a trail line on a 200 that fails to parse), each restored from a snapshot and checked with cmp each one fails its matching test, and each call-site reversion also fails TestNoCodeBypassesTheSharedClient
Review: How to test steps 2 to 6, run as written trail and ua.log match the steps
Live edge on 2026-09-27: HEAD https://api.mindshub.ai/v1/decisions Go-http-client/2.0 gets 403 from the #231 rule; yolocoder/main (+https://github.com/mindsdb/yolocoder) gets 401, past the edge
Review round: go vet, GOOS=windows go vet ./..., go test ./..., gofmt -l ., and go test -race on agent, httpclient, auth and update all clean, every package ok
Review round: 8 mutations, each in a throwaway copy (no trail line on a transport error; a line when the turn context has ended; the first round's combined read-and-status check; a line on a shrug; the server's reason phrase in the line; Clone(context.Background()) in the transport; the guard forgetting DefaultTransport; the guard allowing value clients) each fails its matching test
Review round: guard probes for new(http.Client), a zero-value http.Client, http.DefaultTransport.RoundTrip, an aliased and a dot import, and &http.Transport{} each is flagged; the first round's text match let each through except an alias whose name ends in http
Review round: How to test steps 2 to 6 against the new commit trail shows the 403 skip line, then file choice, then Nothing to change.; ua.log shows the yolocoder agent on /v1/decisions and /v1/responses
Real api.mindshub.ai /v1/decisions call not run; it needs a key and a paid call

Ships with

  • Ticket: ENG-3022
  • Follows mindsdb/terraform#227 and mindsdb/terraform#231. #227 blocked the exact Go-http-client/1.1 agent on api.mindshub.ai/v1/decisions. #231 widened that rule in place to starts_with(http.user_agent, "Go-http-client/"), merged on 2026-09-27 at 11:17 UTC, and is live. Its rule comment and its test already describe the agents this PR sends, so no terraform follow-up is needed.
  • Merge order: none left. The terraform side is already live, so until this merges, every build with /preselect on loses preselection on every turn. Merging cuts the release, and each user recovers at their next launch. There is no deploy coupling, and yolocoder has no per-PR environment.

…on (ENG-3022)

Lucas Koontz, for ENG-3022 "Stop free Jev floods from taking down the
shared prod database (2026-09-26 incident)".

Every outbound request now goes through internal/httpclient, whose
transport sets "User-Agent: yolocoder/<version>
(+https://github.com/mindsdb/yolocoder)". Before this, the /v1/decisions
file chooser, the coding calls, model listing, the dialect probe,
sign-in and the self-updater all sent Go's default Go-http-client/1.1 or
Go-http-client/2.0, which is also what the Jev flood fleet sends. The
edge can now block that default without blocking yolocoder.

chooseFiles now prints "file preselection skipped: decisions returned
<status>" on the turn's trail when /v1/decisions answers with a non-2xx
status. A block or a revoked key used to cost the preselection silently
on every turn.

A test walks the source tree and fails when non-test code sends through
http.DefaultClient or builds its own http.Client.

Refs: ENG-3022

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot review overview

🟢 Approval recommended

No unresolved review issues were identified.

Review effort: Lite
Findings: None

What changed in this PR

Updates outbound requests to identify as yolocoder/<version> and reports refused file-preselection responses without interrupting turns.

Changes:

  • Added shared User-Agent handling across HTTP/2, redirects, provider, auth, and update calls.
  • Logged non-2xx preselection responses while preserving fallback behavior.
  • Added comprehensive tests and documentation.
File Description
README.md Documents User-Agent behavior and preselection failures.
internal/​update/​update.go Routes update requests through the shared client.
internal/​update/​update_test.go Tests updater User-Agent behavior.
internal/​httpclient/​httpclient.go Implements the shared User-Agent transport.
internal/​httpclient/​httpclient_test.go Tests transport, redirects, HTTP/2, and request immutability.
internal/​auth/​auth.go Routes sign-in requests through the shared client.
internal/​auth/​auth_test.go Tests sign-in User-Agent behavior.
internal/​agent/​user_agent_test.go Tests User-Agent coverage across provider calls.
internal/​agent/​edit_router.go Removes the obsolete per-call User-Agent.
internal/​agent/​decide.go Reports non-2xx preselection responses.
internal/​agent/​decide_test.go Tests preselection failure reporting and continuation.
internal/​agent/​client.go Routes client and model requests through the shared client.
internal/​agent/​chat.go Routes dialect probes through the shared client.
internal/​agent/​agent.go Passes progress reporting into preselection.

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

…ENG-3022)

Lucas Koontz, for ENG-3022 "Stop free Jev floods from taking down the
shared prod database (2026-09-26 incident)". Review round on #11.

chooseFiles now also puts a line on the trail when /v1/decisions runs
out of time ("decisions timed out after 6s") or cannot be reached
("decisions unreachable"). The reason given for keeping these quiet, that
a user cancel ends the call, had no case behind it: every turn runs on
context.Background(), so a stalled endpoint cost each turn its
preselection with nothing on screen. A call whose turn context has
already ended still prints nothing. The status in the skip line is now
built from the code and Go's name for it, so Cloudflare's 52x codes read
"522" rather than "522 " and a server's own reason phrase never reaches
the terminal. The line after the step reads "file choice" instead of
"chose files", which contradicted a skip line printed just above it.

TestNoCodeBypassesTheSharedClient now reads the syntax tree instead of
matching text. It catches http.DefaultTransport, new(http.Client), a
zero-value or literal http.Client or http.Transport, and aliased or dot
imports of net/http, and it no longer flags comments, strings or a
struct field named http. It exempts two exact files, the client itself
and the --web preview proxy, instead of any directory named httpclient.
A table test pins each form.

New tests pin that the shared client keeps the caller's deadline, that a
shrug and a 2xx cut off mid-body print no skip line, and the timeout,
unreachable and ended-turn cases above. The README scopes "every
request" to provider, sign-in and update calls, names the two sends that
sit outside the client, lists what the guard catches, and says
preselection runs only in folders of up to 80 mapped files.

Refs: ENG-3022
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants