diff --git a/package-lock.json b/package-lock.json index e4f8a6b1..c09c5fee 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "opencode-antigravity-auth", - "version": "1.3.3-beta.2", + "version": "1.4.4-beta.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "opencode-antigravity-auth", - "version": "1.3.3-beta.2", + "version": "1.4.4-beta.0", "license": "MIT", "dependencies": { "@openauthjs/openauth": "^0.4.3", diff --git a/src/plugin.ts b/src/plugin.ts index f982cfde..ff309d57 100644 --- a/src/plugin.ts +++ b/src/plugin.ts @@ -603,6 +603,50 @@ async function extractRetryInfoFromBody(response: Response): Promise | undefined { + if (!parsed || typeof parsed !== "object") return undefined; + const error = (parsed as { error?: any }).error; + const details = error?.details; + if (Array.isArray(details)) { + for (const detail of details) { + if (detail && typeof detail === "object") { + const type = detail["@type"]; + if (typeof type === "string" && type.includes("google.rpc.ErrorInfo")) { + return detail.metadata; + } + } + } + } + return undefined; +} + +function extractVerificationUrl(parsed: unknown, rawText: string): string | undefined { + const metadata = getErrorMetadata(parsed); + if (metadata?.verificationUrl) return metadata.verificationUrl; + + const urlMatch = rawText.match(/https:\/\/accounts[^\s\\"']*/); + return urlMatch ? urlMatch[0].replace(/\\u0026/g, "&") : undefined; +} + +function isVerificationRequiredError( + parsedBody: unknown, + rawText: string +): { required: boolean; url?: string } { + const { reason } = extractRateLimitBodyInfo(parsedBody); + if (reason === "VALIDATION_REQUIRED") { + const url = extractVerificationUrl(parsedBody, rawText); + return { required: true, url }; + } + + if (rawText.includes("verify your account") || + rawText.includes("validation_error_message")) { + const url = extractVerificationUrl(parsedBody, rawText); + return { required: true, url }; + } + + return { required: false }; +} + function formatWaitTime(ms: number): string { if (ms < 1000) return `${ms}ms`; const seconds = Math.ceil(ms / 1000); @@ -617,6 +661,23 @@ function formatWaitTime(ms: number): string { return remainingMinutes > 0 ? `${hours}h ${remainingMinutes}m` : `${hours}h`; } +function buildVerificationErrorMessage( + email: string, + url: string | undefined, + cooldown: string +): string { + const urlSection = url + ? `\n\nPlease visit this URL to verify your account:\n${url}` + : `\n\nPlease check your email or Google account for verification instructions.`; + + return `[Antigravity] Account verification required for ${email}.` + + urlSection + + `\n\nAfter verification, either:` + + `\n• Use "opencode auth" > select account > "Clear verification block"` + + `\n• Wait for cooldown to expire (${cooldown})` + + `\n\nCooldown: ${cooldown}`; +} + // Progressive rate limit retry delays const FIRST_RETRY_DELAY_MS = 1000; // 1s - first 429 quick retry on same account const SWITCH_ACCOUNT_DELAY_MS = 5000; // 5s - delay before switching to another account @@ -1793,6 +1854,8 @@ export const createAntigravityPlugin = (providerId: string) => async ( getHealthTracker().recordSuccess(account.index); accountManager.markAccountUsed(account.index); + accountManager.clearVerificationStateOnSuccess(account); + void triggerAsyncQuotaRefreshForAccount( accountManager, account.index, @@ -1820,6 +1883,67 @@ export const createAntigravityPlugin = (providerId: string) => async ( return createSyntheticErrorResponse(errorMessage, prepared.requestedModel); } } + + if (response.status === 403) { + const cloned403 = response.clone(); + const body403 = await cloned403.text(); + + let parsedBody: unknown; + try { + parsedBody = JSON.parse(body403); + } catch { + parsedBody = null; + } + + const verificationCheck = isVerificationRequiredError(parsedBody, body403); + + if (verificationCheck.required) { + const accountEmail = account.email || "unknown account"; + const verifyUrl = verificationCheck.url; + + const cooldownMs = accountManager.markVerificationRequired(account, verifyUrl); + const cooldownFormatted = formatWaitTime(cooldownMs); + + await showToast( + `⚠️ Verification required for ${accountEmail} (cooldown: ${cooldownFormatted})`, + "error" + ); + + console.error(`\n⚠️ Account verification required for ${accountEmail}`); + if (verifyUrl) { + console.error(` Verify at: ${verifyUrl}`); + } + console.error(` Cooldown: ${cooldownFormatted}`); + console.error(` After verifying, run: opencode auth login > select account > "Clear verification block"\n`); + + log.warn("Account verification required", { + email: accountEmail, + url: verifyUrl, + cooldownMs + }); + + const nextAccount = accountManager.getCurrentOrNextForFamily(family); + if (nextAccount) { + shouldSwitchAccount = true; + break; + } + + const errorMessage = buildVerificationErrorMessage(accountEmail, verifyUrl, cooldownFormatted); + + return new Response(JSON.stringify({ + error: { + type: "verification_required", + message: errorMessage, + verification_url: verifyUrl, + account_email: accountEmail, + cooldown_seconds: Math.ceil(cooldownMs / 1000), + } + }), { + status: 403, + headers: { "Content-Type": "application/json" }, + }); + } + } } // Empty response retry logic (ported from LLM-API-Key-Proxy) @@ -2200,6 +2324,18 @@ export const createAntigravityPlugin = (providerId: string) => async ( console.log(`\nAccount ${acc.email || menuResult.toggleAccountIndex + 1} ${acc.enabled ? 'enabled' : 'disabled'}.\n`); } } + if (menuResult.clearVerificationAccountIndex !== undefined) { + const acc = existingStorage.accounts[menuResult.clearVerificationAccountIndex]; + if (acc && acc.cooldownReason === "verification-required") { + delete acc.coolingDownUntil; + delete acc.cooldownReason; + acc.verificationAttemptCount = 0; + delete acc.verificationUrl; + delete acc.verificationUrlCapturedAt; + await saveAccounts(existingStorage); + console.log(`\nVerification block cleared for ${acc.email || menuResult.clearVerificationAccountIndex + 1}.\n`); + } + } continue; } diff --git a/src/plugin/accounts-verification.test.ts b/src/plugin/accounts-verification.test.ts new file mode 100644 index 00000000..34044cec --- /dev/null +++ b/src/plugin/accounts-verification.test.ts @@ -0,0 +1,122 @@ +import { beforeEach, afterEach, describe, expect, it, vi } from "vitest"; +import { AccountManager, type ManagedAccount } from "./accounts"; +import type { AccountStorageV3 } from "./storage"; + +describe("AccountManager - Verification Progressive Cooldown", () => { + let manager: AccountManager; + let account: ManagedAccount; + + beforeEach(() => { + vi.useFakeTimers(); + vi.setSystemTime(new Date(1000000)); // Fixed start time + + const stored: AccountStorageV3 = { + version: 3, + accounts: [ + { refreshToken: "r1", projectId: "p1", addedAt: 1, lastUsed: 0 }, + ], + activeIndex: 0, + }; + + manager = new AccountManager(undefined, stored); + const accounts = manager.getAccounts(); + if (!accounts[0]) throw new Error("No account found"); + account = accounts[0]; + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it("first attempt uses 10-minute cooldown (plus jitter)", () => { + const cooldownMs = manager.markVerificationRequired(account, "https://verify.me"); + + expect(account.verificationAttemptCount).toBe(1); + expect(account.verificationUrl).toBe("https://verify.me"); + expect(account.verificationUrlCapturedAt).toBe(1000000); + expect(account.cooldownReason).toBe("verification-required"); + + // 10 mins = 600,000ms. With jitter (max 60s), range is 600,000 - 660,000 + expect(cooldownMs).toBeGreaterThanOrEqual(600000); + expect(cooldownMs).toBeLessThan(600000 + 60000 + 1); + expect(account.coolingDownUntil).toBe(1000000 + cooldownMs); + }); + + it("repeat during cooldown escalates to next tier", () => { + // First attempt (10m) + manager.markVerificationRequired(account); + expect(account.verificationAttemptCount).toBe(1); + + // Second attempt (immediate repeat) - should be 1h + const cooldownMs = manager.markVerificationRequired(account); + + expect(account.verificationAttemptCount).toBe(2); + // 1h = 3,600,000ms + expect(cooldownMs).toBeGreaterThanOrEqual(3600000); + expect(cooldownMs).toBeLessThan(3600000 + 60000 + 1); + }); + + it("repeat after cooldown expires resets to tier 1", () => { + // First attempt + manager.markVerificationRequired(account); + expect(account.verificationAttemptCount).toBe(1); + + // Advance time past cooldown (approx 24h to be safe, though 10m is enough for tier 1) + vi.advanceTimersByTime(24 * 60 * 60 * 1000); + + // Should clear cooldown state naturally when checking or when re-marking + // However, markVerificationRequired calls isAccountCoolingDown which clears expired cooldowns + + // Next attempt should be fresh + const cooldownMs = manager.markVerificationRequired(account); + + expect(account.verificationAttemptCount).toBe(1); + expect(cooldownMs).toBeGreaterThanOrEqual(600000); // Back to 10m + }); + + it("caps at 24 hours", () => { + // Tier 1: 10m + manager.markVerificationRequired(account); + // Tier 2: 1h + manager.markVerificationRequired(account); + // Tier 3: 6h + manager.markVerificationRequired(account); + // Tier 4: 24h + const cooldownMs4 = manager.markVerificationRequired(account); + expect(cooldownMs4).toBeGreaterThanOrEqual(24 * 60 * 60 * 1000); + + // Tier 5: 24h (capped) + const cooldownMs5 = manager.markVerificationRequired(account); + expect(cooldownMs5).toBeGreaterThanOrEqual(24 * 60 * 60 * 1000); + expect(cooldownMs5).toBeLessThan(24 * 60 * 60 * 1000 + 60000 + 1); + }); + + it("clearVerificationCooldown resets counter and clears URL", () => { + manager.markVerificationRequired(account, "https://verify.me"); + expect(account.verificationAttemptCount).toBe(1); + expect(account.verificationUrl).toBeDefined(); + + const result = manager.clearVerificationCooldown(0); + expect(result).toBe(true); + + expect(account.verificationAttemptCount).toBe(0); + expect(account.verificationUrl).toBeUndefined(); + expect(account.coolingDownUntil).toBeUndefined(); + }); + + it("clearVerificationStateOnSuccess resets counter on success", () => { + manager.markVerificationRequired(account, "https://verify.me"); + + manager.clearVerificationStateOnSuccess(account); + + expect(account.verificationAttemptCount).toBe(0); + expect(account.verificationUrl).toBeUndefined(); + // Should NOT clear active cooldown + expect(account.coolingDownUntil).toBeDefined(); + }); + + it("getVerificationUrl returns stored URL", () => { + manager.markVerificationRequired(account, "https://verify.me"); + expect(manager.getVerificationUrl(0)).toBe("https://verify.me"); + }); +}); diff --git a/src/plugin/accounts.test.ts b/src/plugin/accounts.test.ts index 9550cb0f..69b5f995 100644 --- a/src/plugin/accounts.test.ts +++ b/src/plugin/accounts.test.ts @@ -631,6 +631,115 @@ describe("AccountManager", () => { expect(manager.isRateLimitedForHeaderStyle(account!, "gemini", "antigravity")).toBe(false); expect(manager.isRateLimitedForHeaderStyle(account!, "gemini", "gemini-cli")).toBe(false); }); + + it("supports verification-required cooldown reason for account verification errors", () => { + const stored: AccountStorageV3 = { + version: 3, + accounts: [ + { refreshToken: "r1", projectId: "p1", addedAt: 1, lastUsed: 0 }, + { refreshToken: "r2", projectId: "p2", addedAt: 1, lastUsed: 0 }, + ], + activeIndex: 0, + }; + + const manager = new AccountManager(undefined, stored); + const account = manager.getCurrentOrNextForFamily("claude"); + + manager.markAccountCoolingDown(account!, 24 * 60 * 60 * 1000, "verification-required"); + + expect(manager.isAccountCoolingDown(account!)).toBe(true); + expect(manager.getAccountCooldownReason(account!)).toBe("verification-required"); + + const nextAccount = manager.getCurrentOrNextForFamily("claude"); + expect(nextAccount?.parts.refreshToken).toBe("r2"); + }); + }); + + describe("getMinWaitTimeForFamily with cooldowns", () => { + it("returns cooldown wait time when account is cooling down but not rate-limited", () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date(0)); + + const stored: AccountStorageV3 = { + version: 3, + accounts: [ + { refreshToken: "r1", projectId: "p1", addedAt: 1, lastUsed: 0 }, + ], + activeIndex: 0, + }; + + const manager = new AccountManager(undefined, stored); + const account = manager.getAccounts()[0]!; + + manager.markAccountCoolingDown(account, 600_000, "verification-required"); + + const waitTime = manager.getMinWaitTimeForFamily("claude"); + expect(waitTime).toBe(600_000); + + vi.useRealTimers(); + }); + + it("returns max of rate-limit and cooldown when both set", () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date(0)); + + const stored: AccountStorageV3 = { + version: 3, + accounts: [ + { refreshToken: "r1", projectId: "p1", addedAt: 1, lastUsed: 0 }, + ], + activeIndex: 0, + }; + + const manager = new AccountManager(undefined, stored); + const account = manager.getAccounts()[0]!; + + manager.markAccountCoolingDown(account, 300_000, "auth-failure"); + manager.markRateLimited(account, 600_000, "claude", "antigravity"); + + const waitTime = manager.getMinWaitTimeForFamily("claude", null, "antigravity"); + expect(waitTime).toBe(600_000); + + vi.useRealTimers(); + }); + + it("returns 0 when at least one account is available", () => { + const stored: AccountStorageV3 = { + version: 3, + accounts: [ + { refreshToken: "r1", projectId: "p1", addedAt: 1, lastUsed: 0 }, + { refreshToken: "r2", projectId: "p2", addedAt: 1, lastUsed: 0 }, + ], + activeIndex: 0, + }; + + const manager = new AccountManager(undefined, stored); + const [acc1, acc2] = manager.getAccounts(); + + manager.markAccountCoolingDown(acc1!, 600_000, "verification-required"); + // acc2 is available + + const waitTime = manager.getMinWaitTimeForFamily("claude"); + expect(waitTime).toBe(0); + }); + + it("excludes cooling-down accounts from available set", () => { + const stored: AccountStorageV3 = { + version: 3, + accounts: [ + { refreshToken: "r1", projectId: "p1", addedAt: 1, lastUsed: 0 }, + ], + activeIndex: 0, + }; + + const manager = new AccountManager(undefined, stored); + const account = manager.getAccounts()[0]!; + + manager.markAccountCoolingDown(account, 600_000, "verification-required"); + + const next = manager.getCurrentOrNextForFamily("claude"); + expect(next).toBeNull(); + }); }); describe("account selection strategies", () => { diff --git a/src/plugin/accounts.ts b/src/plugin/accounts.ts index bcdd07f1..553cd08d 100644 --- a/src/plugin/accounts.ts +++ b/src/plugin/accounts.ts @@ -49,6 +49,17 @@ const SERVER_ERROR_BACKOFF = 20_000; const UNKNOWN_BACKOFF = 60_000; const MIN_BACKOFF_MS = 2_000; +// Progressive cooldown: 10m → 1h → 6h → 24h +const VERIFICATION_COOLDOWN_SCHEDULE = [ + 10 * 60 * 1000, // 10 minutes + 60 * 60 * 1000, // 1 hour + 6 * 60 * 60 * 1000, // 6 hours + 24 * 60 * 60 * 1000, // 24 hours (cap) +] as const; + +// Jitter to prevent synchronized retries +const COOLDOWN_JITTER_MAX_MS = 60 * 1000; // +0-60s + /** * Generate a random jitter value for backoff timing. * Helps prevent thundering herd problem when multiple clients retry simultaneously. @@ -152,6 +163,9 @@ export interface ManagedAccount { lastSwitchReason?: "rate-limit" | "initial" | "rotation"; coolingDownUntil?: number; cooldownReason?: CooldownReason; + verificationUrl?: string; + verificationUrlCapturedAt?: number; + verificationAttemptCount?: number; touchedForQuota: Record; consecutiveFailures?: number; /** Timestamp of last failure for TTL-based reset of consecutiveFailures */ @@ -369,6 +383,9 @@ export class AccountManager { lastSwitchReason: acc.lastSwitchReason, coolingDownUntil: acc.coolingDownUntil, cooldownReason: acc.cooldownReason, + verificationUrl: acc.verificationUrl, + verificationUrlCapturedAt: acc.verificationUrlCapturedAt, + verificationAttemptCount: acc.verificationAttemptCount, touchedForQuota: {}, // Use stored fingerprint (with updated version) or generate new one fingerprint: acc.fingerprint @@ -702,6 +719,83 @@ export class AccountManager { return this.isAccountCoolingDown(account) ? account.cooldownReason : undefined; } + /** + * Mark account as requiring verification with progressive cooldown. + * Uses "set if later" semantics to handle concurrent requests. + * Only escalates if called during active cooldown. + */ + markVerificationRequired(account: ManagedAccount, verifyUrl?: string): number { + const now = nowMs(); + const isCurrentlyCoolingDown = this.isAccountCoolingDown(account) && + account.cooldownReason === "verification-required"; + + // Only escalate if repeating during active cooldown + const attemptCount = isCurrentlyCoolingDown + ? (account.verificationAttemptCount ?? 0) + 1 + : 1; + + account.verificationAttemptCount = attemptCount; + + if (verifyUrl) { + account.verificationUrl = verifyUrl; + account.verificationUrlCapturedAt = now; + } + + // Calculate cooldown with jitter + const scheduleIndex = Math.min(attemptCount - 1, VERIFICATION_COOLDOWN_SCHEDULE.length - 1); + const baseCooldown = VERIFICATION_COOLDOWN_SCHEDULE[scheduleIndex] ?? VERIFICATION_COOLDOWN_SCHEDULE[0]; + const jitter = Math.floor(Math.random() * COOLDOWN_JITTER_MAX_MS); + const cooldownMs = baseCooldown + jitter; + + // "Set if later" semantics for concurrency safety + const newCooldownUntil = now + cooldownMs; + if (!account.coolingDownUntil || newCooldownUntil > account.coolingDownUntil) { + this.markAccountCoolingDown(account, cooldownMs, "verification-required"); + } + + this.requestSaveToDisk(); + return cooldownMs; + } + + /** + * Clear verification cooldown and reset attempt counter. + */ + clearVerificationCooldown(accountIndex: number): boolean { + const account = this.accounts[accountIndex]; + if (!account) return false; + + if (account.cooldownReason === "verification-required") { + this.clearAccountCooldown(account); + account.verificationAttemptCount = 0; + delete account.verificationUrl; + delete account.verificationUrlCapturedAt; + this.requestSaveToDisk(); + return true; + } + return false; + } + + /** + * Clear verification state on successful request. + * Called after any successful API call. + */ + clearVerificationStateOnSuccess(account: ManagedAccount): void { + if (account.verificationAttemptCount || account.verificationUrl) { + account.verificationAttemptCount = 0; + delete account.verificationUrl; + delete account.verificationUrlCapturedAt; + // Active cooldown remains until expiration + this.requestSaveToDisk(); + } + } + + /** + * Get stored verification URL for an account. + */ + getVerificationUrl(accountIndex: number): string | undefined { + return this.accounts[accountIndex]?.verificationUrl; + } + markTouchedForQuota(account: ManagedAccount, quotaKey: string): void { account.touchedForQuota[quotaKey] = nowMs(); } @@ -854,42 +948,48 @@ export class AccountManager { headerStyle?: HeaderStyle, strict?: boolean, ): number { - const available = this.accounts.filter((a) => { - clearExpiredRateLimits(a); - return a.enabled !== false && (strict && headerStyle - ? !isRateLimitedForHeaderStyle(a, family, headerStyle, model) - : !isRateLimitedForFamily(a, family, model)); - }); - if (available.length > 0) { - return 0; - } + const now = nowMs(); + let minWait = Infinity; - const waitTimes: number[] = []; - for (const a of this.accounts) { + for (const account of this.accounts) { + if (account.enabled === false) continue; + + // Check rate limit wait time + let rateLimitWait = 0; if (family === "claude") { - const t = a.rateLimitResetTimes.claude; - if (t !== undefined) waitTimes.push(Math.max(0, t - nowMs())); + const t = account.rateLimitResetTimes.claude; + if (t !== undefined) rateLimitWait = Math.max(0, t - now); } else if (strict && headerStyle) { const key = getQuotaKey(family, headerStyle, model); - const t = a.rateLimitResetTimes[key]; - if (t !== undefined) waitTimes.push(Math.max(0, t - nowMs())); + const t = account.rateLimitResetTimes[key]; + if (t !== undefined) rateLimitWait = Math.max(0, t - now); } else { - // For Gemini, account becomes available when EITHER pool expires for this model/family + // For Gemini, account becomes available when EITHER pool expires const antigravityKey = getQuotaKey(family, "antigravity", model); const cliKey = getQuotaKey(family, "gemini-cli", model); - const t1 = a.rateLimitResetTimes[antigravityKey]; - const t2 = a.rateLimitResetTimes[cliKey]; - - const accountWait = Math.min( - t1 !== undefined ? Math.max(0, t1 - nowMs()) : Infinity, - t2 !== undefined ? Math.max(0, t2 - nowMs()) : Infinity - ); - if (accountWait !== Infinity) waitTimes.push(accountWait); + const t1 = account.rateLimitResetTimes[antigravityKey]; + const t2 = account.rateLimitResetTimes[cliKey]; + + const wait1 = t1 !== undefined ? Math.max(0, t1 - now) : 0; + const wait2 = t2 !== undefined ? Math.max(0, t2 - now) : 0; + rateLimitWait = Math.min(wait1, wait2); } + + // Check cooldown wait time + const cooldownWait = account.coolingDownUntil + ? Math.max(0, account.coolingDownUntil - now) + : 0; + + // Account is available when BOTH are 0 (or Infinity for rate limit means no limit) + const accountWait = Math.max( + rateLimitWait === Infinity ? 0 : rateLimitWait, + cooldownWait + ); + minWait = Math.min(minWait, accountWait); } - return waitTimes.length > 0 ? Math.min(...waitTimes) : 0; + return minWait === Infinity ? 0 : minWait; } getAccounts(): ManagedAccount[] { @@ -913,6 +1013,9 @@ export class AccountManager { rateLimitResetTimes: Object.keys(a.rateLimitResetTimes).length > 0 ? a.rateLimitResetTimes : undefined, coolingDownUntil: a.coolingDownUntil, cooldownReason: a.cooldownReason, + verificationUrl: a.verificationUrl, + verificationUrlCapturedAt: a.verificationUrlCapturedAt, + verificationAttemptCount: a.verificationAttemptCount, fingerprint: a.fingerprint, fingerprintHistory: a.fingerprintHistory?.length ? a.fingerprintHistory : undefined, cachedQuota: a.cachedQuota && Object.keys(a.cachedQuota).length > 0 ? a.cachedQuota : undefined, diff --git a/src/plugin/cli.ts b/src/plugin/cli.ts index ebc5a7fb..d428c00f 100644 --- a/src/plugin/cli.ts +++ b/src/plugin/cli.ts @@ -46,6 +46,7 @@ export interface LoginMenuResult { deleteAccountIndex?: number; refreshAccountIndex?: number; toggleAccountIndex?: number; + clearVerificationAccountIndex?: number; deleteAll?: boolean; } @@ -124,6 +125,9 @@ export async function promptLoginMode(existingAccounts: ExistingAccountInfo[]): if (accountAction === "toggle") { return { mode: "manage", toggleAccountIndex: action.account.index }; } + if (accountAction === "clear-verification") { + return { mode: "manage", clearVerificationAccountIndex: action.account.index }; + } continue; } diff --git a/src/plugin/storage.ts b/src/plugin/storage.ts index ee44f513..74a11044 100644 --- a/src/plugin/storage.ts +++ b/src/plugin/storage.ts @@ -177,7 +177,7 @@ export interface AccountStorage { activeIndex: number; } -export type CooldownReason = "auth-failure" | "network-error" | "project-error"; +export type CooldownReason = "auth-failure" | "network-error" | "project-error" | "verification-required"; export interface AccountMetadataV3 { email?: string; @@ -191,6 +191,16 @@ export interface AccountMetadataV3 { rateLimitResetTimes?: RateLimitStateV3; coolingDownUntil?: number; cooldownReason?: CooldownReason; + + /** Verification URL from last VALIDATION_REQUIRED error */ + verificationUrl?: string; + + /** Timestamp when verification URL was captured */ + verificationUrlCapturedAt?: number; + + /** Count of consecutive verification failures (for progressive cooldown) */ + verificationAttemptCount?: number; + /** Per-account device fingerprint for rate limit mitigation */ fingerprint?: import("./fingerprint").Fingerprint; /** Cached soft quota data */ @@ -389,6 +399,10 @@ function mergeAccountStorage( // Preserve manually configured projectId/managedProjectId if not in incoming projectId: acc.projectId ?? existingAcc.projectId, managedProjectId: acc.managedProjectId ?? existingAcc.managedProjectId, + // Preserve verification fields if incoming doesn't have them + verificationUrl: acc.verificationUrl ?? existingAcc.verificationUrl, + verificationUrlCapturedAt: acc.verificationUrlCapturedAt ?? existingAcc.verificationUrlCapturedAt, + verificationAttemptCount: acc.verificationAttemptCount ?? existingAcc.verificationAttemptCount, rateLimitResetTimes: { ...existingAcc.rateLimitResetTimes, ...acc.rateLimitResetTimes, diff --git a/src/plugin/ui/ansi.ts b/src/plugin/ui/ansi.ts index 66f37c26..14559c21 100644 --- a/src/plugin/ui/ansi.ts +++ b/src/plugin/ui/ansi.ts @@ -18,6 +18,7 @@ export const ANSI = { green: '\x1b[32m', red: '\x1b[31m', yellow: '\x1b[33m', + magenta: '\x1b[35m', dim: '\x1b[2m', bold: '\x1b[1m', reset: '\x1b[0m', diff --git a/src/plugin/ui/auth-menu.ts b/src/plugin/ui/auth-menu.ts index 77ba6de3..07845d0e 100644 --- a/src/plugin/ui/auth-menu.ts +++ b/src/plugin/ui/auth-menu.ts @@ -2,7 +2,9 @@ import { ANSI } from './ansi'; import { select, type MenuItem } from './select'; import { confirm } from './confirm'; -export type AccountStatus = 'active' | 'rate-limited' | 'expired' | 'unknown'; +export type AccountStatus = 'active' | 'rate-limited' | 'expired' | 'verification-required' | 'unknown'; + +type CooldownReason = "auth-failure" | "network-error" | "project-error" | "verification-required"; export interface AccountInfo { email?: string; @@ -12,6 +14,9 @@ export interface AccountInfo { status?: AccountStatus; isCurrentAccount?: boolean; enabled?: boolean; + cooldownReason?: CooldownReason; + coolingDownUntil?: number; + verificationUrl?: string; } export type AuthMenuAction = @@ -22,7 +27,7 @@ export type AuthMenuAction = | { type: 'manage' } | { type: 'cancel' }; -export type AccountAction = 'back' | 'delete' | 'refresh' | 'toggle' | 'cancel'; +export type AccountAction = 'back' | 'delete' | 'refresh' | 'toggle' | 'clear-verification' | 'cancel'; function formatRelativeTime(timestamp: number | undefined): string { if (!timestamp) return 'never'; @@ -44,6 +49,7 @@ function getStatusBadge(status: AccountStatus | undefined): string { case 'active': return `${ANSI.green}[active]${ANSI.reset}`; case 'rate-limited': return `${ANSI.yellow}[rate-limited]${ANSI.reset}`; case 'expired': return `${ANSI.red}[expired]${ANSI.reset}`; + case 'verification-required': return `${ANSI.magenta}[verification-required]${ANSI.reset}`; default: return ''; } } @@ -96,15 +102,39 @@ export async function showAccountDetails(account: AccountInfo): Promise[] = [ + { label: 'Back', value: 'back' }, + { + label: account.enabled === false ? 'Enable account' : 'Disable account', + value: 'toggle', + color: account.enabled === false ? 'green' : 'yellow' + }, + ]; + + if (account.cooldownReason === 'verification-required') { + options.push({ + label: 'Clear verification block (I verified my account)', + value: 'clear-verification', + color: 'green', + }); + } + + options.push( + { label: 'Refresh token', value: 'refresh', color: 'cyan' }, + { label: 'Delete this account', value: 'delete', color: 'red' }, + ); + while (true) { - const result = await select([ - { label: 'Back', value: 'back' as const }, - { label: account.enabled === false ? 'Enable account' : 'Disable account', value: 'toggle' as const, color: account.enabled === false ? 'green' : 'yellow' }, - { label: 'Refresh token', value: 'refresh' as const, color: 'cyan' }, - { label: 'Delete this account', value: 'delete' as const, color: 'red' }, - ], { + const result = await select(options, { message: 'Account options', subtitle: 'Select action' }); @@ -118,6 +148,14 @@ export async function showAccountDetails(account: AccountInfo): Promise