authorgravatar for me@t3.ggTheo Browne <me@t3.gg> 2026-10-13 18:59:39-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-10-13 18:59:39-07:00
log91e93a25ae054c63d51b89ef4ae8ef766403a5d9
treea6e29edbffb25d7d10f9008c274700f37eb96ae7
parent85272eeeae53c5baa1cba1a36d84bbec81a3a0b5
signature Signed by PGP key B5690EEEBB952194

fix(usage): show one Antigravity account once, not once per machine (#19207)

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

2 files changed, 64 insertions(+), 13 deletions(-)

apps/server/src/provider/antigravityUsageLimits.test.ts+27-1
......@@ -53,6 +53,9 @@ const fixture = Effect.fn("AntigravityUsageTest.fixture")(function* (
5353 models?: unknown;
5454 summaryStatus?: number;
5555 tokenStatus?: number;
56 refreshToken?: string;
57 /** Google account id from userinfo; omit to make userinfo fail. */
58 userId?: string;
5659 missingToken?: boolean;
5760 authMethod?: "oauth-personal" | "gemini-api-key";
5861 } = {},
......@@ -72,7 +75,7 @@ const fixture = Effect.fn("AntigravityUsageTest.fixture")(function* (
7275 project_id: "project-id",
7376 }),
7477 );
75 if (!options.missingToken) yield* writeToken("refresh-a");
78 if (!options.missingToken) yield* writeToken(options.refreshToken ?? "refresh-a");
7679 const requests: Array<{ url: string; body: string }> = [];
7780 const http = HttpClient.make((request, _url, _signal, fiber) =>
7881 Effect.sync(() => {
......@@ -92,6 +95,14 @@ const fixture = Effect.fn("AntigravityUsageTest.fixture")(function* (
9295 );
9396 }
9497 expect(request.headers.authorization).toBe("Bearer access-token");
98 if (request.url.endsWith("/userinfo")) {
99 return HttpClientResponse.fromWeb(
100 request,
101 options.userId
102 ? Response.json({ id: options.userId, email: "someone@example.com" })
103 : Response.json({}, { status: 403 }),
104 );
105 }
95106 expect(JSON.parse(body)).toEqual({ project: "project-id" });
96107 return HttpClientResponse.fromWeb(
97108 request,
......@@ -142,6 +153,7 @@ describe("Antigravity subscription limits", () => {
142153 const first = yield* probe;
143154 yield* writeToken("refresh-b");
144155 const second = yield* probe;
156 // Without a readable Google account, the credential itself names the account.
145157 expect(second.credentialFingerprint).not.toBe(first.credentialFingerprint);
146158 expect(requests.filter((request) => request.url.endsWith("/token"))).toHaveLength(2);
147159 yield* (yield* FileSystem.FileSystem).remove(tokenPath);
......@@ -155,6 +167,20 @@ describe("Antigravity subscription limits", () => {
155167 }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)),
156168 );
157169
170 it.effect("names one Google account the same way from every sign-in", () =>
171 Effect.gen(function* () {
172 const read = (refreshToken: string, userId: string) =>
173 fixture({ refreshToken, userId }).pipe(Effect.flatMap((usage) => usage.probe));
174 const machineA = yield* read("refresh-a", "google-user-1");
175 const machineB = yield* read("refresh-b", "google-user-1");
176 const otherAccount = yield* read("refresh-c", "google-user-2");
177 expect(machineA.credentialFingerprint).toMatch(/^[0-9a-f]{64}$/);
178 expect(machineB.credentialFingerprint).toBe(machineA.credentialFingerprint);
179 expect(otherAccount.credentialFingerprint).not.toBe(machineA.credentialFingerprint);
180 expect(JSON.stringify(machineA)).not.toMatch(/google-user-1|someone@example.com/);
181 }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)),
182 );
183
158184 it.effect("falls back to model quotas without inventing a window duration", () =>
159185 Effect.gen(function* () {
160186 const { probe } = yield* fixture({
apps/server/src/provider/antigravityUsageLimits.ts+37-12
......@@ -30,6 +30,7 @@ const TokenResponse = Schema.Struct({
3030 access_token: Schema.NonEmptyString,
3131 expires_in: Schema.Number.check(Schema.isGreaterThan(0)),
3232});
33const UserInfo = Schema.Struct({ id: Schema.NonEmptyString });
3334const Quota = Schema.Struct({
3435 remainingFraction: Schema.optional(Schema.Number),
3536 resetTime: Schema.optional(Schema.String),
......@@ -65,6 +66,7 @@ const Models = Schema.Struct({
6566
6667const decodeTokenFile = Schema.decodeEffect(Schema.fromJsonString(TokenFile));
6768const decodeTokenResponse = Schema.decodeUnknownEffect(TokenResponse);
69const decodeUserInfo = Schema.decodeUnknownEffect(UserInfo);
6870const decodeSummary = Schema.decodeUnknownEffect(Summary);
6971const decodeModels = Schema.decodeUnknownEffect(Models);
7072
......@@ -168,9 +170,20 @@ export const makeAntigravityUsageProbe = Effect.fn("makeAntigravityUsageProbe")(
168170 const platform = yield* HostProcess.Platform;
169171 const architecture = yield* HostProcess.Architecture;
170172 const tokenPath = path.join(input.profileDirectory, "antigravity-acp", "acp_token.json");
171 let cached: { fingerprint: string; accessToken: string; expiresAt: number } | undefined;
173 let cached:
174 | { fingerprint: string; account: string; accessToken: string; expiresAt: number }
175 | undefined;
172176 let generation = 0;
173177
178 const sha256 = (text: string) =>
179 crypto
180 .digest("SHA-256", new TextEncoder().encode(text))
181 .pipe(
182 Effect.map((digest) =>
183 Array.from(digest, (byte) => byte.toString(16).padStart(2, "0")).join(""),
184 ),
185 );
186
174187 const request = (request: HttpClientRequest.HttpClientRequest) =>
175188 client.execute(request).pipe(
176189 Effect.provideService(FetchHttpClient.RequestInit, MANUAL_REDIRECT),
......@@ -193,17 +206,12 @@ export const makeAntigravityUsageProbe = Effect.fn("makeAntigravityUsageProbe")(
193206 });
194207 }
195208 const token = yield* fs.readFileString(tokenPath).pipe(Effect.flatMap(decodeTokenFile));
196 const fingerprint = Array.from(
197 yield* crypto.digest(
198 "SHA-256",
199 new TextEncoder().encode(
200 `${token.client_id}\0${token.refresh_token}\0${token.project_id ?? ""}`,
201 ),
202 ),
203 (byte) => byte.toString(16).padStart(2, "0"),
204 ).join("");
209 const fingerprint = yield* sha256(
210 `${token.client_id}\0${token.refresh_token}\0${token.project_id ?? ""}`,
211 );
205212 const now = yield* Clock.currentTimeMillis;
206213 let accessToken: string;
214 let account: string;
207215 if (cached?.fingerprint !== fingerprint || cached.expiresAt <= now + 60_000) {
208216 cached = undefined;
209217 const refreshed = yield* request(
......@@ -217,11 +225,28 @@ export const makeAntigravityUsageProbe = Effect.fn("makeAntigravityUsageProbe")(
217225 ),
218226 ).pipe(Effect.flatMap(decodeTokenResponse));
219227 accessToken = refreshed.access_token;
228 // Each machine's sign-in has its own refresh token, so the Google
229 // account id is what lets clients merge one account across environments.
230 account = yield* request(
231 HttpClientRequest.get("https://www.googleapis.com/oauth2/v2/userinfo").pipe(
232 HttpClientRequest.setHeaders({ Authorization: `Bearer ${accessToken}` }),
233 ),
234 ).pipe(
235 Effect.flatMap(decodeUserInfo),
236 Effect.flatMap((info) => sha256(`google-account\0${info.id}`)),
237 Effect.orElseSucceed(() => fingerprint),
238 );
220239 if (generation === startedGeneration) {
221 cached = { fingerprint, accessToken, expiresAt: now + refreshed.expires_in * 1000 };
240 cached = {
241 fingerprint,
242 account,
243 accessToken,
244 expiresAt: now + refreshed.expires_in * 1000,
245 };
222246 }
223247 } else {
224248 accessToken = cached.accessToken;
249 account = cached.account;
225250 }
226251 const fetchWindows = Effect.fn("AntigravityUsageProbe.fetchWindows")(function* <E>(
227252 operation: string,
......@@ -259,7 +284,7 @@ export const makeAntigravityUsageProbe = Effect.fn("makeAntigravityUsageProbe")(
259284 message: "Antigravity did not return usable quota information.",
260285 });
261286 }
262 return { ...makeUsageLimits({ checkedAt, windows }), credentialFingerprint: fingerprint };
287 return { ...makeUsageLimits({ checkedAt, windows }), credentialFingerprint: account };
263288 });
264289 const probe = read.pipe(
265290 Effect.timeout("30 seconds"),