diff --git a/internal/provider/google.go b/internal/provider/google.go index 241585f0..0934e2c9 100644 --- a/internal/provider/google.go +++ b/internal/provider/google.go @@ -131,10 +131,21 @@ func geminiDir() string { return filepath.Join(home, ".gemini") } -// geminiOwnLogin reads Gemini CLI's own Google sign-in. +// geminiOwnLogin reads Gemini CLI's own Google sign-in. One another app's +// OAuth client minted there isn't Gemini CLI's: Google refreshes a token +// only for the client that minted it, so it is left alone. (Antigravity's +// CLI, agy, keeps its sign-in in the system keyring, not here.) func geminiOwnLogin() (googleAccount, bool) { + path := filepath.Join(geminiDir(), "oauth_creds.json") var a googleAuth - if !readJSON(filepath.Join(geminiDir(), "oauth_creds.json"), &a) || a.RefreshToken == "" { + if !readJSON(path, &a) || a.RefreshToken == "" { + return googleAccount{}, false + } + var id struct { + IDToken string `json:"id_token"` + } + readJSON(path, &id) + if c := googleClientOf(id.IDToken); c != "" && c != geminiApp.clientID { return googleAccount{}, false } var accts struct { @@ -154,6 +165,16 @@ func geminiOwnLogin() (googleAccount, bool) { return googleAccount{app: app, user: user, auth: a, own: true}, true } +// googleClientOf is the OAuth client a Google ID token was minted for, "" +// when there is none to tell. +func googleClientOf(idToken string) string { + c := jwtClaims(idToken) + if azp := claimString(c, "azp"); azp != "" { + return azp + } + return claimString(c, "aud") +} + // envFileValue is one KEY=value of a dotenv file. func envFileValue(path, key string) string { f, err := os.Open(path) @@ -579,6 +600,7 @@ func (g googleAccount) needsProject(why string) error { if g.own { msg += " or GOOGLE_CLOUD_PROJECT in ~/.gemini/.env" } + msg += ". A personal Google account is served to Antigravity instead: add it under Antigravity in magpie" if why != "" { msg = why + " — " + msg } diff --git a/internal/provider/google_test.go b/internal/provider/google_test.go index b78d2b2b..e1ea41e3 100644 --- a/internal/provider/google_test.go +++ b/internal/provider/google_test.go @@ -3,6 +3,7 @@ package provider import ( "bytes" "context" + "encoding/base64" "encoding/json" "io" "net/http" @@ -126,6 +127,44 @@ func TestGeminiOwnLoginRefreshesInMemory(t *testing.T) { } } +// Only a sign-in Gemini CLI's own OAuth client minted is Gemini CLI's: one +// minted for another client (Antigravity's) can't be refreshed as Gemini +// CLI's and isn't listed; one without an ID token is taken as Gemini CLI's. +func TestGeminiOwnLoginOnlyGeminiCLIsClient(t *testing.T) { + idToken := func(claims map[string]any) string { + b, _ := json.Marshal(claims) + return "e30." + base64.RawURLEncoding.EncodeToString(b) + ".sig" + } + for _, c := range []struct { + name, idToken string + want bool + }{ + {"no id token", "", true}, + {"gemini cli", idToken(map[string]any{"aud": geminiApp.clientID, "azp": geminiApp.clientID}), true}, + {"antigravity", idToken(map[string]any{"aud": antigravityApp.clientID, "azp": antigravityApp.clientID}), false}, + {"antigravity aud only", idToken(map[string]any{"aud": antigravityApp.clientID}), false}, + } { + t.Run(c.name, func(t *testing.T) { + googleSandbox(t, &fakeGoogle{}) + dir := geminiDir() + os.MkdirAll(dir, 0o700) + creds, _ := json.Marshal(map[string]any{"access_token": "old", "refresh_token": "rt-own", "expiry_date": 1, "id_token": c.idToken}) + os.WriteFile(filepath.Join(dir, "oauth_creds.json"), creds, 0o600) + os.WriteFile(filepath.Join(dir, "google_accounts.json"), []byte(`{"active":"me@example.com","old":[]}`), 0o600) + _, ok := geminiOwnLogin() + if ok != c.want { + t.Fatalf("own login found = %v, want %v", ok, c.want) + } + if n := len(googleLoginList("gemini")); n != map[bool]int{true: 1, false: 0}[c.want] { + t.Errorf("gemini lists %d accounts", n) + } + if n := len(googleLoginList("antigravity")); n != 0 { + t.Errorf("antigravity lists %d accounts", n) + } + }) + } +} + // Google turns individuals away from Gemini CLI's sign-in; the account // then needs a project, and magpie says how to name one. func TestGeminiNeedsAProject(t *testing.T) { @@ -136,7 +175,8 @@ func TestGeminiNeedsAProject(t *testing.T) { g, _ := geminiOwnLogin() _, err := g.project(context.Background()) if err == nil || !strings.Contains(err.Error(), "magpie accounts project gemini me@example.com") || - !strings.Contains(err.Error(), "no longer supported") || !strings.Contains(err.Error(), "~/.gemini/.env") { + !strings.Contains(err.Error(), "no longer supported") || !strings.Contains(err.Error(), "~/.gemini/.env") || + !strings.Contains(err.Error(), "add it under Antigravity in magpie") { t.Fatalf("err = %v", err) } if len(f.onboards) != 0 {