Send the versioned User-Agent from every CLI HTTP client - #2096
Conversation
#2049 fixed dispatch on the premise that api.Client already stamped entire-cli/<version> on every other CLI request. It didn't. The largest gap was the control plane: newCrossJurisHTTPClient wrapped httpclient.NewTransport with no UserAgentTransport, so every org/project/repo/grant/search/api/auth request went out as Go-http-client/2.0. The auth package's three bare clients (carrying clusterdiscovery's well-known fetch and the cluster-catalog GET) and the plugin asset client were unstamped too. Fix at the client constructors rather than the call sites, so a new request site can't silently regress. versioninfo.WrapTransport binds httpclient.UserAgentTransport to UserAgent() in one place instead of repeating it in three packages; httpclient keeps taking UA as a field, so git-remote-entire still supplies its own identity. Wrap innermost, at the base of a transport chain rather than on top: crossJurisRoundTripper builds the RFC 8693 exchange and the federation manifest fetch itself and sends both straight to t.base, so an outer wrapper would miss them. Moving the wrapper outside makes TestNewCrossJurisHTTPClient_StampsUserAgentOnEveryHop report Go-http-client/1.1 on those two hops. The OAuth device-flow, auth-code, and refresh clients are left alone: they set UserAgent to the OAuth client ID ("entire-cli", unversioned), which the login server reads, so retargeting it is a server-coupled decision rather than part of this sweep. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes and found 1 potential issue.
❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.
Comment @cursor review or bugbot run to trigger another review on this PR
Reviewed by Cursor Bugbot for commit b37febe. Configure here.
| var pluginHTTPClient = &http.Client{ | ||
| Timeout: 5 * time.Minute, | ||
| Timeout: 5 * time.Minute, | ||
| Transport: versioninfo.WrapTransport(nil), |
There was a problem hiding this comment.
Stale User-Agent at package init
Medium Severity
pluginHTTPClient calls versioninfo.WrapTransport in a package-level var initializer, which freezes UserAgent() before main runs versioninfo.Load(). For go install @<version> builds (no ldflags), plugin asset and checksum requests keep sending entire-cli/dev while every lazily built client sends the real version. The new test cannot catch this because both sides still read dev under go test.
Reviewed by Cursor Bugbot for commit b37febe. Configure here.
There was a problem hiding this comment.
Pull request overview
This PR standardizes outbound HTTP identification by ensuring CLI HTTP clients consistently send a versioned User-Agent (entire-cli/<version>), including control-plane cross-jurisdiction hops, auth discovery/exchange clients, and plugin asset downloads. It does this primarily by wrapping transports at client-construction time to prevent future call sites from silently omitting the header.
Changes:
- Add
versioninfo.WrapTransportto stamp a versionedUser-Agentviahttpclient.UserAgentTransport, and apply it to several HTTP client constructors. - Fix control-plane cross-jurisdiction client wiring so the federation manifest fetch and RFC 8693 token exchange also carry the versioned
User-Agent(wrapper placed under the cross-juris transport). - Add targeted tests to pin
User-Agentbehavior across these clients and hops.
Reviewed changes
Copilot reviewed 10 out of 10 changed files in this pull request and generated 1 comment.
Show a summary per file
| File | Description |
|---|---|
| internal/coreapi/cross_juris_useragent_test.go | Adds an integration-style test asserting the control-plane client stamps User-Agent on every hop (wrong core, federation fetch, home core, exchange). |
| internal/coreapi/cross_juris_transport.go | Wraps the base transport with the versioned User-Agent wrapper under the cross-juris transport to cover synthesized requests. |
| cmd/entire/cli/versioninfo/transport.go | Introduces versioninfo.WrapTransport helper to attach httpclient.UserAgentTransport using versioninfo.UserAgent(). |
| cmd/entire/cli/versioninfo/transport_test.go | Adds unit tests verifying WrapTransport stamps User-Agent, doesn’t mutate caller requests, and handles nil next. |
| cmd/entire/cli/plugin_fetch.go | Stamps plugin download traffic by adding a transport wrapper to the shared plugin download client. |
| cmd/entire/cli/plugin_fetch_useragent_test.go | Adds a test asserting plugin asset/download requests include the versioned User-Agent. |
| cmd/entire/cli/auth/useragent_test.go | Adds tests asserting auth discovery and cell-exchange HTTP clients send the versioned User-Agent. |
| cmd/entire/cli/auth/data_api.go | Wraps the data API discovery client transport and simplifies the plain-HTTP discovery transport wiring. |
| cmd/entire/cli/auth/control_plane.go | Wraps the control-plane cluster discovery HTTP client transport to stamp the versioned User-Agent. |
| cmd/entire/cli/auth/cell_data_api.go | Wraps the cell exchange HTTP client (including the test seam transport) to stamp the versioned User-Agent. |
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
| var pluginHTTPClient = &http.Client{ | ||
| Timeout: 5 * time.Minute, | ||
| Timeout: 5 * time.Minute, | ||
| Transport: versioninfo.WrapTransport(nil), | ||
| CheckRedirect: func(req *http.Request, via []*http.Request) error { | ||
| if len(via) >= maxAssetRedirects { |


https://entire.io/gh/entireio/cli/trails/1122
Summary
Follow-up to #2049. That PR's premise — that
api.Clientalready stampedentire-cli/<version>on "every other CLI request" — wasn't true. The biggest hole was the control plane:coreapi.newCrossJurisHTTPClientwrappedhttpclient.NewTransportwith noUserAgentTransport, so everyentire org|project|repo|grant|search|api|authrequest went out asGo-http-client/2.0.Fixed at the client constructors, not the call sites, so a new request site can't silently regress — the structural version of a nit raised on #2049.
What was uncovered
coreapi.newCrossJurisHTTPClientGo-http-client/2.0auth's 3 bare clients, which carryclusterdiscovery's well-known fetch and the cluster-catalog GEThttps://entire.io/.well-known/entire-api.json, core/api/v1/clustersGo-http-client/2.0pluginHTTPClientchecksums.txtGo-http-client/2.0Already covered, unchanged:
api.Clientand itsbearerTransport(which is also what entire-api cell clients use),versioncheck, dispatch (#2049), andgit-remote-entire+remotehelper/transport, which pass their own binary identity.Audited by enumerating every non-test
http.Client{}construction and everyhttp.NewRequest*site undercmd/andinternal/; each one is now either transport-wrapped or sets the header itself.New helper
versioninfo.WrapTransportbindshttpclient.UserAgentTransporttoUserAgent()in one place rather than repeating the same two lines in three packages.httpclientstays binary-agnostic (it still takesUAas a field, which is howgit-remote-entiresupplies its own).internal/coreapiimportingcmd/entire/cli/versioninfofollows what that file already does — it importscmd/entire/cli/authtoday — and avoids a startup-order footgun: nothing has to remember to call a setter before the first request.The load-bearing detail: wrap innermost
The wrapper goes at the base of a transport chain, never on top.
crossJurisRoundTripperbuilds two requests of its own — the RFC 8693 token exchange and the federation manifest fetch — and sends both straight tot.base, bypassing anything wrapped outside it.TestNewCrossJurisHTTPClient_StampsUserAgentOnEveryHoppins this. Moving the wrapper outside the round tripper makes it fail:It drives the real
newCrossJurisHTTPClientrather than the existingtransportFor(t)helper, because the wiring is what's under test.Test plan
TestWrapTransport_StampsUserAgent(table: unset / Go default / caller value) + asserts the caller's own request is not mutated, andTestWrapTransport_NilNextUsesDefaultTransport.TestNewCrossJurisHTTPClient_StampsUserAgentOnEveryHop— 421 → federation manifest → bare 401 → RFC 8693 exchange → retry, asserting the UA on all four hop kinds and failing if any hop stops happening. Mutation-verified above.TestAuthHTTPClients_SendVersionedUserAgent— table overdataAPIDiscoveryClient(https and loopback-http branches, the latter checking the stamp survivesdataAPIHTTPDiscoveryTransportlayered on top) andcellExchangeHTTPClient.TestPluginHTTPClient_SendsVersionedUserAgent.authRecorderconvention ininternal/coreapi: HTTP completion is not a happens-before edge the race detector recognises.mise run fmt && mise run lintclean;mise run test:cigreen.Left alone on purpose
auth's device-flow, auth-code, and refresh clients setUserAgent: oauthClientID, i.e.User-Agent: entire-cliwith no version. That value is deliberately the OAuth client ID and it is read by the login server, not the BFF, so retargeting it is a server-coupled product call rather than part of a mechanical sweep. Worth deciding separately if login traffic ever needs version attribution.entire-cli/devis not semver. Local andgo buildbinaries send it, and entire.io#3783's gate passes non-semver untouched by design.Sequencing note before flipping
CLI_MIN_VERSIONThis makes a large amount of previously-anonymous traffic gate-visible, which is the point, but it also means the blast radius of flipping
CLI_MIN_VERSIONgrows the moment it ships. Worth letting version telemetry show the real distribution of old clients first. Also worth confirming server-side that the semver parse tolerates prerelease suffixes — nightlies sendentire-cli/0.6.2-nightly.202605160654.ddf1a331.🤖 Generated with Claude Code
Note
Medium Risk
Touches every control-plane request plus discovery and plugin downloads by changing shared HTTP transports. Behavior is header-only, but wrapping order in the cross-jurisdiction client is load-bearing.
Overview
Stamps
entire-cli/<version>on CLI HTTP clients that were still sending Go's default User-Agent, by wrapping transports at construction time rather than at each call site.Adds
versioninfo.WrapTransportand applies it to the control-plane client (innermost, so federation fetch and RFC 8693 exchange are covered), auth discovery/cell-exchange clients, and plugin asset downloads. Tests pin the header on those paths, including every hop of a 421 → federation → exchange chain.Reviewed by Cursor Bugbot for commit b37febe. Configure here.