diff --git a/AGENTS.MD b/AGENTS.MD index f23d666..9f1b41f 100644 --- a/AGENTS.MD +++ b/AGENTS.MD @@ -118,7 +118,7 @@ Tool schemas are cleaned via allowlist. Unsupported fields (`const`, `$ref`, `$d Accounts rotate on rate limits. Gemini has dual quota pools (Antigravity headers + Gemini CLI headers). Fingerprints are per-account and regenerated on capacity exhaustion. ### 6. Fingerprint System -Per-account device fingerprints stored in `antigravity-accounts.json`. Each fingerprint includes deviceId, sessionToken, userAgent, apiClient, clientMetadata. Headers: `User-Agent`, `X-Goog-Api-Client`, `Client-Metadata`, `X-Goog-QuotaUser`, `X-Client-Device-Id`. History tracked (max 5), restorable. +Per-account device fingerprints stored in `antigravity-accounts.json`. Each fingerprint includes deviceId, sessionToken, userAgent, and a reduced clientMetadata (ideType, platform, pluginType — no osVersion, arch, or sqmId). The only header composed is `User-Agent`, built by `buildFingerprintHeaders()` in `fingerprint.ts` and applied on the antigravity request path in `request.ts`. History tracked (max 5), restorable. ## Dependencies diff --git a/src/plugin.ts b/src/plugin.ts index 340e0ed..a85e5fe 100644 --- a/src/plugin.ts +++ b/src/plugin.ts @@ -847,7 +847,7 @@ async function persistAccountPool( : (typeof stored?.activeIndex === "number" && Number.isFinite(stored.activeIndex) ? stored.activeIndex : 0); await saveAccounts({ - version: 3, + version: 4, accounts, activeIndex: clampInt(activeIndex, 0, accounts.length - 1), activeIndexByFamily: { @@ -2929,7 +2929,7 @@ export const createAntigravityPlugin = (providerId: string) => async ( ); // Use saveAccountsReplace to bypass merge (otherwise deleted account gets merged back) await saveAccountsReplace({ - version: 3, + version: 4, accounts: updatedAccounts, activeIndex: 0, activeIndexByFamily: { claude: 0, gemini: 0 }, @@ -3145,7 +3145,7 @@ export const createAntigravityPlugin = (providerId: string) => async ( lastUsed: Date.now(), }; await saveAccounts({ - version: 3, + version: 4, accounts: updatedAccounts, activeIndex: currentStorage.activeIndex, activeIndexByFamily: currentStorage.activeIndexByFamily, diff --git a/src/plugin/accounts.test.ts b/src/plugin/accounts.test.ts index 01bf561..24a02a5 100644 --- a/src/plugin/accounts.test.ts +++ b/src/plugin/accounts.test.ts @@ -1,7 +1,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import { AccountManager, type ModelFamily, type HeaderStyle, parseRateLimitReason, calculateBackoffMs, type RateLimitReason, resolveQuotaGroup } from "./accounts"; -import type { AccountStorageV3 } from "./storage"; +import type { AccountStorageV4 } from "./storage"; import type { OAuthAuthDetails } from "./types"; // Mock storage to prevent test data from leaking to real config files @@ -28,8 +28,8 @@ describe("AccountManager", () => { expires: 123, }; - const stored: AccountStorageV3 = { - version: 3, + const stored: AccountStorageV4 = { + version: 4, accounts: [], activeIndex: 0, }; @@ -39,8 +39,8 @@ describe("AccountManager", () => { }); it("returns current account when not rate-limited for family", () => { - const stored: AccountStorageV3 = { - version: 3, + const stored: AccountStorageV4 = { + version: 4, accounts: [ { refreshToken: "r1", projectId: "p1", addedAt: 1, lastUsed: 0 }, { refreshToken: "r2", projectId: "p2", addedAt: 1, lastUsed: 0 }, @@ -58,8 +58,8 @@ describe("AccountManager", () => { }); it("switches to next account when current is rate-limited for family", () => { - const stored: AccountStorageV3 = { - version: 3, + const stored: AccountStorageV4 = { + version: 4, accounts: [ { refreshToken: "r1", projectId: "p1", addedAt: 1, lastUsed: 0 }, { refreshToken: "r2", projectId: "p2", addedAt: 1, lastUsed: 0 }, @@ -78,8 +78,8 @@ describe("AccountManager", () => { }); it("returns null when all accounts are rate-limited for family", () => { - const stored: AccountStorageV3 = { - version: 3, + const stored: AccountStorageV4 = { + version: 4, accounts: [ { refreshToken: "r1", projectId: "p1", addedAt: 1, lastUsed: 0 }, { refreshToken: "r2", projectId: "p2", addedAt: 1, lastUsed: 0 }, @@ -101,8 +101,8 @@ describe("AccountManager", () => { vi.useFakeTimers(); vi.setSystemTime(new Date(0)); - const stored: AccountStorageV3 = { - version: 3, + const stored: AccountStorageV4 = { + version: 4, accounts: [ { refreshToken: "r1", projectId: "p1", addedAt: 1, lastUsed: 0 }, ], @@ -123,8 +123,8 @@ describe("AccountManager", () => { vi.useFakeTimers(); vi.setSystemTime(new Date(0)); - const stored: AccountStorageV3 = { - version: 3, + const stored: AccountStorageV4 = { + version: 4, accounts: [ { refreshToken: "r1", projectId: "p1", addedAt: 1, lastUsed: 0 }, { refreshToken: "r2", projectId: "p2", addedAt: 1, lastUsed: 0 }, @@ -143,8 +143,8 @@ describe("AccountManager", () => { }); it("tracks rate limits per model family independently", () => { - const stored: AccountStorageV3 = { - version: 3, + const stored: AccountStorageV4 = { + version: 4, accounts: [ { refreshToken: "r1", projectId: "p1", addedAt: 1, lastUsed: 0 }, ], @@ -172,8 +172,8 @@ describe("AccountManager", () => { vi.useFakeTimers(); vi.setSystemTime(new Date(0)); - const stored: AccountStorageV3 = { - version: 3, + const stored: AccountStorageV4 = { + version: 4, accounts: [ { refreshToken: "r1", projectId: "p1", addedAt: 1, lastUsed: 0 }, { refreshToken: "r2", projectId: "p2", addedAt: 1, lastUsed: 0 }, @@ -206,8 +206,8 @@ describe("AccountManager", () => { vi.useFakeTimers(); vi.setSystemTime(new Date(0)); - const stored: AccountStorageV3 = { - version: 3, + const stored: AccountStorageV4 = { + version: 4, accounts: [ { refreshToken: "r1", projectId: "p1", addedAt: 1, lastUsed: 0 }, { refreshToken: "r2", projectId: "p2", addedAt: 1, lastUsed: 0 }, @@ -237,8 +237,8 @@ describe("AccountManager", () => { expires: 123, }; - const stored: AccountStorageV3 = { - version: 3, + const stored: AccountStorageV4 = { + version: 4, accounts: [ { refreshToken: "r1", projectId: "p1", addedAt: 1, lastUsed: 0 }, { refreshToken: "r2", projectId: "p2", addedAt: 1, lastUsed: 0 }, @@ -259,8 +259,8 @@ describe("AccountManager", () => { vi.useFakeTimers(); vi.setSystemTime(new Date(0)); - const stored: AccountStorageV3 = { - version: 3, + const stored: AccountStorageV4 = { + version: 4, accounts: [ { refreshToken: "r1", projectId: "p1", addedAt: 1, lastUsed: 0 }, ], @@ -282,8 +282,8 @@ describe("AccountManager", () => { describe("header style fallback for Gemini", () => { it("tracks rate limits separately for each header style", () => { - const stored: AccountStorageV3 = { - version: 3, + const stored: AccountStorageV4 = { + version: 4, accounts: [ { refreshToken: "r1", projectId: "p1", addedAt: 1, lastUsed: 0 }, ], @@ -300,8 +300,8 @@ describe("AccountManager", () => { }); it("getAvailableHeaderStyle returns antigravity first for Gemini", () => { - const stored: AccountStorageV3 = { - version: 3, + const stored: AccountStorageV4 = { + version: 4, accounts: [ { refreshToken: "r1", projectId: "p1", addedAt: 1, lastUsed: 0 }, ], @@ -315,8 +315,8 @@ describe("AccountManager", () => { }); it("getAvailableHeaderStyle returns gemini-cli when antigravity is rate-limited", () => { - const stored: AccountStorageV3 = { - version: 3, + const stored: AccountStorageV4 = { + version: 4, accounts: [ { refreshToken: "r1", projectId: "p1", addedAt: 1, lastUsed: 0 }, ], @@ -332,8 +332,8 @@ describe("AccountManager", () => { }); it("getAvailableHeaderStyle returns null when both header styles are rate-limited", () => { - const stored: AccountStorageV3 = { - version: 3, + const stored: AccountStorageV4 = { + version: 4, accounts: [ { refreshToken: "r1", projectId: "p1", addedAt: 1, lastUsed: 0 }, ], @@ -350,8 +350,8 @@ describe("AccountManager", () => { }); it("getAvailableHeaderStyle always returns antigravity for Claude", () => { - const stored: AccountStorageV3 = { - version: 3, + const stored: AccountStorageV4 = { + version: 4, accounts: [ { refreshToken: "r1", projectId: "p1", addedAt: 1, lastUsed: 0 }, ], @@ -365,8 +365,8 @@ describe("AccountManager", () => { }); it("getAvailableHeaderStyle returns null for Claude when rate-limited", () => { - const stored: AccountStorageV3 = { - version: 3, + const stored: AccountStorageV4 = { + version: 4, accounts: [ { refreshToken: "r1", projectId: "p1", addedAt: 1, lastUsed: 0 }, ], @@ -385,8 +385,8 @@ describe("AccountManager", () => { vi.useFakeTimers(); vi.setSystemTime(new Date(0)); - const stored: AccountStorageV3 = { - version: 3, + const stored: AccountStorageV4 = { + version: 4, accounts: [ { refreshToken: "r1", projectId: "p1", addedAt: 1, lastUsed: 0 }, ], @@ -411,8 +411,8 @@ describe("AccountManager", () => { vi.useFakeTimers(); vi.setSystemTime(new Date(0)); - const stored: AccountStorageV3 = { - version: 3, + const stored: AccountStorageV4 = { + version: 4, accounts: [ { refreshToken: "r1", projectId: "p1", addedAt: 1, lastUsed: 0 }, ], @@ -434,8 +434,8 @@ describe("AccountManager", () => { describe("per-family account tracking", () => { it("tracks current account independently per model family", () => { - const stored: AccountStorageV3 = { - version: 3, + const stored: AccountStorageV4 = { + version: 4, accounts: [ { refreshToken: "r1", projectId: "p1", addedAt: 1, lastUsed: 0 }, { refreshToken: "r2", projectId: "p2", addedAt: 1, lastUsed: 0 }, @@ -458,8 +458,8 @@ describe("AccountManager", () => { }); it("switching Claude account does not affect Gemini account selection", () => { - const stored: AccountStorageV3 = { - version: 3, + const stored: AccountStorageV4 = { + version: 4, accounts: [ { refreshToken: "r1", projectId: "p1", addedAt: 1, lastUsed: 0 }, { refreshToken: "r2", projectId: "p2", addedAt: 1, lastUsed: 0 }, @@ -486,8 +486,8 @@ describe("AccountManager", () => { }); it("persists per-family indices to storage", async () => { - const stored: AccountStorageV3 = { - version: 3, + const stored: AccountStorageV4 = { + version: 4, accounts: [ { refreshToken: "r1", projectId: "p1", addedAt: 1, lastUsed: 0 }, { refreshToken: "r2", projectId: "p2", addedAt: 1, lastUsed: 0 }, @@ -506,8 +506,8 @@ describe("AccountManager", () => { }); it("loads per-family indices from storage", () => { - const stored: AccountStorageV3 = { - version: 3, + const stored: AccountStorageV4 = { + version: 4, accounts: [ { refreshToken: "r1", projectId: "p1", addedAt: 1, lastUsed: 0 }, { refreshToken: "r2", projectId: "p2", addedAt: 1, lastUsed: 0 }, @@ -527,8 +527,8 @@ describe("AccountManager", () => { }); it("falls back to activeIndex when activeIndexByFamily is not present", () => { - const stored: AccountStorageV3 = { - version: 3, + const stored: AccountStorageV4 = { + version: 4, accounts: [ { refreshToken: "r1", projectId: "p1", addedAt: 1, lastUsed: 0 }, { refreshToken: "r2", projectId: "p2", addedAt: 1, lastUsed: 0 }, @@ -545,8 +545,8 @@ describe("AccountManager", () => { describe("account cooldown (non-429 errors)", () => { it("marks account as cooling down with reason", () => { - const stored: AccountStorageV3 = { - version: 3, + const stored: AccountStorageV4 = { + version: 4, accounts: [ { refreshToken: "r1", projectId: "p1", addedAt: 1, lastUsed: 0 }, ], @@ -565,8 +565,8 @@ describe("AccountManager", () => { vi.useFakeTimers(); vi.setSystemTime(new Date(0)); - const stored: AccountStorageV3 = { - version: 3, + const stored: AccountStorageV4 = { + version: 4, accounts: [ { refreshToken: "r1", projectId: "p1", addedAt: 1, lastUsed: 0 }, ], @@ -586,8 +586,8 @@ describe("AccountManager", () => { }); it("clearAccountCooldown removes cooldown state", () => { - const stored: AccountStorageV3 = { - version: 3, + const stored: AccountStorageV4 = { + version: 4, accounts: [ { refreshToken: "r1", projectId: "p1", addedAt: 1, lastUsed: 0 }, ], @@ -605,8 +605,8 @@ describe("AccountManager", () => { }); it("cooling down account is skipped in getCurrentOrNextForFamily", () => { - const stored: AccountStorageV3 = { - version: 3, + const stored: AccountStorageV4 = { + version: 4, accounts: [ { refreshToken: "r1", projectId: "p1", addedAt: 1, lastUsed: 0 }, { refreshToken: "r2", projectId: "p2", addedAt: 1, lastUsed: 0 }, @@ -624,8 +624,8 @@ describe("AccountManager", () => { }); it("cooldown is independent from rate limits", () => { - const stored: AccountStorageV3 = { - version: 3, + const stored: AccountStorageV4 = { + version: 4, accounts: [ { refreshToken: "r1", projectId: "p1", addedAt: 1, lastUsed: 0 }, ], @@ -646,8 +646,8 @@ describe("AccountManager", () => { describe("account selection strategies", () => { describe("sticky strategy (default)", () => { it("returns same account on consecutive calls", () => { - const stored: AccountStorageV3 = { - version: 3, + const stored: AccountStorageV4 = { + version: 4, accounts: [ { refreshToken: "r1", projectId: "p1", addedAt: 1, lastUsed: 0 }, { refreshToken: "r2", projectId: "p2", addedAt: 1, lastUsed: 0 }, @@ -667,8 +667,8 @@ describe("AccountManager", () => { }); it("switches account only when current is rate-limited", () => { - const stored: AccountStorageV3 = { - version: 3, + const stored: AccountStorageV4 = { + version: 4, accounts: [ { refreshToken: "r1", projectId: "p1", addedAt: 1, lastUsed: 0 }, { refreshToken: "r2", projectId: "p2", addedAt: 1, lastUsed: 0 }, @@ -690,8 +690,8 @@ describe("AccountManager", () => { describe("round-robin strategy", () => { it("rotates to next account on each call", () => { - const stored: AccountStorageV3 = { - version: 3, + const stored: AccountStorageV4 = { + version: 4, accounts: [ { refreshToken: "r1", projectId: "p1", addedAt: 1, lastUsed: 0 }, { refreshToken: "r2", projectId: "p2", addedAt: 1, lastUsed: 0 }, @@ -712,8 +712,8 @@ describe("AccountManager", () => { }); it("skips rate-limited accounts", () => { - const stored: AccountStorageV3 = { - version: 3, + const stored: AccountStorageV4 = { + version: 4, accounts: [ { refreshToken: "r1", projectId: "p1", addedAt: 1, lastUsed: 0 }, { refreshToken: "r2", projectId: "p2", addedAt: 1, lastUsed: 0 }, @@ -736,8 +736,8 @@ describe("AccountManager", () => { describe("hybrid strategy", () => { it("returns fresh (untouched) accounts first", () => { - const stored: AccountStorageV3 = { - version: 3, + const stored: AccountStorageV4 = { + version: 4, accounts: [ { refreshToken: "r1", projectId: "p1", addedAt: 1, lastUsed: 0 }, { refreshToken: "r2", projectId: "p2", addedAt: 1, lastUsed: 0 }, @@ -759,8 +759,8 @@ describe("AccountManager", () => { }); it("continues to return valid accounts after all touched", () => { - const stored: AccountStorageV3 = { - version: 3, + const stored: AccountStorageV4 = { + version: 4, accounts: [ { refreshToken: "r1", projectId: "p1", addedAt: 1, lastUsed: 0 }, { refreshToken: "r2", projectId: "p2", addedAt: 1, lastUsed: 0 }, @@ -785,8 +785,8 @@ describe("AccountManager", () => { describe("hybrid strategy with token bucket", () => { it("returns account based on health and token availability", () => { - const stored: AccountStorageV3 = { - version: 3, + const stored: AccountStorageV4 = { + version: 4, accounts: [ { refreshToken: "r1", projectId: "p1", addedAt: 1, lastUsed: 0 }, { refreshToken: "r2", projectId: "p2", addedAt: 1, lastUsed: 0 }, @@ -803,8 +803,8 @@ describe("AccountManager", () => { }); it("skips rate-limited accounts", () => { - const stored: AccountStorageV3 = { - version: 3, + const stored: AccountStorageV4 = { + version: 4, accounts: [ { refreshToken: "r1", projectId: "p1", addedAt: 1, lastUsed: 0 }, { refreshToken: "r2", projectId: "p2", addedAt: 1, lastUsed: 0 }, @@ -821,8 +821,8 @@ describe("AccountManager", () => { }); it("skips cooling down accounts", () => { - const stored: AccountStorageV3 = { - version: 3, + const stored: AccountStorageV4 = { + version: 4, accounts: [ { refreshToken: "r1", projectId: "p1", addedAt: 1, lastUsed: 0 }, { refreshToken: "r2", projectId: "p2", addedAt: 1, lastUsed: 0 }, @@ -842,8 +842,8 @@ describe("AccountManager", () => { vi.useFakeTimers(); vi.setSystemTime(new Date(0)); - const stored: AccountStorageV3 = { - version: 3, + const stored: AccountStorageV4 = { + version: 4, accounts: [ { refreshToken: "r1", projectId: "p1", addedAt: 1, lastUsed: 0 }, ], @@ -860,8 +860,8 @@ describe("AccountManager", () => { vi.useFakeTimers(); vi.setSystemTime(new Date(5000)); - const stored: AccountStorageV3 = { - version: 3, + const stored: AccountStorageV4 = { + version: 4, accounts: [ { refreshToken: "r1", projectId: "p1", addedAt: 1, lastUsed: 0 }, { refreshToken: "r2", projectId: "p2", addedAt: 1, lastUsed: 0 }, @@ -884,8 +884,8 @@ describe("AccountManager", () => { vi.useFakeTimers(); vi.setSystemTime(new Date(1000)); - const stored: AccountStorageV3 = { - version: 3, + const stored: AccountStorageV4 = { + version: 4, accounts: [ { refreshToken: "r1", projectId: "p1", addedAt: 1, lastUsed: 0 }, ], @@ -901,8 +901,8 @@ describe("AccountManager", () => { }); it("isFreshForQuota returns true for untouched accounts", () => { - const stored: AccountStorageV3 = { - version: 3, + const stored: AccountStorageV4 = { + version: 4, accounts: [ { refreshToken: "r1", projectId: "p1", addedAt: 1, lastUsed: 0 }, ], @@ -919,8 +919,8 @@ describe("AccountManager", () => { vi.useFakeTimers(); vi.setSystemTime(new Date(1000)); - const stored: AccountStorageV3 = { - version: 3, + const stored: AccountStorageV4 = { + version: 4, accounts: [ { refreshToken: "r1", projectId: "p1", addedAt: 1, lastUsed: 0 }, ], @@ -939,8 +939,8 @@ describe("AccountManager", () => { vi.useFakeTimers(); vi.setSystemTime(new Date(1000)); - const stored: AccountStorageV3 = { - version: 3, + const stored: AccountStorageV4 = { + version: 4, accounts: [ { refreshToken: "r1", projectId: "p1", addedAt: 1, lastUsed: 0 }, ], @@ -962,8 +962,8 @@ describe("AccountManager", () => { describe("consecutiveFailures tracking", () => { it("initializes consecutiveFailures as undefined", () => { - const stored: AccountStorageV3 = { - version: 3, + const stored: AccountStorageV4 = { + version: 4, accounts: [ { refreshToken: "r1", projectId: "p1", addedAt: 1, lastUsed: 0 }, ], @@ -977,8 +977,8 @@ describe("AccountManager", () => { }); it("can increment and reset consecutiveFailures", () => { - const stored: AccountStorageV3 = { - version: 3, + const stored: AccountStorageV4 = { + version: 4, accounts: [ { refreshToken: "r1", projectId: "p1", addedAt: 1, lastUsed: 0 }, ], @@ -1001,8 +1001,8 @@ describe("AccountManager", () => { describe("Issue #147: headerStyle-aware account selection", () => { it("skips account when requested headerStyle is rate-limited even if other style is available", () => { - const stored: AccountStorageV3 = { - version: 3, + const stored: AccountStorageV4 = { + version: 4, accounts: [ { refreshToken: "r1", projectId: "p1", addedAt: 1, lastUsed: 0 }, { refreshToken: "r2", projectId: "p2", addedAt: 1, lastUsed: 0 }, @@ -1037,8 +1037,8 @@ describe("AccountManager", () => { }); it("returns same account when a different headerStyle is rate-limited", () => { - const stored: AccountStorageV3 = { - version: 3, + const stored: AccountStorageV4 = { + version: 4, accounts: [ { refreshToken: "r1", projectId: "p1", addedAt: 1, lastUsed: 0 }, { refreshToken: "r2", projectId: "p2", addedAt: 1, lastUsed: 0 }, @@ -1070,8 +1070,8 @@ describe("AccountManager", () => { it("requestSaveToDisk coalesces multiple calls into one write", async () => { vi.useFakeTimers(); - const stored: AccountStorageV3 = { - version: 3, + const stored: AccountStorageV4 = { + version: 4, accounts: [ { refreshToken: "r1", projectId: "p1", addedAt: 1, lastUsed: 0 }, ], @@ -1097,8 +1097,8 @@ describe("AccountManager", () => { it("flushSaveToDisk waits for pending save to complete", async () => { vi.useFakeTimers(); - const stored: AccountStorageV3 = { - version: 3, + const stored: AccountStorageV4 = { + version: 4, accounts: [ { refreshToken: "r1", projectId: "p1", addedAt: 1, lastUsed: 0 }, ], @@ -1123,8 +1123,8 @@ describe("AccountManager", () => { it("does not save again if no new requestSaveToDisk after flush", async () => { vi.useFakeTimers(); - const stored: AccountStorageV3 = { - version: 3, + const stored: AccountStorageV4 = { + version: 4, accounts: [ { refreshToken: "r1", projectId: "p1", addedAt: 1, lastUsed: 0 }, ], @@ -1152,8 +1152,8 @@ describe("AccountManager", () => { vi.useFakeTimers(); vi.setSystemTime(new Date(0)); - const stored: AccountStorageV3 = { - version: 3, + const stored: AccountStorageV4 = { + version: 4, accounts: [ { refreshToken: "r1", projectId: "p1", addedAt: 1, lastUsed: 0 }, ], @@ -1248,8 +1248,8 @@ describe("AccountManager", () => { vi.useFakeTimers(); vi.setSystemTime(1000); - const stored: AccountStorageV3 = { - version: 3, + const stored: AccountStorageV4 = { + version: 4, accounts: [ { refreshToken: "r1", projectId: "p1", addedAt: 1, lastUsed: 0 }, ], @@ -1284,8 +1284,8 @@ describe("AccountManager", () => { vi.useFakeTimers(); vi.setSystemTime(1000); - const stored: AccountStorageV3 = { - version: 3, + const stored: AccountStorageV4 = { + version: 4, accounts: [ { refreshToken: "r1", projectId: "p1", addedAt: 1, lastUsed: 0 }, ], @@ -1306,8 +1306,8 @@ describe("AccountManager", () => { describe("markRequestSuccess", () => { it("resets consecutive failure counter", () => { - const stored: AccountStorageV3 = { - version: 3, + const stored: AccountStorageV4 = { + version: 4, accounts: [ { refreshToken: "r1", projectId: "p1", addedAt: 1, lastUsed: 0 }, ], @@ -1328,8 +1328,8 @@ describe("AccountManager", () => { vi.useFakeTimers(); vi.setSystemTime(10_000); - const stored: AccountStorageV3 = { - version: 3, + const stored: AccountStorageV4 = { + version: 4, accounts: [ { refreshToken: "r1", projectId: "p1", addedAt: 1, lastUsed: 0, rateLimitResetTimes: { "gemini-antigravity": 11_500, "gemini-cli": 11_500 } }, ], @@ -1346,8 +1346,8 @@ describe("AccountManager", () => { vi.useFakeTimers(); vi.setSystemTime(10_000); - const stored: AccountStorageV3 = { - version: 3, + const stored: AccountStorageV4 = { + version: 4, accounts: [ { refreshToken: "r1", projectId: "p1", addedAt: 1, lastUsed: 0, rateLimitResetTimes: { "gemini-antigravity": 15_000, "gemini-cli": 15_000 } }, ], @@ -1361,8 +1361,8 @@ describe("AccountManager", () => { }); it("shouldTryOptimisticReset returns false when accounts are available", () => { - const stored: AccountStorageV3 = { - version: 3, + const stored: AccountStorageV4 = { + version: 4, accounts: [ { refreshToken: "r1", projectId: "p1", addedAt: 1, lastUsed: 0 }, ], @@ -1377,8 +1377,8 @@ describe("AccountManager", () => { vi.useFakeTimers(); vi.setSystemTime(10_000); - const stored: AccountStorageV3 = { - version: 3, + const stored: AccountStorageV4 = { + version: 4, accounts: [ { refreshToken: "r1", projectId: "p1", addedAt: 1, lastUsed: 0, rateLimitResetTimes: { "gemini-antigravity": 70_000, "gemini-cli": 80_000 } }, { refreshToken: "r2", projectId: "p2", addedAt: 2, lastUsed: 0, rateLimitResetTimes: { "gemini-antigravity": 90_000 } }, @@ -1409,8 +1409,8 @@ describe("AccountManager", () => { vi.useFakeTimers(); vi.setSystemTime(new Date(0)); - const stored: AccountStorageV3 = { - version: 3, + const stored: AccountStorageV4 = { + version: 4, accounts: [ { refreshToken: "r1", projectId: "p1", addedAt: 1, lastUsed: 0 }, ], @@ -1439,8 +1439,8 @@ describe("AccountManager", () => { vi.useFakeTimers(); vi.setSystemTime(new Date(0)); - const stored: AccountStorageV3 = { - version: 3, + const stored: AccountStorageV4 = { + version: 4, accounts: [ { refreshToken: "r1", projectId: "p1", addedAt: 1, lastUsed: 0 }, ], @@ -1467,8 +1467,8 @@ describe("AccountManager", () => { describe("Fingerprint History", () => { it("regenerateAccountFingerprint saves old fingerprint to history", () => { - const stored: AccountStorageV3 = { - version: 3, + const stored: AccountStorageV4 = { + version: 4, accounts: [ { refreshToken: "r1", projectId: "p1", addedAt: 1, lastUsed: 0 }, ], @@ -1493,8 +1493,8 @@ describe("AccountManager", () => { vi.useFakeTimers(); vi.setSystemTime(new Date(1000)); // Start at 1000 to avoid 0 being falsy - const stored: AccountStorageV3 = { - version: 3, + const stored: AccountStorageV4 = { + version: 4, accounts: [ { refreshToken: "r1", projectId: "p1", addedAt: 1, lastUsed: 0 }, ], @@ -1533,8 +1533,8 @@ describe("AccountManager", () => { }); it("getAccountFingerprintHistory returns empty array for new account", () => { - const stored: AccountStorageV3 = { - version: 3, + const stored: AccountStorageV4 = { + version: 4, accounts: [ { refreshToken: "r1", projectId: "p1", addedAt: 1, lastUsed: 0 }, ], @@ -1548,8 +1548,8 @@ describe("AccountManager", () => { }); it("limits fingerprint history to MAX_FINGERPRINT_HISTORY", () => { - const stored: AccountStorageV3 = { - version: 3, + const stored: AccountStorageV4 = { + version: 4, accounts: [ { refreshToken: "r1", projectId: "p1", addedAt: 1, lastUsed: 0 }, ], @@ -1570,8 +1570,8 @@ describe("AccountManager", () => { describe("soft quota threshold", () => { it("skips account over soft quota threshold in sticky mode", () => { - const stored: AccountStorageV3 = { - version: 3, + const stored: AccountStorageV4 = { + version: 4, accounts: [ { refreshToken: "r1", projectId: "p1", addedAt: 1, lastUsed: 0 }, { refreshToken: "r2", projectId: "p2", addedAt: 2, lastUsed: 0 }, @@ -1587,8 +1587,8 @@ describe("AccountManager", () => { }); it("allows account under soft quota threshold", () => { - const stored: AccountStorageV3 = { - version: 3, + const stored: AccountStorageV4 = { + version: 4, accounts: [ { refreshToken: "r1", projectId: "p1", addedAt: 1, lastUsed: 0 }, ], @@ -1603,8 +1603,8 @@ describe("AccountManager", () => { }); it("threshold of 100 disables soft quota protection", () => { - const stored: AccountStorageV3 = { - version: 3, + const stored: AccountStorageV4 = { + version: 4, accounts: [ { refreshToken: "r1", projectId: "p1", addedAt: 1, lastUsed: 0 }, ], @@ -1619,8 +1619,8 @@ describe("AccountManager", () => { }); it("returns null when all accounts over threshold", () => { - const stored: AccountStorageV3 = { - version: 3, + const stored: AccountStorageV4 = { + version: 4, accounts: [ { refreshToken: "r1", projectId: "p1", addedAt: 1, lastUsed: 0 }, { refreshToken: "r2", projectId: "p2", addedAt: 2, lastUsed: 0 }, @@ -1637,8 +1637,8 @@ describe("AccountManager", () => { }); it("skips account over threshold in round-robin mode", () => { - const stored: AccountStorageV3 = { - version: 3, + const stored: AccountStorageV4 = { + version: 4, accounts: [ { refreshToken: "r1", projectId: "p1", addedAt: 1, lastUsed: 0 }, { refreshToken: "r2", projectId: "p2", addedAt: 2, lastUsed: 0 }, @@ -1654,8 +1654,8 @@ describe("AccountManager", () => { }); it("account without cached quota is not skipped", () => { - const stored: AccountStorageV3 = { - version: 3, + const stored: AccountStorageV4 = { + version: 4, accounts: [ { refreshToken: "r1", projectId: "p1", addedAt: 1, lastUsed: 0 }, ], @@ -1669,8 +1669,8 @@ describe("AccountManager", () => { }); it("handles remainingFraction of 0 (fully exhausted)", () => { - const stored: AccountStorageV3 = { - version: 3, + const stored: AccountStorageV4 = { + version: 4, accounts: [ { refreshToken: "r1", projectId: "p1", addedAt: 1, lastUsed: 0 }, { refreshToken: "r2", projectId: "p2", addedAt: 2, lastUsed: 0 }, @@ -1689,8 +1689,8 @@ describe("AccountManager", () => { vi.useFakeTimers(); vi.setSystemTime(new Date(0)); - const stored: AccountStorageV3 = { - version: 3, + const stored: AccountStorageV4 = { + version: 4, accounts: [ { refreshToken: "r1", projectId: "p1", addedAt: 1, lastUsed: 0 }, ], @@ -1709,8 +1709,8 @@ describe("AccountManager", () => { }); it("fails open when cachedQuotaUpdatedAt is missing", () => { - const stored: AccountStorageV3 = { - version: 3, + const stored: AccountStorageV4 = { + version: 4, accounts: [ { refreshToken: "r1", projectId: "p1", addedAt: 1, lastUsed: 0 }, ], @@ -1729,8 +1729,8 @@ describe("AccountManager", () => { describe("getMinWaitTimeForSoftQuota", () => { it("returns 0 when accounts are available (under threshold)", () => { - const stored: AccountStorageV3 = { - version: 3, + const stored: AccountStorageV4 = { + version: 4, accounts: [ { refreshToken: "r1", projectId: "p1", addedAt: 1, lastUsed: 0 }, ], @@ -1745,8 +1745,8 @@ describe("AccountManager", () => { }); it("returns null when no resetTime available", () => { - const stored: AccountStorageV3 = { - version: 3, + const stored: AccountStorageV4 = { + version: 4, accounts: [ { refreshToken: "r1", projectId: "p1", addedAt: 1, lastUsed: 0 }, ], @@ -1764,8 +1764,8 @@ describe("AccountManager", () => { vi.useFakeTimers(); vi.setSystemTime(new Date("2026-01-28T10:00:00Z")); - const stored: AccountStorageV3 = { - version: 3, + const stored: AccountStorageV4 = { + version: 4, accounts: [ { refreshToken: "r1", projectId: "p1", addedAt: 1, lastUsed: 0 }, ], @@ -1791,8 +1791,8 @@ describe("AccountManager", () => { vi.useFakeTimers(); vi.setSystemTime(new Date("2026-01-28T16:00:00Z")); - const stored: AccountStorageV3 = { - version: 3, + const stored: AccountStorageV4 = { + version: 4, accounts: [ { refreshToken: "r1", projectId: "p1", addedAt: 1, lastUsed: 0 }, ], @@ -1818,8 +1818,8 @@ describe("AccountManager", () => { vi.useFakeTimers(); vi.setSystemTime(new Date("2026-01-28T10:00:00Z")); - const stored: AccountStorageV3 = { - version: 3, + const stored: AccountStorageV4 = { + version: 4, accounts: [ { refreshToken: "r1", projectId: "p1", addedAt: 1, lastUsed: 0 }, { refreshToken: "r2", projectId: "p2", addedAt: 2, lastUsed: 0 }, diff --git a/src/plugin/accounts.ts b/src/plugin/accounts.ts index 01eed27..e86ed5f 100644 --- a/src/plugin/accounts.ts +++ b/src/plugin/accounts.ts @@ -1,5 +1,5 @@ import { formatRefreshParts, parseRefreshParts } from "./auth"; -import { loadAccounts, saveAccounts, type AccountStorageV3, type AccountMetadataV3, type RateLimitStateV3, type ModelFamily, type HeaderStyle, type CooldownReason } from "./storage"; +import { loadAccounts, saveAccounts, type AccountStorageV4, type AccountMetadataV3, type RateLimitStateV3, type ModelFamily, type HeaderStyle, type CooldownReason } from "./storage"; import type { OAuthAuthDetails, RefreshParts } from "./types"; import type { AccountSelectionStrategy } from "./config/schema"; import { getHealthTracker, getTokenTracker, selectHybridAccount, type AccountWithMetrics } from "./rotation"; @@ -318,7 +318,7 @@ export class AccountManager { return new AccountManager(authFallback, stored); } - constructor(authFallback?: OAuthAuthDetails, stored?: AccountStorageV3 | null) { + constructor(authFallback?: OAuthAuthDetails, stored?: AccountStorageV4 | null) { const authParts = authFallback ? parseRefreshParts(authFallback.refresh) : null; if (stored && stored.accounts.length === 0) { @@ -359,8 +359,8 @@ export class AccountManager { coolingDownUntil: acc.coolingDownUntil, cooldownReason: acc.cooldownReason, touchedForQuota: {}, - // Always regenerate fingerprint on load to pick up alignment changes - fingerprint: generateFingerprint(), + fingerprint: acc.fingerprint ?? generateFingerprint(), + fingerprintHistory: acc.fingerprintHistory ?? [], cachedQuota: acc.cachedQuota as Partial> | undefined, cachedQuotaUpdatedAt: acc.cachedQuotaUpdatedAt, verificationRequired: acc.verificationRequired, @@ -977,8 +977,8 @@ export class AccountManager { const claudeIndex = Math.max(0, this.currentAccountIndexByFamily.claude); const geminiIndex = Math.max(0, this.currentAccountIndexByFamily.gemini); - const storage: AccountStorageV3 = { - version: 3, + const storage: AccountStorageV4 = { + version: 4, accounts: this.accounts.map((a) => ({ email: a.email, refreshToken: a.parts.refreshToken, diff --git a/src/plugin/antigravity-first-fallback.test.ts b/src/plugin/antigravity-first-fallback.test.ts index 0006eb9..917872e 100644 --- a/src/plugin/antigravity-first-fallback.test.ts +++ b/src/plugin/antigravity-first-fallback.test.ts @@ -1,7 +1,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import { AccountManager, type ModelFamily, type HeaderStyle } from "./accounts"; -import type { AccountStorageV3 } from "./storage"; +import type { AccountStorageV4 } from "./storage"; /** * Test: Antigravity-first fallback logic @@ -21,8 +21,8 @@ describe("Antigravity-first fallback", () => { describe("hasOtherAccountWithAntigravityAvailable", () => { it("returns true when another account has antigravity available", () => { - const stored: AccountStorageV3 = { - version: 3, + const stored: AccountStorageV4 = { + version: 4, accounts: [ { refreshToken: "r1", projectId: "p1", addedAt: 1, lastUsed: 0 }, { refreshToken: "r2", projectId: "p2", addedAt: 1, lastUsed: 0 }, @@ -47,8 +47,8 @@ describe("Antigravity-first fallback", () => { }); it("returns false when all other accounts are also rate-limited for antigravity", () => { - const stored: AccountStorageV3 = { - version: 3, + const stored: AccountStorageV4 = { + version: 4, accounts: [ { refreshToken: "r1", projectId: "p1", addedAt: 1, lastUsed: 0 }, { refreshToken: "r2", projectId: "p2", addedAt: 1, lastUsed: 0 }, @@ -73,8 +73,8 @@ describe("Antigravity-first fallback", () => { }); it("skips disabled accounts", () => { - const stored: AccountStorageV3 = { - version: 3, + const stored: AccountStorageV4 = { + version: 4, accounts: [ { refreshToken: "r1", projectId: "p1", addedAt: 1, lastUsed: 0 }, { refreshToken: "r2", projectId: "p2", addedAt: 1, lastUsed: 0, enabled: false }, @@ -99,8 +99,8 @@ describe("Antigravity-first fallback", () => { }); it("skips cooling down accounts", () => { - const stored: AccountStorageV3 = { - version: 3, + const stored: AccountStorageV4 = { + version: 4, accounts: [ { refreshToken: "r1", projectId: "p1", addedAt: 1, lastUsed: 0 }, { refreshToken: "r2", projectId: "p2", addedAt: 1, lastUsed: 0 }, @@ -126,8 +126,8 @@ describe("Antigravity-first fallback", () => { }); it("works with model-specific rate limits", () => { - const stored: AccountStorageV3 = { - version: 3, + const stored: AccountStorageV4 = { + version: 4, accounts: [ { refreshToken: "r1", projectId: "p1", addedAt: 1, lastUsed: 0 }, { refreshToken: "r2", projectId: "p2", addedAt: 1, lastUsed: 0 }, @@ -152,8 +152,8 @@ describe("Antigravity-first fallback", () => { }); it("returns false for Claude family (no gemini-cli fallback)", () => { - const stored: AccountStorageV3 = { - version: 3, + const stored: AccountStorageV4 = { + version: 4, accounts: [ { refreshToken: "r1", projectId: "p1", addedAt: 1, lastUsed: 0 }, { refreshToken: "r2", projectId: "p2", addedAt: 1, lastUsed: 0 }, @@ -177,8 +177,8 @@ describe("Antigravity-first fallback", () => { describe("Pre-check fallback logic", () => { it("should switch to account with antigravity rather than fall back to gemini-cli", () => { - const stored: AccountStorageV3 = { - version: 3, + const stored: AccountStorageV4 = { + version: 4, accounts: [ { refreshToken: "r1", projectId: "p1", addedAt: 1, lastUsed: 0 }, { refreshToken: "r2", projectId: "p2", addedAt: 1, lastUsed: 0 }, @@ -211,8 +211,8 @@ describe("Antigravity-first fallback", () => { }); it("should only fall back to gemini-cli when ALL accounts exhausted antigravity", () => { - const stored: AccountStorageV3 = { - version: 3, + const stored: AccountStorageV4 = { + version: 4, accounts: [ { refreshToken: "r1", projectId: "p1", addedAt: 1, lastUsed: 0 }, { refreshToken: "r2", projectId: "p2", addedAt: 1, lastUsed: 0 }, diff --git a/src/plugin/persist-account-pool.test.ts b/src/plugin/persist-account-pool.test.ts index db0b539..b98c590 100644 --- a/src/plugin/persist-account-pool.test.ts +++ b/src/plugin/persist-account-pool.test.ts @@ -11,7 +11,7 @@ import { describe, expect, it, vi, beforeEach, afterEach } from "vitest"; import { promises as fs } from "node:fs"; import * as storageModule from "./storage"; -import type { AccountStorageV3, AccountMetadataV3 } from "./storage"; +import type { AccountStorageV4, AccountMetadataV3 } from "./storage"; vi.mock("proper-lockfile", () => ({ default: { @@ -46,9 +46,9 @@ function createMockAccount(overrides: Partial = {}): AccountM }; } -function createMockStorage(accounts: AccountMetadataV3[], activeIndex = 0): AccountStorageV3 { +function createMockStorage(accounts: AccountMetadataV3[], activeIndex = 0): AccountStorageV4 { return { - version: 3, + version: 4, accounts, activeIndex, }; @@ -83,7 +83,7 @@ describe("loadAccounts", () => { const result = await storageModule.loadAccounts(); expect(result).not.toBeNull(); - expect(result?.version).toBe(3); + expect(result?.version).toBe(4); expect(result?.accounts).toHaveLength(1); }); @@ -141,7 +141,7 @@ describe("loadAccounts", () => { }); it("returns null on invalid storage format", async () => { - vi.mocked(fs.readFile).mockResolvedValue(JSON.stringify({ version: 3, notAccounts: [] })); + vi.mocked(fs.readFile).mockResolvedValue(JSON.stringify({ version: 4, notAccounts: [] })); const result = await storageModule.loadAccounts(); @@ -175,7 +175,7 @@ describe("loadAccounts", () => { const result = await storageModule.loadAccounts(); - expect(result?.version).toBe(3); + expect(result?.version).toBe(4); expect(result?.accounts).toHaveLength(1); }); }); @@ -197,7 +197,7 @@ describe("saveAccounts", () => { const writtenContent = vi.mocked(fs.writeFile).mock.calls[0]?.[1]; expect(writtenContent).toBeDefined(); const parsed = JSON.parse(writtenContent as string); - expect(parsed.version).toBe(3); + expect(parsed.version).toBe(4); expect(parsed.accounts).toHaveLength(1); }); }); diff --git a/src/plugin/refresh-queue.test.ts b/src/plugin/refresh-queue.test.ts index 8bae7a8..d87903f 100644 --- a/src/plugin/refresh-queue.test.ts +++ b/src/plugin/refresh-queue.test.ts @@ -1,7 +1,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import { ProactiveRefreshQueue } from "./refresh-queue"; import { AccountManager } from "./accounts"; -import type { AccountStorageV3 } from "./storage"; +import type { AccountStorageV4 } from "./storage"; import type { PluginClient } from "./types"; // Mock PluginClient @@ -22,8 +22,8 @@ describe("ProactiveRefreshQueue", () => { describe("getAccountsNeedingRefresh", () => { it("skips disabled accounts", () => { const now = Date.now(); - const stored: AccountStorageV3 = { - version: 3, + const stored: AccountStorageV4 = { + version: 4, accounts: [ { refreshToken: "r1", @@ -75,8 +75,8 @@ describe("ProactiveRefreshQueue", () => { it("includes accounts with undefined enabled (default to enabled)", () => { const now = Date.now(); - const stored: AccountStorageV3 = { - version: 3, + const stored: AccountStorageV4 = { + version: 4, accounts: [ { refreshToken: "r1", @@ -109,8 +109,8 @@ describe("ProactiveRefreshQueue", () => { it("skips expired accounts", () => { const now = Date.now(); - const stored: AccountStorageV3 = { - version: 3, + const stored: AccountStorageV4 = { + version: 4, accounts: [ { refreshToken: "r1", @@ -142,8 +142,8 @@ describe("ProactiveRefreshQueue", () => { it("skips accounts that don't need refresh yet", () => { const now = Date.now(); - const stored: AccountStorageV3 = { - version: 3, + const stored: AccountStorageV4 = { + version: 4, accounts: [ { refreshToken: "r1", diff --git a/src/plugin/storage.test.ts b/src/plugin/storage.test.ts index e935764..e1c6f9e 100644 --- a/src/plugin/storage.test.ts +++ b/src/plugin/storage.test.ts @@ -373,7 +373,7 @@ describe("Storage Migration", () => { vi.clearAllMocks(); }); - it("migrates V2 storage on load and persists V3", async () => { + it("migrates V2 storage on load and persists V4", async () => { const v2Data = { version: 2, accounts: [ @@ -402,7 +402,7 @@ describe("Storage Migration", () => { const result = await loadAccounts(); expect(result).not.toBeNull(); - expect(result?.version).toBe(3); + expect(result?.version).toBe(4); const account = result?.accounts[0]; if (!account) throw new Error("Account not found"); @@ -419,7 +419,7 @@ describe("Storage Migration", () => { if (!saveCall) throw new Error("saveAccounts was not called (tmp file not found)"); const savedContent = JSON.parse(saveCall[1] as string); - expect(savedContent.version).toBe(3); + expect(savedContent.version).toBe(4); expect(savedContent.accounts[0].rateLimitResetTimes).toEqual({ "gemini-antigravity": future, }); diff --git a/src/plugin/storage.ts b/src/plugin/storage.ts index 7d42152..6dac278 100644 --- a/src/plugin/storage.ts +++ b/src/plugin/storage.ts @@ -193,6 +193,7 @@ export interface AccountMetadataV3 { cooldownReason?: CooldownReason; /** Per-account device fingerprint for rate limit mitigation */ fingerprint?: import("./fingerprint").Fingerprint; + fingerprintHistory?: import("./fingerprint").FingerprintVersion[]; /** Set when Google asks the user to verify this account before requests can continue. */ verificationRequired?: boolean; verificationRequiredAt?: number; @@ -213,7 +214,21 @@ export interface AccountStorageV3 { }; } -type AnyAccountStorage = AccountStorageV1 | AccountStorage | AccountStorageV3; +export interface AccountStorageV4 { + version: 4; + accounts: AccountMetadataV3[]; + activeIndex: number; + activeIndexByFamily?: { + claude?: number; + gemini?: number; + }; +} + +type AnyAccountStorage = + | AccountStorageV1 + | AccountStorage + | AccountStorageV3 + | AccountStorageV4; /** * Gets the legacy Windows config directory (%APPDATA%\opencode). @@ -361,7 +376,7 @@ async function ensureFileExists(path: string): Promise { await fs.mkdir(dirname(path), { recursive: true }); await fs.writeFile( path, - JSON.stringify({ version: 3, accounts: [], activeIndex: 0 }, null, 2), + JSON.stringify({ version: 4, accounts: [], activeIndex: 0 }, null, 2), { encoding: "utf-8", mode: 0o600 }, ); } @@ -385,9 +400,9 @@ async function withFileLock(path: string, fn: () => Promise): Promise { } function mergeAccountStorage( - existing: AccountStorageV3, - incoming: AccountStorageV3, -): AccountStorageV3 { + existing: AccountStorageV4, + incoming: AccountStorageV4, +): AccountStorageV4 { const accountMap = new Map(); for (const acc of existing.accounts) { @@ -419,7 +434,7 @@ function mergeAccountStorage( } return { - version: 3, + version: 4, accounts: Array.from(accountMap.values()), activeIndex: incoming.activeIndex, activeIndexByFamily: incoming.activeIndexByFamily, @@ -558,7 +573,20 @@ export function migrateV2ToV3(v2: AccountStorage): AccountStorageV3 { }; } -export async function loadAccounts(): Promise { +export function migrateV3ToV4(v3: AccountStorageV3): AccountStorageV4 { + return { + version: 4, + accounts: v3.accounts.map((acc) => ({ + ...acc, + fingerprint: undefined, + fingerprintHistory: undefined, + })), + activeIndex: v3.activeIndex, + activeIndexByFamily: v3.activeIndexByFamily, + }; +} + +export async function loadAccounts(): Promise { try { const path = getStoragePath(); // Ensure permissions are correct on load (fixes existing files) @@ -572,32 +600,45 @@ export async function loadAccounts(): Promise { return null; } - let storage: AccountStorageV3; + let storage: AccountStorageV4; if (data.version === 1) { - log.info("Migrating account storage from v1 to v3"); + log.info("Migrating account storage from v1 to v4"); const v2 = migrateV1ToV2(data); - storage = migrateV2ToV3(v2); + const v3 = migrateV2ToV3(v2); + storage = migrateV3ToV4(v3); try { await saveAccounts(storage); - log.info("Migration to v3 complete"); + log.info("Migration to v4 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); + log.info("Migrating account storage from v2 to v4"); + const v3 = migrateV2ToV3(data); + storage = migrateV3ToV4(v3); try { await saveAccounts(storage); - log.info("Migration to v3 complete"); + log.info("Migration to v4 complete"); } catch (saveError) { log.warn("Failed to persist migrated storage", { error: String(saveError), }); } } else if (data.version === 3) { + log.info("Migrating account storage from v3 to v4"); + storage = migrateV3ToV4(data); + try { + await saveAccounts(storage); + log.info("Migration to v4 complete"); + } catch (saveError) { + log.warn("Failed to persist migrated storage", { + error: String(saveError), + }); + } + } else if (data.version === 4) { storage = data; } else { log.warn("Unknown storage version, ignoring", { @@ -634,7 +675,7 @@ export async function loadAccounts(): Promise { } return { - version: 3, + version: 4, accounts: deduplicatedAccounts, activeIndex, }; @@ -648,7 +689,7 @@ export async function loadAccounts(): Promise { } } -export async function saveAccounts(storage: AccountStorageV3): Promise { +export async function saveAccounts(storage: AccountStorageV4): Promise { const path = getStoragePath(); const configDir = dirname(path); await fs.mkdir(configDir, { recursive: true }); @@ -681,7 +722,7 @@ export async function saveAccounts(storage: AccountStorageV3): Promise { * Use this for destructive operations like delete where we need to * remove accounts that would otherwise be merged back from existing storage. */ -export async function saveAccountsReplace(storage: AccountStorageV3): Promise { +export async function saveAccountsReplace(storage: AccountStorageV4): Promise { const path = getStoragePath(); const configDir = dirname(path); await fs.mkdir(configDir, { recursive: true }); @@ -705,7 +746,7 @@ export async function saveAccountsReplace(storage: AccountStorageV3): Promise { +async function loadAccountsUnsafe(): Promise { try { const path = getStoragePath(); // Ensure permissions are correct on load (fixes existing files) @@ -715,10 +756,13 @@ async function loadAccountsUnsafe(): Promise { const parsed = JSON.parse(content); if (parsed.version === 1) { - return migrateV2ToV3(migrateV1ToV2(parsed)); + return migrateV3ToV4(migrateV2ToV3(migrateV1ToV2(parsed))); } if (parsed.version === 2) { - return migrateV2ToV3(parsed); + return migrateV3ToV4(migrateV2ToV3(parsed)); + } + if (parsed.version === 3) { + return migrateV3ToV4(parsed); } return {