From dbbe989d29c88bd5c9c38a9bfc95c7f41c7f5d3e Mon Sep 17 00:00:00 2001 From: Catie Date: Wed, 23 Sep 2026 14:23:49 +0800 Subject: [PATCH] feat: per-account User-Agent for Antigravity to reduce ban risk Every Antigravity request previously sent one hardcoded client UA (antigravity/hub/2.9.1 darwin/arm64), so a whole pool of accounts sharing a server IP fingerprinted as a single identical client. Derive a stable UA per account from a small pool of realistic client strings (varying only os/arch, never inventing versions): the same account always reports the same client like a real single-device user, while different accounts spread across the pool. Also stamp the UA on the Google token refresh/exchange calls so they no longer leak the default Go-http-client agent. Co-Authored-By: Claude Opus 4.8 (1M context) --- cmd/capi/antigravity.go | 47 ++++++++++++++++++++++++++++++++---- cmd/capi/antigravity_test.go | 40 ++++++++++++++++++++++++++++++ 2 files changed, 82 insertions(+), 5 deletions(-) diff --git a/cmd/capi/antigravity.go b/cmd/capi/antigravity.go index abd848e..8e138de 100644 --- a/cmd/capi/antigravity.go +++ b/cmd/capi/antigravity.go @@ -217,6 +217,7 @@ func (s *Server) refreshAntigravityAccount(refreshToken string) (OpenAIRefreshRe } request.Header.Set("Content-Type", "application/x-www-form-urlencoded") request.Header.Set("Accept", "application/json") + request.Header.Set("User-Agent", antigravityUserAgent) response, err := s.httpClient.Do(request) if err != nil { @@ -288,6 +289,7 @@ func (s *Server) exchangeAntigravityOAuthCode(code, verifier string) (OpenAIRefr } request.Header.Set("Content-Type", "application/x-www-form-urlencoded") request.Header.Set("Accept", "application/json") + request.Header.Set("User-Agent", antigravityUserAgent) response, err := s.httpClient.Do(request) if err != nil { @@ -342,7 +344,7 @@ func (s *Server) resolveAntigravityProject(account OpenAIAccount, accessToken st if err != nil { return "", err } - s.setAntigravityHeaders(request, accessToken) + s.setAntigravityHeaders(request, account, accessToken) response, err := s.httpClient.Do(request) if err != nil { @@ -373,10 +375,45 @@ func (s *Server) resolveAntigravityProject(account OpenAIAccount, accessToken st return project, nil } -func (s *Server) setAntigravityHeaders(request *http.Request, accessToken string) { +// antigravityUserAgents is the pool of realistic Antigravity desktop-client +// User-Agent strings. Real clients report their host platform, so spreading +// pooled accounts across the platform axis (while keeping the known-good client +// version) avoids every account fingerprinting as one identical client from a +// single server IP. Only the os/arch suffix varies — inventing version numbers +// would risk looking less real, not more. +var antigravityUserAgents = []string{ + "antigravity/hub/2.9.1 darwin/arm64", + "antigravity/hub/2.9.1 darwin/x64", + "antigravity/hub/2.9.1 win32/x64", + "antigravity/hub/2.9.1 linux/x64", +} + +// antigravityUserAgentFor returns a stable User-Agent for an account: the same +// account always reports the same client (mirroring a real single-device user), +// while different accounts spread deterministically across the pool. A stable +// per-account UA is deliberately chosen over per-request rotation — a single +// account flipping platforms every call looks more bot-like, not less. Falls +// back to the canonical UA when the account has no identifier yet. +func antigravityUserAgentFor(account OpenAIAccount) string { + id := strings.TrimSpace(account.ID) + if id == "" { + id = strings.TrimSpace(account.AccountID) + } + if id == "" { + id = strings.TrimSpace(account.Email) + } + if id == "" { + return antigravityUserAgent + } + hasher := fnv.New32a() + _, _ = hasher.Write([]byte(id)) + return antigravityUserAgents[hasher.Sum32()%uint32(len(antigravityUserAgents))] +} + +func (s *Server) setAntigravityHeaders(request *http.Request, account OpenAIAccount, accessToken string) { request.Header.Set("Content-Type", "application/json") request.Header.Set("Authorization", "Bearer "+accessToken) - request.Header.Set("User-Agent", antigravityUserAgent) + request.Header.Set("User-Agent", antigravityUserAgentFor(account)) } // buildAntigravityPayload converts an OpenAI chat request into the Antigravity @@ -621,7 +658,7 @@ func (s *Server) callAntigravityWithAccount(call GatewayCall, account OpenAIAcco if err != nil { return nil, &ProviderError{Status: http.StatusBadGateway, Code: "upstream_unreachable", Message: err.Error(), Type: "api_error"} } - s.setAntigravityHeaders(request, accessToken) + s.setAntigravityHeaders(request, account, accessToken) response, err := s.httpClient.Do(request) if err != nil { @@ -668,7 +705,7 @@ func (s *Server) streamAntigravityWithAccount(c *gin.Context, call GatewayCall, if err != nil { return &ProviderError{Status: http.StatusBadGateway, Code: "upstream_unreachable", Message: err.Error(), Type: "api_error"} } - s.setAntigravityHeaders(request, accessToken) + s.setAntigravityHeaders(request, account, accessToken) response, err := s.httpClient.Do(request) if err != nil { diff --git a/cmd/capi/antigravity_test.go b/cmd/capi/antigravity_test.go index 98d4d70..5fc3601 100644 --- a/cmd/capi/antigravity_test.go +++ b/cmd/capi/antigravity_test.go @@ -3,6 +3,7 @@ package main import ( "encoding/json" "net/url" + "strconv" "strings" "testing" @@ -212,3 +213,42 @@ func TestAntigravityAuthorizeURL(t *testing.T) { t.Fatalf("scope must request cloud-platform, got %q", query.Get("scope")) } } + +func TestAntigravityUserAgentForIsStableAndSpread(t *testing.T) { + // Every UA in the pool must be a well-formed antigravity client string. + poolSet := map[string]bool{} + for _, ua := range antigravityUserAgents { + if !strings.HasPrefix(ua, "antigravity/hub/") { + t.Fatalf("unexpected UA format in pool: %q", ua) + } + poolSet[ua] = true + } + + // The same account always reports the same UA (a real single-device user), + // and the chosen UA is always drawn from the pool. + account := OpenAIAccount{ID: "oaiacc_abc"} + first := antigravityUserAgentFor(account) + if !poolSet[first] { + t.Fatalf("UA %q not from pool", first) + } + for i := 0; i < 5; i++ { + if got := antigravityUserAgentFor(account); got != first { + t.Fatalf("UA not stable for same account: %q vs %q", got, first) + } + } + + // Across many accounts the selection spreads over more than one platform, + // so the pool does not fingerprint as a single identical client. + seen := map[string]bool{} + for i := 0; i < 200; i++ { + seen[antigravityUserAgentFor(OpenAIAccount{ID: "oaiacc_" + strconv.Itoa(i)})] = true + } + if len(seen) < 2 { + t.Fatalf("UA selection did not spread across the pool: %v", seen) + } + + // No identifier at all falls back to the canonical UA rather than panicking. + if got := antigravityUserAgentFor(OpenAIAccount{}); got != antigravityUserAgent { + t.Fatalf("empty account should fall back to canonical UA, got %q", got) + } +}