Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
47 changes: 42 additions & 5 deletions cmd/capi/antigravity.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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 {
Expand Down
40 changes: 40 additions & 0 deletions cmd/capi/antigravity_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package main
import (
"encoding/json"
"net/url"
"strconv"
"strings"
"testing"

Expand Down Expand Up @@ -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)
}
}
Loading