Skip to content
Open
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
121 changes: 121 additions & 0 deletions apps/server/src/provider/antigravityUsageLimits.test.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,18 @@
import * as NodeServices from "@effect/platform-node/NodeServices";
import { describe, expect, it } from "@effect/vitest";
import {
EnvironmentId,
ProviderDriverKind,
ProviderInstanceId,
type ServerProvider,
type ServerProviderUsageLimits,
} from "@t3tools/contracts";
import { collectLimitAccounts, collectLimitPools } from "@t3tools/shared/usageLimits";
import * as Context from "effect/Context";
import * as Effect from "effect/Effect";
import * as FileSystem from "effect/FileSystem";
import * as Path from "effect/Path";
import * as TestClock from "effect/testing/TestClock";
import { FetchHttpClient, HttpClient, HttpClientResponse } from "effect/http";

import { makeAntigravityUsageProbe } from "./antigravityUsageLimits.ts";
Expand Down Expand Up @@ -55,6 +64,8 @@ const fixture = Effect.fn("AntigravityUsageTest.fixture")(function* (
tokenStatus?: number;
missingToken?: boolean;
authMethod?: "oauth-personal" | "gemini-api-key";
userInfo?: unknown;
userInfoStatus?: number;
} = {},
) {
const fs = yield* FileSystem.FileSystem;
Expand Down Expand Up @@ -92,6 +103,13 @@ const fixture = Effect.fn("AntigravityUsageTest.fixture")(function* (
);
}
expect(request.headers.authorization).toBe("Bearer access-token");
if (request.url.endsWith("/userinfo")) {
expect(request.method).toBe("GET");
return HttpClientResponse.fromWeb(
request,
Response.json(options.userInfo ?? {}, { status: options.userInfoStatus ?? 200 }),
);
}
expect(JSON.parse(body)).toEqual({ project: "project-id" });
return HttpClientResponse.fromWeb(
request,
Expand All @@ -109,6 +127,57 @@ const fixture = Effect.fn("AntigravityUsageTest.fixture")(function* (
});

describe("Antigravity subscription limits", () => {
it.effect("collates independent sign-ins to one Google account across computers", () =>
Effect.gen(function* () {
const first = yield* fixture({ userInfo: { sub: "google-account-a" } });
const second = yield* fixture({ userInfo: { sub: "google-account-a" } });
yield* second.writeToken("refresh-b");
const firstLimits = yield* first.probe;
const secondLimits = yield* second.probe;
const provider = (usageLimits: ServerProviderUsageLimits): ServerProvider => ({
instanceId: ProviderInstanceId.make("antigravity"),
driver: ProviderDriverKind.make("antigravity"),
enabled: true,
installed: true,
version: null,
status: "ready",
auth: { status: "authenticated" },
checkedAt: usageLimits.checkedAt,
models: [],
slashCommands: [],
skills: [],
usageLimits,
});
const accounts = collectLimitAccounts(
new Map([
[
EnvironmentId.make("env-a"),
{
entry: { target: { label: "computer-a" } },
serverConfig: { providers: [provider(firstLimits)] },
},
],
[
EnvironmentId.make("env-b"),
{
entry: { target: { label: "computer-b" } },
serverConfig: { providers: [provider(secondLimits)] },
},
],
]),
);
expect(accounts).toHaveLength(1);
expect(accounts[0]?.environments.map((environment) => environment.label)).toEqual([
"computer-a",
"computer-b",
]);
const pools = collectLimitPools(accounts, Date.parse(firstLimits.checkedAt));
expect(pools).toHaveLength(1);
expect(pools[0]?.windows).toHaveLength(4);
expect(pools[0]?.windows.every((window) => window.members.length === 1)).toBe(true);
}).pipe(Effect.scoped, Effect.provide(NodeServices.layer)),
);

it.effect("reports separate Gemini and shared Claude/GPT five-hour and weekly limits", () =>
Effect.gen(function* () {
const { probe, requests } = yield* fixture();
Expand Down Expand Up @@ -136,6 +205,58 @@ describe("Antigravity subscription limits", () => {
}).pipe(Effect.scoped, Effect.provide(NodeServices.layer)),
);

it.effect("caches account identity but re-reads it when credentials change or are cleared", () =>
Effect.gen(function* () {
const options = { userInfo: { sub: "google-account-a" } };
const { probe, clear, requests, writeToken, tokenPath } = yield* fixture(options);
const first = yield* probe;
expect(JSON.stringify(first)).not.toContain("google-account-a");
yield* TestClock.adjust("1 hour");
expect((yield* probe).credentialFingerprint).toBe(first.credentialFingerprint);
expect(requests.filter((request) => request.url.endsWith("/token"))).toHaveLength(2);
expect(requests.filter((request) => request.url.endsWith("/userinfo"))).toHaveLength(1);

options.userInfo.sub = "google-account-b";
yield* writeToken("refresh-b");
const second = yield* probe;
expect(second.credentialFingerprint).not.toBe(first.credentialFingerprint);
expect(requests.filter((request) => request.url.endsWith("/userinfo"))).toHaveLength(2);

options.userInfo.sub = "google-account-c";
yield* clear;
const third = yield* probe;
expect(third.credentialFingerprint).not.toBe(second.credentialFingerprint);

yield* (yield* FileSystem.FileSystem).remove(tokenPath);
expect((yield* probe).unavailable?.reason).toBe("unsupported");
options.userInfo.sub = "google-account-d";
yield* writeToken("refresh-b");
expect((yield* probe).credentialFingerprint).not.toBe(third.credentialFingerprint);
expect(requests.filter((request) => request.url.endsWith("/userinfo"))).toHaveLength(4);
}).pipe(Effect.scoped, Effect.provide(NodeServices.layer)),
);

it.effect("keeps quota windows and distinct credentials when account lookup fails", () =>
Effect.gen(function* () {
for (const options of [
{ userInfoStatus: 403 },
{ userInfo: {} },
{ userInfo: { sub: "" } },
]) {
const { probe, writeToken } = yield* fixture(options);
const first = yield* probe;
yield* writeToken("refresh-b");
const second = yield* probe;
expect(first.windows).toHaveLength(4);
expect(second.windows).toHaveLength(4);
expect(first.unavailable).toBeUndefined();
expect(second.unavailable).toBeUndefined();
expect(second.credentialFingerprint).not.toBe(first.credentialFingerprint);
expect(JSON.stringify([first, second])).not.toMatch(/refresh-a|refresh-b|access-token/);
}
}).pipe(Effect.scoped, Effect.provide(NodeServices.layer)),
);

it.effect("re-reads changed credentials and clears cached access after sign-out", () =>
Effect.gen(function* () {
const { probe, clear, requests, writeToken, tokenPath } = yield* fixture();
Expand Down
54 changes: 43 additions & 11 deletions apps/server/src/provider/antigravityUsageLimits.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,8 @@
import type { AntigravitySettings, ServerProviderUsageWindow } from "@t3tools/contracts";
import {
type AntigravitySettings,
type ServerProviderUsageWindow,
TrimmedNonEmptyString,
} from "@t3tools/contracts";
import * as HostProcess from "@t3tools/shared/HostProcess";
import * as Clock from "effect/Clock";
import * as Crypto from "effect/Crypto";
Expand Down Expand Up @@ -30,6 +34,7 @@ const TokenResponse = Schema.Struct({
access_token: Schema.NonEmptyString,
expires_in: Schema.Number.check(Schema.isGreaterThan(0)),
});
const UserInfo = Schema.Struct({ sub: TrimmedNonEmptyString });
const Quota = Schema.Struct({
remainingFraction: Schema.optional(Schema.Number),
resetTime: Schema.optional(Schema.String),
Expand Down Expand Up @@ -169,8 +174,18 @@ export const makeAntigravityUsageProbe = Effect.fn("makeAntigravityUsageProbe")(
const architecture = yield* HostProcess.Architecture;
const tokenPath = path.join(input.profileDirectory, "antigravity-acp", "acp_token.json");
let cached: { fingerprint: string; accessToken: string; expiresAt: number } | undefined;
let account: { credentialFingerprint: string; fingerprint: string } | undefined;
let generation = 0;

const hash = (value: string) =>
crypto
.digest("SHA-256", new TextEncoder().encode(value))
.pipe(
Effect.map((bytes) =>
Array.from(bytes, (byte) => byte.toString(16).padStart(2, "0")).join(""),
),
);

const request = (request: HttpClientRequest.HttpClientRequest) =>
client.execute(request).pipe(
Effect.provideService(FetchHttpClient.RequestInit, MANUAL_REDIRECT),
Expand All @@ -186,22 +201,18 @@ export const makeAntigravityUsageProbe = Effect.fn("makeAntigravityUsageProbe")(
}
if (!(yield* fs.exists(tokenPath))) {
cached = undefined;
account = undefined;
return makeUnavailableUsageLimits({
checkedAt,
reason: "unsupported",
message: "Sign in with Google to see Antigravity limits.",
});
}
const token = yield* fs.readFileString(tokenPath).pipe(Effect.flatMap(decodeTokenFile));
const fingerprint = Array.from(
yield* crypto.digest(
"SHA-256",
new TextEncoder().encode(
`${token.client_id}\0${token.refresh_token}\0${token.project_id ?? ""}`,
),
),
(byte) => byte.toString(16).padStart(2, "0"),
).join("");
const fingerprint = yield* hash(
`${token.client_id}\0${token.refresh_token}\0${token.project_id ?? ""}`,
);
if (account?.credentialFingerprint !== fingerprint) account = undefined;
const now = yield* Clock.currentTimeMillis;
let accessToken: string;
if (cached?.fingerprint !== fingerprint || cached.expiresAt <= now + 60_000) {
Expand All @@ -223,6 +234,23 @@ export const makeAntigravityUsageProbe = Effect.fn("makeAntigravityUsageProbe")(
} else {
accessToken = cached.accessToken;
}
// Separate sign-ins issue different refresh tokens for the same subscription.
// Google's stable subject joins them without exposing the account identifier.
const accountFingerprint =
account?.fingerprint ??
(yield* request(
HttpClientRequest.get("https://openidconnect.googleapis.com/v1/userinfo").pipe(
HttpClientRequest.bearerToken(accessToken),
),
).pipe(
Effect.flatMap(Schema.decodeUnknownEffect(UserInfo)),
Effect.flatMap((user) => hash(`antigravity:google-account:${user.sub}`)),
Effect.timeout("3 seconds"),
Effect.orElseSucceed(() => undefined),
));
if (accountFingerprint && generation === startedGeneration) {
account = { credentialFingerprint: fingerprint, fingerprint: accountFingerprint };
}
const fetchWindows = Effect.fn("AntigravityUsageProbe.fetchWindows")(function* <E>(
operation: string,
parse: (response: unknown) => Effect.Effect<ServerProviderUsageWindow[], E>,
Expand Down Expand Up @@ -259,7 +287,10 @@ export const makeAntigravityUsageProbe = Effect.fn("makeAntigravityUsageProbe")(
message: "Antigravity did not return usable quota information.",
});
}
return { ...makeUsageLimits({ checkedAt, windows }), credentialFingerprint: fingerprint };
return {
...makeUsageLimits({ checkedAt, windows }),
credentialFingerprint: accountFingerprint ?? fingerprint,
};
});
const probe = read.pipe(
Effect.timeout("30 seconds"),
Expand All @@ -277,6 +308,7 @@ export const makeAntigravityUsageProbe = Effect.fn("makeAntigravityUsageProbe")(
probe,
clear: Effect.sync(() => {
cached = undefined;
account = undefined;
generation++;
}),
};
Expand Down
Loading