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* (...@@ -53,6 +53,9 @@ const fixture = Effect.fn("AntigravityUsageTest.fixture")(function* (
53 models?: unknown;53 models?: unknown;
54 summaryStatus?: number;54 summaryStatus?: number;
55 tokenStatus?: number;55 tokenStatus?: number;
56 refreshToken?: string;
57 /** Google account id from userinfo; omit to make userinfo fail. */
58 userId?: string;
56 missingToken?: boolean;59 missingToken?: boolean;
57 authMethod?: "oauth-personal" | "gemini-api-key";60 authMethod?: "oauth-personal" | "gemini-api-key";
58 } = {},61 } = {},
...@@ -72,7 +75,7 @@ const fixture = Effect.fn("AntigravityUsageTest.fixture")(function* (...@@ -72,7 +75,7 @@ const fixture = Effect.fn("AntigravityUsageTest.fixture")(function* (
72 project_id: "project-id",75 project_id: "project-id",
73 }),76 }),
74 );77 );
75 if (!options.missingToken) yield* writeToken("refresh-a");78 if (!options.missingToken) yield* writeToken(options.refreshToken ?? "refresh-a");
76 const requests: Array<{ url: string; body: string }> = [];79 const requests: Array<{ url: string; body: string }> = [];
77 const http = HttpClient.make((request, _url, _signal, fiber) =>80 const http = HttpClient.make((request, _url, _signal, fiber) =>
78 Effect.sync(() => {81 Effect.sync(() => {
...@@ -92,6 +95,14 @@ const fixture = Effect.fn("AntigravityUsageTest.fixture")(function* (...@@ -92,6 +95,14 @@ const fixture = Effect.fn("AntigravityUsageTest.fixture")(function* (
92 );95 );
93 }96 }
94 expect(request.headers.authorization).toBe("Bearer access-token");97 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 }
95 expect(JSON.parse(body)).toEqual({ project: "project-id" });106 expect(JSON.parse(body)).toEqual({ project: "project-id" });
96 return HttpClientResponse.fromWeb(107 return HttpClientResponse.fromWeb(
97 request,108 request,
...@@ -142,6 +153,7 @@ describe("Antigravity subscription limits", () => {...@@ -142,6 +153,7 @@ describe("Antigravity subscription limits", () => {
142 const first = yield* probe;153 const first = yield* probe;
143 yield* writeToken("refresh-b");154 yield* writeToken("refresh-b");
144 const second = yield* probe;155 const second = yield* probe;
156 // Without a readable Google account, the credential itself names the account.
145 expect(second.credentialFingerprint).not.toBe(first.credentialFingerprint);157 expect(second.credentialFingerprint).not.toBe(first.credentialFingerprint);
146 expect(requests.filter((request) => request.url.endsWith("/token"))).toHaveLength(2);158 expect(requests.filter((request) => request.url.endsWith("/token"))).toHaveLength(2);
147 yield* (yield* FileSystem.FileSystem).remove(tokenPath);159 yield* (yield* FileSystem.FileSystem).remove(tokenPath);
...@@ -155,6 +167,20 @@ describe("Antigravity subscription limits", () => {...@@ -155,6 +167,20 @@ describe("Antigravity subscription limits", () => {
155 }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)),167 }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)),
156 );168 );
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
158 it.effect("falls back to model quotas without inventing a window duration", () =>184 it.effect("falls back to model quotas without inventing a window duration", () =>
159 Effect.gen(function* () {185 Effect.gen(function* () {
160 const { probe } = yield* fixture({186 const { probe } = yield* fixture({
apps/server/src/provider/antigravityUsageLimits.ts+37-12
...@@ -30,6 +30,7 @@ const TokenResponse = Schema.Struct({...@@ -30,6 +30,7 @@ const TokenResponse = Schema.Struct({
30 access_token: Schema.NonEmptyString,30 access_token: Schema.NonEmptyString,
31 expires_in: Schema.Number.check(Schema.isGreaterThan(0)),31 expires_in: Schema.Number.check(Schema.isGreaterThan(0)),
32});32});
33const UserInfo = Schema.Struct({ id: Schema.NonEmptyString });
33const Quota = Schema.Struct({34const Quota = Schema.Struct({
34 remainingFraction: Schema.optional(Schema.Number),35 remainingFraction: Schema.optional(Schema.Number),
35 resetTime: Schema.optional(Schema.String),36 resetTime: Schema.optional(Schema.String),
...@@ -65,6 +66,7 @@ const Models = Schema.Struct({...@@ -65,6 +66,7 @@ const Models = Schema.Struct({
6566
66const decodeTokenFile = Schema.decodeEffect(Schema.fromJsonString(TokenFile));67const decodeTokenFile = Schema.decodeEffect(Schema.fromJsonString(TokenFile));
67const decodeTokenResponse = Schema.decodeUnknownEffect(TokenResponse);68const decodeTokenResponse = Schema.decodeUnknownEffect(TokenResponse);
69const decodeUserInfo = Schema.decodeUnknownEffect(UserInfo);
68const decodeSummary = Schema.decodeUnknownEffect(Summary);70const decodeSummary = Schema.decodeUnknownEffect(Summary);
69const decodeModels = Schema.decodeUnknownEffect(Models);71const decodeModels = Schema.decodeUnknownEffect(Models);
7072
...@@ -168,9 +170,20 @@ export const makeAntigravityUsageProbe = Effect.fn("makeAntigravityUsageProbe")(...@@ -168,9 +170,20 @@ export const makeAntigravityUsageProbe = Effect.fn("makeAntigravityUsageProbe")(
168 const platform = yield* HostProcess.Platform;170 const platform = yield* HostProcess.Platform;
169 const architecture = yield* HostProcess.Architecture;171 const architecture = yield* HostProcess.Architecture;
170 const tokenPath = path.join(input.profileDirectory, "antigravity-acp", "acp_token.json");172 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;
172 let generation = 0;176 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
174 const request = (request: HttpClientRequest.HttpClientRequest) =>187 const request = (request: HttpClientRequest.HttpClientRequest) =>
175 client.execute(request).pipe(188 client.execute(request).pipe(
176 Effect.provideService(FetchHttpClient.RequestInit, MANUAL_REDIRECT),189 Effect.provideService(FetchHttpClient.RequestInit, MANUAL_REDIRECT),
...@@ -193,17 +206,12 @@ export const makeAntigravityUsageProbe = Effect.fn("makeAntigravityUsageProbe")(...@@ -193,17 +206,12 @@ export const makeAntigravityUsageProbe = Effect.fn("makeAntigravityUsageProbe")(
193 });206 });
194 }207 }
195 const token = yield* fs.readFileString(tokenPath).pipe(Effect.flatMap(decodeTokenFile));208 const token = yield* fs.readFileString(tokenPath).pipe(Effect.flatMap(decodeTokenFile));
196 const fingerprint = Array.from(209 const fingerprint = yield* sha256(
197 yield* crypto.digest(210 `${token.client_id}\0${token.refresh_token}\0${token.project_id ?? ""}`,
198 "SHA-256",211 );
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("");
205 const now = yield* Clock.currentTimeMillis;212 const now = yield* Clock.currentTimeMillis;
206 let accessToken: string;213 let accessToken: string;
214 let account: string;
207 if (cached?.fingerprint !== fingerprint || cached.expiresAt <= now + 60_000) {215 if (cached?.fingerprint !== fingerprint || cached.expiresAt <= now + 60_000) {
208 cached = undefined;216 cached = undefined;
209 const refreshed = yield* request(217 const refreshed = yield* request(
...@@ -217,11 +225,28 @@ export const makeAntigravityUsageProbe = Effect.fn("makeAntigravityUsageProbe")(...@@ -217,11 +225,28 @@ export const makeAntigravityUsageProbe = Effect.fn("makeAntigravityUsageProbe")(
217 ),225 ),
218 ).pipe(Effect.flatMap(decodeTokenResponse));226 ).pipe(Effect.flatMap(decodeTokenResponse));
219 accessToken = refreshed.access_token;227 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 );
220 if (generation === startedGeneration) {239 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 };
222 }246 }
223 } else {247 } else {
224 accessToken = cached.accessToken;248 accessToken = cached.accessToken;
249 account = cached.account;
225 }250 }
226 const fetchWindows = Effect.fn("AntigravityUsageProbe.fetchWindows")(function* <E>(251 const fetchWindows = Effect.fn("AntigravityUsageProbe.fetchWindows")(function* <E>(
227 operation: string,252 operation: string,
...@@ -259,7 +284,7 @@ export const makeAntigravityUsageProbe = Effect.fn("makeAntigravityUsageProbe")(...@@ -259,7 +284,7 @@ export const makeAntigravityUsageProbe = Effect.fn("makeAntigravityUsageProbe")(
259 message: "Antigravity did not return usable quota information.",284 message: "Antigravity did not return usable quota information.",
260 });285 });
261 }286 }
262 return { ...makeUsageLimits({ checkedAt, windows }), credentialFingerprint: fingerprint };287 return { ...makeUsageLimits({ checkedAt, windows }), credentialFingerprint: account };
263 });288 });
264 const probe = read.pipe(289 const probe = read.pipe(
265 Effect.timeout("30 seconds"),290 Effect.timeout("30 seconds"),