mirror of
https://github.com/NoeFabris/opencode-antigravity-auth.git
synced 2026-05-21 21:04:55 +00:00
- File locking via proper-lockfile for concurrent write safety - Atomic writes with temp file + rename pattern - Merge-on-write pattern to preserve concurrent changes - PID-based offset so different sessions start at different accounts - Remove unused import (extractVariantThinkingConfig)
412 lines
12 KiB
TypeScript
412 lines
12 KiB
TypeScript
import { promises as fs } from "node:fs";
|
|
import { dirname, join } from "node:path";
|
|
import { homedir } from "node:os";
|
|
import { randomBytes } from "node:crypto";
|
|
import lockfile from "proper-lockfile";
|
|
import type { HeaderStyle } from "../constants";
|
|
import { createLogger } from "./logger";
|
|
|
|
const log = createLogger("storage");
|
|
|
|
export type ModelFamily = "claude" | "gemini";
|
|
export type { HeaderStyle };
|
|
|
|
export interface RateLimitState {
|
|
claude?: number;
|
|
gemini?: number;
|
|
}
|
|
|
|
export interface RateLimitStateV3 {
|
|
claude?: number;
|
|
"gemini-antigravity"?: number;
|
|
"gemini-cli"?: number;
|
|
[key: string]: number | undefined;
|
|
}
|
|
|
|
export interface AccountMetadataV1 {
|
|
email?: string;
|
|
refreshToken: string;
|
|
projectId?: string;
|
|
managedProjectId?: string;
|
|
addedAt: number;
|
|
lastUsed: number;
|
|
isRateLimited?: boolean;
|
|
rateLimitResetTime?: number;
|
|
lastSwitchReason?: "rate-limit" | "initial" | "rotation";
|
|
}
|
|
|
|
export interface AccountStorageV1 {
|
|
version: 1;
|
|
accounts: AccountMetadataV1[];
|
|
activeIndex: number;
|
|
}
|
|
|
|
export interface AccountMetadata {
|
|
email?: string;
|
|
refreshToken: string;
|
|
projectId?: string;
|
|
managedProjectId?: string;
|
|
addedAt: number;
|
|
lastUsed: number;
|
|
lastSwitchReason?: "rate-limit" | "initial" | "rotation";
|
|
rateLimitResetTimes?: RateLimitState;
|
|
}
|
|
|
|
export interface AccountStorage {
|
|
version: 2;
|
|
accounts: AccountMetadata[];
|
|
activeIndex: number;
|
|
}
|
|
|
|
export type CooldownReason = "auth-failure" | "network-error" | "project-error";
|
|
|
|
export interface AccountMetadataV3 {
|
|
email?: string;
|
|
refreshToken: string;
|
|
projectId?: string;
|
|
managedProjectId?: string;
|
|
addedAt: number;
|
|
lastUsed: number;
|
|
lastSwitchReason?: "rate-limit" | "initial" | "rotation";
|
|
rateLimitResetTimes?: RateLimitStateV3;
|
|
coolingDownUntil?: number;
|
|
cooldownReason?: CooldownReason;
|
|
}
|
|
|
|
export interface AccountStorageV3 {
|
|
version: 3;
|
|
accounts: AccountMetadataV3[];
|
|
activeIndex: number;
|
|
activeIndexByFamily?: {
|
|
claude?: number;
|
|
gemini?: number;
|
|
};
|
|
}
|
|
|
|
type AnyAccountStorage = AccountStorageV1 | AccountStorage | AccountStorageV3;
|
|
|
|
function getConfigDir(): string {
|
|
const platform = process.platform;
|
|
if (platform === "win32") {
|
|
return join(process.env.APPDATA || join(homedir(), "AppData", "Roaming"), "opencode");
|
|
}
|
|
|
|
const xdgConfig = process.env.XDG_CONFIG_HOME || join(homedir(), ".config");
|
|
return join(xdgConfig, "opencode");
|
|
}
|
|
|
|
export function getStoragePath(): string {
|
|
return join(getConfigDir(), "antigravity-accounts.json");
|
|
}
|
|
|
|
const LOCK_OPTIONS = {
|
|
stale: 10000,
|
|
retries: {
|
|
retries: 5,
|
|
minTimeout: 100,
|
|
maxTimeout: 1000,
|
|
factor: 2,
|
|
},
|
|
};
|
|
|
|
async function ensureFileExists(path: string): Promise<void> {
|
|
try {
|
|
await fs.access(path);
|
|
} catch {
|
|
await fs.mkdir(dirname(path), { recursive: true });
|
|
await fs.writeFile(path, JSON.stringify({ version: 3, accounts: [], activeIndex: 0 }, null, 2), "utf-8");
|
|
}
|
|
}
|
|
|
|
async function withFileLock<T>(path: string, fn: () => Promise<T>): Promise<T> {
|
|
await ensureFileExists(path);
|
|
let release: (() => Promise<void>) | null = null;
|
|
try {
|
|
release = await lockfile.lock(path, LOCK_OPTIONS);
|
|
return await fn();
|
|
} finally {
|
|
if (release) {
|
|
try {
|
|
await release();
|
|
} catch (unlockError) {
|
|
log.warn("Failed to release lock", { error: String(unlockError) });
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
function mergeAccountStorage(existing: AccountStorageV3, incoming: AccountStorageV3): AccountStorageV3 {
|
|
const accountMap = new Map<string, AccountMetadataV3>();
|
|
|
|
for (const acc of existing.accounts) {
|
|
if (acc.refreshToken) {
|
|
accountMap.set(acc.refreshToken, acc);
|
|
}
|
|
}
|
|
|
|
for (const acc of incoming.accounts) {
|
|
if (acc.refreshToken) {
|
|
const existingAcc = accountMap.get(acc.refreshToken);
|
|
if (existingAcc) {
|
|
accountMap.set(acc.refreshToken, {
|
|
...existingAcc,
|
|
...acc,
|
|
rateLimitResetTimes: {
|
|
...existingAcc.rateLimitResetTimes,
|
|
...acc.rateLimitResetTimes,
|
|
},
|
|
lastUsed: Math.max(existingAcc.lastUsed || 0, acc.lastUsed || 0),
|
|
});
|
|
} else {
|
|
accountMap.set(acc.refreshToken, acc);
|
|
}
|
|
}
|
|
}
|
|
|
|
return {
|
|
version: 3,
|
|
accounts: Array.from(accountMap.values()),
|
|
activeIndex: incoming.activeIndex,
|
|
activeIndexByFamily: incoming.activeIndexByFamily,
|
|
};
|
|
}
|
|
|
|
export function deduplicateAccountsByEmail<T extends { email?: string; lastUsed?: number; addedAt?: number }>(accounts: T[]): T[] {
|
|
const emailToNewestIndex = new Map<string, number>();
|
|
const indicesToKeep = new Set<number>();
|
|
|
|
// First pass: find the newest account for each email (by lastUsed, then addedAt)
|
|
for (let i = 0; i < accounts.length; i++) {
|
|
const acc = accounts[i];
|
|
if (!acc) continue;
|
|
|
|
if (!acc.email) {
|
|
// No email - keep this account (can't deduplicate without email)
|
|
indicesToKeep.add(i);
|
|
continue;
|
|
}
|
|
|
|
const existingIndex = emailToNewestIndex.get(acc.email);
|
|
if (existingIndex === undefined) {
|
|
emailToNewestIndex.set(acc.email, i);
|
|
continue;
|
|
}
|
|
|
|
// Compare to find which is newer
|
|
const existing = accounts[existingIndex];
|
|
if (!existing) {
|
|
emailToNewestIndex.set(acc.email, i);
|
|
continue;
|
|
}
|
|
|
|
// Prefer higher lastUsed, then higher addedAt
|
|
// Compare fields separately to avoid integer overflow with large timestamps
|
|
const currLastUsed = acc.lastUsed || 0;
|
|
const existLastUsed = existing.lastUsed || 0;
|
|
const currAddedAt = acc.addedAt || 0;
|
|
const existAddedAt = existing.addedAt || 0;
|
|
|
|
const isNewer = currLastUsed > existLastUsed ||
|
|
(currLastUsed === existLastUsed && currAddedAt > existAddedAt);
|
|
|
|
if (isNewer) {
|
|
emailToNewestIndex.set(acc.email, i);
|
|
}
|
|
}
|
|
|
|
// Add all the newest email-based indices to the keep set
|
|
for (const idx of emailToNewestIndex.values()) {
|
|
indicesToKeep.add(idx);
|
|
}
|
|
|
|
// Build the deduplicated list, preserving original order for kept items
|
|
const result: T[] = [];
|
|
for (let i = 0; i < accounts.length; i++) {
|
|
if (indicesToKeep.has(i)) {
|
|
const acc = accounts[i];
|
|
if (acc) {
|
|
result.push(acc);
|
|
}
|
|
}
|
|
}
|
|
|
|
return result;
|
|
}
|
|
|
|
function migrateV1ToV2(v1: AccountStorageV1): AccountStorage {
|
|
return {
|
|
version: 2,
|
|
accounts: v1.accounts.map((acc) => {
|
|
const rateLimitResetTimes: RateLimitState = {};
|
|
if (acc.isRateLimited && acc.rateLimitResetTime && acc.rateLimitResetTime > Date.now()) {
|
|
rateLimitResetTimes.claude = acc.rateLimitResetTime;
|
|
rateLimitResetTimes.gemini = acc.rateLimitResetTime;
|
|
}
|
|
return {
|
|
email: acc.email,
|
|
refreshToken: acc.refreshToken,
|
|
projectId: acc.projectId,
|
|
managedProjectId: acc.managedProjectId,
|
|
addedAt: acc.addedAt,
|
|
lastUsed: acc.lastUsed,
|
|
lastSwitchReason: acc.lastSwitchReason,
|
|
rateLimitResetTimes: Object.keys(rateLimitResetTimes).length > 0 ? rateLimitResetTimes : undefined,
|
|
};
|
|
}),
|
|
activeIndex: v1.activeIndex,
|
|
};
|
|
}
|
|
|
|
export function migrateV2ToV3(v2: AccountStorage): AccountStorageV3 {
|
|
return {
|
|
version: 3,
|
|
accounts: v2.accounts.map((acc) => {
|
|
const rateLimitResetTimes: RateLimitStateV3 = {};
|
|
if (acc.rateLimitResetTimes?.claude && acc.rateLimitResetTimes.claude > Date.now()) {
|
|
rateLimitResetTimes.claude = acc.rateLimitResetTimes.claude;
|
|
}
|
|
if (acc.rateLimitResetTimes?.gemini && acc.rateLimitResetTimes.gemini > Date.now()) {
|
|
rateLimitResetTimes["gemini-antigravity"] = acc.rateLimitResetTimes.gemini;
|
|
}
|
|
return {
|
|
email: acc.email,
|
|
refreshToken: acc.refreshToken,
|
|
projectId: acc.projectId,
|
|
managedProjectId: acc.managedProjectId,
|
|
addedAt: acc.addedAt,
|
|
lastUsed: acc.lastUsed,
|
|
lastSwitchReason: acc.lastSwitchReason,
|
|
rateLimitResetTimes: Object.keys(rateLimitResetTimes).length > 0 ? rateLimitResetTimes : undefined,
|
|
};
|
|
}),
|
|
activeIndex: v2.activeIndex,
|
|
};
|
|
}
|
|
|
|
export async function loadAccounts(): Promise<AccountStorageV3 | null> {
|
|
try {
|
|
const path = getStoragePath();
|
|
const content = await fs.readFile(path, "utf-8");
|
|
const data = JSON.parse(content) as AnyAccountStorage;
|
|
|
|
if (!Array.isArray(data.accounts)) {
|
|
log.warn("Invalid storage format, ignoring");
|
|
return null;
|
|
}
|
|
|
|
let storage: AccountStorageV3;
|
|
|
|
if (data.version === 1) {
|
|
log.info("Migrating account storage from v1 to v3");
|
|
const v2 = migrateV1ToV2(data);
|
|
storage = migrateV2ToV3(v2);
|
|
try {
|
|
await saveAccounts(storage);
|
|
log.info("Migration to v3 complete");
|
|
} catch (saveError) {
|
|
log.warn("Failed to persist migrated storage", { error: String(saveError) });
|
|
}
|
|
} else if (data.version === 2) {
|
|
log.info("Migrating account storage from v2 to v3");
|
|
storage = migrateV2ToV3(data);
|
|
try {
|
|
await saveAccounts(storage);
|
|
log.info("Migration to v3 complete");
|
|
} catch (saveError) {
|
|
log.warn("Failed to persist migrated storage", { error: String(saveError) });
|
|
}
|
|
} else if (data.version === 3) {
|
|
storage = data;
|
|
} else {
|
|
log.warn("Unknown storage version, ignoring", {
|
|
version: (data as { version?: unknown }).version,
|
|
});
|
|
return null;
|
|
}
|
|
|
|
// Validate accounts have required fields
|
|
const validAccounts = storage.accounts.filter((a): a is AccountMetadataV3 => {
|
|
return !!a && typeof a === "object" && typeof (a as AccountMetadataV3).refreshToken === "string";
|
|
});
|
|
|
|
// Deduplicate accounts by email (keeps newest entry for each email)
|
|
const deduplicatedAccounts = deduplicateAccountsByEmail(validAccounts);
|
|
|
|
// Clamp activeIndex to valid range after deduplication
|
|
let activeIndex = typeof storage.activeIndex === "number" && Number.isFinite(storage.activeIndex) ? storage.activeIndex : 0;
|
|
if (deduplicatedAccounts.length > 0) {
|
|
activeIndex = Math.min(activeIndex, deduplicatedAccounts.length - 1);
|
|
activeIndex = Math.max(activeIndex, 0);
|
|
} else {
|
|
activeIndex = 0;
|
|
}
|
|
|
|
return {
|
|
version: 3,
|
|
accounts: deduplicatedAccounts,
|
|
activeIndex,
|
|
};
|
|
} catch (error) {
|
|
const code = (error as NodeJS.ErrnoException).code;
|
|
if (code === "ENOENT") {
|
|
return null;
|
|
}
|
|
log.error("Failed to load account storage", { error: String(error) });
|
|
return null;
|
|
}
|
|
}
|
|
|
|
export async function saveAccounts(storage: AccountStorageV3): Promise<void> {
|
|
const path = getStoragePath();
|
|
|
|
await withFileLock(path, async () => {
|
|
const existing = await loadAccountsUnsafe();
|
|
const merged = existing ? mergeAccountStorage(existing, storage) : storage;
|
|
|
|
const tempPath = `${path}.${randomBytes(6).toString("hex")}.tmp`;
|
|
const content = JSON.stringify(merged, null, 2);
|
|
|
|
await fs.mkdir(dirname(path), { recursive: true });
|
|
await fs.writeFile(tempPath, content, "utf-8");
|
|
await fs.rename(tempPath, path);
|
|
});
|
|
}
|
|
|
|
async function loadAccountsUnsafe(): Promise<AccountStorageV3 | null> {
|
|
try {
|
|
const path = getStoragePath();
|
|
const content = await fs.readFile(path, "utf-8");
|
|
const parsed = JSON.parse(content);
|
|
|
|
if (parsed.version === 1) {
|
|
return migrateV2ToV3(migrateV1ToV2(parsed));
|
|
}
|
|
if (parsed.version === 2) {
|
|
return migrateV2ToV3(parsed);
|
|
}
|
|
|
|
return {
|
|
...parsed,
|
|
accounts: deduplicateAccountsByEmail(parsed.accounts),
|
|
};
|
|
} catch (error) {
|
|
const code = (error as NodeJS.ErrnoException).code;
|
|
if (code === "ENOENT") {
|
|
return null;
|
|
}
|
|
return null;
|
|
}
|
|
}
|
|
|
|
export async function clearAccounts(): Promise<void> {
|
|
try {
|
|
const path = getStoragePath();
|
|
await fs.unlink(path);
|
|
} catch (error) {
|
|
const code = (error as NodeJS.ErrnoException).code;
|
|
if (code !== "ENOENT") {
|
|
log.error("Failed to clear account storage", { error: String(error) });
|
|
}
|
|
}
|
|
}
|