From c4d9243873ae0d42dd967c229d40b144eb14e083 Mon Sep 17 00:00:00 2001 From: Renfei Zhou Date: Sat, 14 Nov 2026 20:44:50 +0000 Subject: [PATCH 1/2] test(fork): pool one Antigravity account read by a local login and a hub A local Antigravity login is keyed by its Google-id fingerprint. A usage hub that withholds Google userinfo lists the same account only by e-mail, so the Usage page shows it twice. These cases fail until the login also reports the e-mail it reads with that fingerprint. Co-Authored-By: Claude Opus 5.5 --- .../provider/antigravityUsageLimits.test.ts | 21 +- .../src/fork/antigravityEmailBindings.test.ts | 251 ++++++++++++++++++ 2 files changed, 270 insertions(+), 2 deletions(-) create mode 100644 packages/shared/src/fork/antigravityEmailBindings.test.ts diff --git a/apps/server/src/provider/antigravityUsageLimits.test.ts b/apps/server/src/provider/antigravityUsageLimits.test.ts index 9defd9df90dce..e14821e03bbcf 100644 --- a/apps/server/src/provider/antigravityUsageLimits.test.ts +++ b/apps/server/src/provider/antigravityUsageLimits.test.ts @@ -63,6 +63,7 @@ const fixture = Effect.fn("AntigravityUsageTest.fixture")(function* ( tokenStatus?: number; refreshToken?: string; userId?: string; + omitUserEmail?: boolean; userInfoAt?: number; missingToken?: boolean; authMethod?: "oauth-personal" | "oauth-business" | "gemini-api-key"; @@ -107,7 +108,10 @@ const fixture = Effect.fn("AntigravityUsageTest.fixture")(function* ( return HttpClientResponse.fromWeb( request, options.userId - ? Response.json({ id: options.userId, email: "someone@example.com" }) + ? Response.json({ + id: options.userId, + ...(options.omitUserEmail ? {} : { email: "someone@example.com" }), + }) : Response.json({}, { status: 403 }), ); } @@ -588,7 +592,20 @@ describe("Antigravity subscription limits", () => { expect(machineA.credentialFingerprint).toMatch(/^[0-9a-f]{64}$/); expect(machineB.credentialFingerprint).toBe(machineA.credentialFingerprint); expect(otherAccount.credentialFingerprint).not.toBe(machineA.credentialFingerprint); - expect(JSON.stringify(machineA)).not.toMatch(/google-user-1|someone@example.com/); + expect(machineA.credentialEmail).toBe("someone@example.com"); + expect(JSON.stringify(machineA)).not.toMatch(/google-user-1/); + const unidentified = yield* fixture({ refreshToken: "refresh-a" }).pipe( + Effect.flatMap((usage) => usage.probe), + ); + expect(unidentified.credentialFingerprint).not.toBe(machineA.credentialFingerprint); + expect(unidentified.credentialEmail).toBeUndefined(); + const idOnly = yield* fixture({ + refreshToken: "refresh-c", + userId: "google-user-1", + omitUserEmail: true, + }).pipe(Effect.flatMap((usage) => usage.probe)); + expect(idOnly.credentialFingerprint).toBe(machineA.credentialFingerprint); + expect(idOnly.credentialEmail).toBeUndefined(); }).pipe(Effect.scoped, Effect.provide(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer))), ); diff --git a/packages/shared/src/fork/antigravityEmailBindings.test.ts b/packages/shared/src/fork/antigravityEmailBindings.test.ts new file mode 100644 index 0000000000000..750c5c05ff300 --- /dev/null +++ b/packages/shared/src/fork/antigravityEmailBindings.test.ts @@ -0,0 +1,251 @@ +import { + EnvironmentId, + ProviderDriverKind, + ProviderInstanceId, + type ServerProvider, + type UsageLimitSourceAccount, + UsageLimitSourceId, +} from "@t3tools/contracts"; +import { describe, expect, it } from "vite-plus/test"; + +import { collectLimitAccounts } from "../usageLimits.ts"; + +const antigravity = ProviderDriverKind.make("antigravity"); +const checkedAt = "2026-09-03T11:00:00.000Z"; +const window = { + id: "gemini-weekly", + kind: "weekly", + label: "Gemini ยท Weekly", + usedPercent: 20, + windowDurationMins: 7 * 24 * 60, +} as const; +const limits = (identity: { credentialFingerprint?: string; credentialEmail?: string }) => ({ + checkedAt, + windows: [window], + ...identity, +}); + +type Reader = + | { + readonly kind: "native"; + readonly env: string; + readonly name: string; + readonly fp: string; + readonly email?: string; + } + | { + readonly kind: "hub"; + readonly env: string; + readonly name: string; + readonly fp?: string; + readonly email?: string; + }; + +const paired = (env: string, name: string, fp: string, email: string): Reader => ({ + kind: "native", + env, + name, + fp, + email, +}); +const fingerprintOnly = (env: string, name: string, fp: string): Reader => ({ + kind: "native", + env, + name, + fp, +}); +const answeringHub = (env: string, name: string, fp: string): Reader => ({ + kind: "hub", + env, + name, + fp, +}); +const withholdingHub = (env: string, name: string, email: string): Reader => ({ + kind: "hub", + env, + name, + email, +}); + +function accountsOf(readers: readonly Reader[]) { + const presentations = new Map< + EnvironmentId, + { + entry: { target: { label: string } }; + serverConfig: { + providers: ServerProvider[]; + usageLimitSources: Array<{ + id: UsageLimitSourceId; + kind: "cliproxy"; + label: string; + checkedAt: string; + accounts: UsageLimitSourceAccount[]; + }>; + }; + } + >(); + for (const reader of readers) { + const environmentId = EnvironmentId.make(reader.env); + const presentation = presentations.get(environmentId) ?? { + entry: { target: { label: reader.env } }, + serverConfig: { + providers: [], + usageLimitSources: [ + { + id: UsageLimitSourceId.make("hub"), + kind: "cliproxy", + label: "hub", + checkedAt, + accounts: [], + }, + ], + }, + }; + presentations.set(environmentId, presentation); + if (reader.kind === "native") { + presentation.serverConfig.providers.push({ + instanceId: ProviderInstanceId.make(reader.name), + driver: antigravity, + displayName: reader.name, + enabled: true, + installed: true, + version: null, + status: "ready", + auth: { status: "authenticated" }, + checkedAt, + models: [], + slashCommands: [], + skills: [], + usageLimits: limits({ + credentialFingerprint: reader.fp, + ...(reader.email ? { credentialEmail: reader.email } : {}), + }), + }); + } else { + presentation.serverConfig.usageLimitSources[0]!.accounts.push({ + id: reader.name, + driver: antigravity, + ...(reader.email ? { label: reader.name, email: reader.email } : {}), + usageLimits: limits(reader.fp ? { credentialFingerprint: reader.fp } : {}), + }); + } + } + return collectLimitAccounts(presentations) + .map((account) => `${account.displayName}${account.email ? ` <${account.email}>` : ""}`) + .toSorted(); +} + +describe("Antigravity accounts identified by a Google fingerprint and e-mail pair", () => { + it.each([ + { + name: "a current login and a hub that withholds userinfo", + readers: [ + paired("env-a", "local", "google-x", "x@example.com"), + withholdingHub("env-a", "g1", "X@example.com"), + ], + accounts: ["g1 "], + }, + { + name: "a current login, an older login, an answering hub and a withholding hub", + readers: [ + paired("env-a", "local", "google-x", "x@example.com"), + fingerprintOnly("env-b", "remote", "google-x"), + answeringHub("env-b", "proxy", "google-x"), + withholdingHub("env-a", "g1", "x@example.com"), + ], + accounts: ["g1 "], + }, + { + name: "two Google accounts, each read every way", + readers: [ + paired("env-a", "local-x", "google-x", "x@example.com"), + paired("env-a", "local-y", "google-y", "y@example.com"), + fingerprintOnly("env-b", "remote-x", "google-x"), + fingerprintOnly("env-b", "remote-y", "google-y"), + withholdingHub("env-a", "gx", "x@example.com"), + withholdingHub("env-a", "gy", "y@example.com"), + ], + accounts: ["gx ", "gy "], + }, + { + name: "one Google account whose e-mail changed between two machines", + readers: [ + paired("env-a", "local", "google-x", "old@example.com"), + paired("env-b", "local", "google-x", "new@example.com"), + ], + accounts: ["local"], + }, + { + name: "two fingerprints claiming one e-mail keep apart, and so does the hub listing it", + readers: [ + paired("env-a", "local-x", "google-x", "same@example.com"), + paired("env-b", "remote-y", "google-y", "same@example.com"), + withholdingHub("env-a", "g1", "same@example.com"), + ], + accounts: ["g1 ", "local-x", "remote-y"], + }, + { + name: "an older login and a withholding hub, with no reader that knows both names", + readers: [ + fingerprintOnly("env-a", "local", "google-x"), + withholdingHub("env-a", "g1", "x@example.com"), + ], + accounts: ["g1 ", "local"], + }, + ])("$name", ({ readers, accounts }) => { + expect(accountsOf(readers)).toEqual(accounts); + expect(accountsOf(readers.toReversed())).toEqual(accounts); + }); + + it("leaves other drivers keyed by e-mail first", () => { + const opencode = ProviderDriverKind.make("opencode"); + const accounts = collectLimitAccounts( + new Map([ + [ + EnvironmentId.make("env-a"), + { + entry: { target: { label: "env-a" } }, + serverConfig: { + providers: [ + { + instanceId: ProviderInstanceId.make("opencode"), + driver: opencode, + enabled: true, + installed: true, + version: null, + status: "ready", + auth: { status: "authenticated", email: "same@example.com" }, + checkedAt, + models: [], + slashCommands: [], + skills: [], + usageLimits: limits({ + credentialFingerprint: "key-a", + credentialEmail: "same@example.com", + }), + }, + ], + usageLimitSources: [ + { + id: UsageLimitSourceId.make("hub"), + kind: "cliproxy", + label: "hub", + checkedAt, + accounts: [ + { + id: "go", + driver: opencode, + email: "same@example.com", + usageLimits: limits({}), + }, + ], + }, + ], + }, + }, + ], + ]), + ); + expect(accounts).toHaveLength(1); + }); +}); From f5fe927770ae84318d9e17217c295400085d0d84 Mon Sep 17 00:00:00 2001 From: Renfei Zhou Date: Sat, 14 Nov 2026 20:45:24 +0000 Subject: [PATCH 2/2] fix(fork): show one Antigravity account when a hub also lists it The local Antigravity probe now keeps the e-mail from the same Google userinfo answer that gives its fingerprint, as usageLimits.credentialEmail. On clients, an Antigravity report that names its account only by e-mail (a hub that withholds userinfo) takes the fingerprint that e-mail is paired with, so it pools with the local login and with readers that know only the fingerprint. An e-mail that two fingerprints claim stays unbound, other drivers key as before, and the pooled row keeps the hub's e-mail. Co-Authored-By: Claude Opus 5.5 --- .../antigravityAccountEmail.ts | 21 ++++++++++ .../src/provider/antigravityUsageLimits.ts | 25 +++++++++--- packages/contracts/src/providerUsageLimits.ts | 2 + .../src/fork/antigravityEmailBindings.ts | 38 +++++++++++++++++++ packages/shared/src/usageLimits.ts | 14 +++++-- 5 files changed, 92 insertions(+), 8 deletions(-) create mode 100644 apps/server/src/fork/antigravityAccountEmail/antigravityAccountEmail.ts create mode 100644 packages/shared/src/fork/antigravityEmailBindings.ts diff --git a/apps/server/src/fork/antigravityAccountEmail/antigravityAccountEmail.ts b/apps/server/src/fork/antigravityAccountEmail/antigravityAccountEmail.ts new file mode 100644 index 0000000000000..b0188f89dc528 --- /dev/null +++ b/apps/server/src/fork/antigravityAccountEmail/antigravityAccountEmail.ts @@ -0,0 +1,21 @@ +import * as Option from "effect/Option"; +import * as Schema from "effect/Schema"; + +export interface AntigravityAccountIdentity { + readonly credentialFingerprint: string; + readonly credentialEmail?: string; +} + +const decodeUserInfoEmail = Schema.decodeUnknownOption(Schema.Struct({ email: Schema.String })); + +export function withAntigravityAccountEmail( + credentialFingerprint: string, + userInfo: unknown, +): AntigravityAccountIdentity { + const email = decodeUserInfoEmail(userInfo).pipe( + Option.map(({ email }) => email.trim()), + Option.filter((email) => email.length > 0), + Option.getOrUndefined, + ); + return email ? { credentialFingerprint, credentialEmail: email } : { credentialFingerprint }; +} diff --git a/apps/server/src/provider/antigravityUsageLimits.ts b/apps/server/src/provider/antigravityUsageLimits.ts index 1bd2848aecde5..57841149862f8 100644 --- a/apps/server/src/provider/antigravityUsageLimits.ts +++ b/apps/server/src/provider/antigravityUsageLimits.ts @@ -21,6 +21,10 @@ import { readAntigravityCliUsageLogin, type AntigravityRuntimeState, } from "../fork/antigravityCliRuntime/antigravityCliRuntime.ts"; +import { + type AntigravityAccountIdentity, + withAntigravityAccountEmail, +} from "../fork/antigravityAccountEmail/antigravityAccountEmail.ts"; export function antigravityUserAgent(platform: NodeJS.Platform, architecture: NodeJS.Architecture) { return `antigravity/hub/2.24.0 (aidev_client; os_type=${platform}; arch=${architecture}; cl=963137146)`; @@ -227,7 +231,12 @@ export const makeAntigravityUsageProbe = Effect.fn("makeAntigravityUsageProbe")( const platform = yield* HostProcess.Platform; const architecture = yield* HostProcess.Architecture; let cached: - | { fingerprint: string; account: string; accessToken: string; expiresAt: number } + | { + fingerprint: string; + account: AntigravityAccountIdentity; + accessToken: string; + expiresAt: number; + } | undefined; let generation = 0; @@ -263,7 +272,7 @@ export const makeAntigravityUsageProbe = Effect.fn("makeAntigravityUsageProbe")( ); const now = yield* Clock.currentTimeMillis; let accessToken: string; - let account: string; + let account: AntigravityAccountIdentity; if (cached?.fingerprint !== fingerprint || cached.expiresAt <= now + 60_000) { cached = undefined; const refreshed = yield* request( @@ -283,8 +292,14 @@ export const makeAntigravityUsageProbe = Effect.fn("makeAntigravityUsageProbe")( HttpClientRequest.setHeaders({ Authorization: `Bearer ${accessToken}` }), ), ).pipe( - Effect.flatMap(antigravityAccountFingerprint), - Effect.orElseSucceed(() => fingerprint), + Effect.flatMap((userInfo) => + antigravityAccountFingerprint(userInfo).pipe( + Effect.map((credentialFingerprint) => + withAntigravityAccountEmail(credentialFingerprint, userInfo), + ), + ), + ), + Effect.orElseSucceed(() => ({ credentialFingerprint: fingerprint })), ); if (generation === startedGeneration) { cached = { @@ -349,7 +364,7 @@ export const makeAntigravityUsageProbe = Effect.fn("makeAntigravityUsageProbe")( message: "Antigravity did not return usable quota information.", }); } - return { ...makeUsageLimits({ checkedAt, windows }), credentialFingerprint: account }; + return { ...makeUsageLimits({ checkedAt, windows }), ...account }; }); const probe = read.pipe( Effect.provideService(Digest.Digest, crypto), diff --git a/packages/contracts/src/providerUsageLimits.ts b/packages/contracts/src/providerUsageLimits.ts index e65e81b2596bc..0229b2e513512 100644 --- a/packages/contracts/src/providerUsageLimits.ts +++ b/packages/contracts/src/providerUsageLimits.ts @@ -52,6 +52,8 @@ export const ServerProviderUsageLimits = Schema.Struct({ /** A $0 monthly spend cap has no percentage, so it cannot be a window. */ zeroSpendCap: Schema.optional(Schema.Boolean), credentialFingerprint: Schema.optional(TrimmedNonEmptyString), + /** The account e-mail read in the same identity answer as `credentialFingerprint`. */ + credentialEmail: Schema.optional(TrimmedNonEmptyString), resetCredits: Schema.optional(ServerProviderResetCredits), externalUsage: Schema.optional( Schema.Struct({ diff --git a/packages/shared/src/fork/antigravityEmailBindings.ts b/packages/shared/src/fork/antigravityEmailBindings.ts new file mode 100644 index 0000000000000..decfb36436b85 --- /dev/null +++ b/packages/shared/src/fork/antigravityEmailBindings.ts @@ -0,0 +1,38 @@ +import type { ServerProvider, ServerProviderUsageLimits } from "@t3tools/contracts"; + +interface AccountReport { + readonly driver: ServerProvider["driver"]; + readonly email?: string | undefined; + readonly usageLimits?: ServerProviderUsageLimits | undefined; +} + +const normalizedEmail = (email: string | undefined) => email?.trim().toLowerCase() || undefined; + +/** A hub that withholds `userinfo` names an Antigravity account only by its e-mail. */ +export function bindAntigravityEmails( + reports: readonly AccountReport[], +): (report: AccountReport) => AccountReport { + const fingerprints = new Map(); + const claimedTwice = new Set(); + for (const { driver, usageLimits } of reports) { + const email = normalizedEmail(usageLimits?.credentialEmail); + const fingerprint = usageLimits?.credentialFingerprint; + if (driver !== "antigravity" || !email || !fingerprint) continue; + const bound = fingerprints.get(email); + if (bound !== undefined && bound !== fingerprint) claimedTwice.add(email); + fingerprints.set(email, fingerprint); + } + if (fingerprints.size === 0) return (report) => report; + return (report) => { + const email = normalizedEmail(report.email); + if (report.driver !== "antigravity" || !email || !report.usageLimits) return report; + if (report.usageLimits.credentialFingerprint || claimedTwice.has(email)) return report; + const fingerprint = fingerprints.get(email); + return fingerprint + ? { + driver: report.driver, + usageLimits: { ...report.usageLimits, credentialFingerprint: fingerprint }, + } + : report; + }; +} diff --git a/packages/shared/src/usageLimits.ts b/packages/shared/src/usageLimits.ts index c38ef2fbcf077..d626df83c78cf 100644 --- a/packages/shared/src/usageLimits.ts +++ b/packages/shared/src/usageLimits.ts @@ -19,6 +19,8 @@ import { import * as DateTime from "effect/DateTime"; +import { bindAntigravityEmails } from "./fork/antigravityEmailBindings.ts"; + const MINUTE = 60_000; const HOUR = 60 * MINUTE; const DAY = 24 * HOUR; @@ -232,16 +234,21 @@ const nativeReport = (provider: ServerProvider): AccountReport => ({ }); // Include every enabled login, even unreadable workspaces. -function accountKeyResolver(reports: Iterable) { +function accountKeyResolver(reports: readonly AccountReport[]) { + const bind = bindAntigravityEmails(reports); + const keyOf = (report: AccountReport) => { + const { driver, email, usageLimits } = bind(report); + return accountKey(driver, email, usageLimits); + }; const workspaces = new Map>(); for (const report of reports) { - const key = accountKey(report.driver, report.email, report.usageLimits); + const key = keyOf(report); if (key && report.workspaceId) { workspaces.set(key, (workspaces.get(key) ?? new Set()).add(report.workspaceId)); } } return (report: AccountReport): string | null => { - const key = accountKey(report.driver, report.email, report.usageLimits); + const key = keyOf(report); if (!key) return null; if (report.workspaceId) return `${key}:${report.workspaceId}`; const known = workspaces.get(key); @@ -335,6 +342,7 @@ export function collectLimitAccounts(presentations: LimitPresentations): readonl displayName?.toLowerCase() === PROVIDER_DISPLAY_NAMES[previous.driver]?.toLowerCase() ? null : displayName, + email: previous.email ?? next.email, plan: planSources.get(key)?.plan ?? previous.plan ?? next.plan, accentColor: previous.accentColor ?? next.accentColor, environments,