diff --git a/lib/codex-cli/sync.ts b/lib/codex-cli/sync.ts index 38e66050..ba0257de 100644 --- a/lib/codex-cli/sync.ts +++ b/lib/codex-cli/sync.ts @@ -1,34 +1,10 @@ -import { - getLastAccountsSaveTimestamp, - type AccountMetadataV3, - type AccountStorageV3, -} from "../storage.js"; -import { MODEL_FAMILIES, type ModelFamily } from "../prompts/codex.js"; import { createLogger } from "../logger.js"; -import { loadCodexCliState, type CodexCliAccountSnapshot } from "./state.js"; -import { - incrementCodexCliMetric, - makeAccountFingerprint, -} from "./observability.js"; -import { getLastCodexCliSelectionWriteTimestamp } from "./writer.js"; +import { MODEL_FAMILIES, type ModelFamily } from "../prompts/codex.js"; +import { type AccountStorageV3 } from "../storage.js"; +import { incrementCodexCliMetric } from "./observability.js"; const log = createLogger("codex-cli-sync"); -function normalizeEmail(value: string | undefined): string | undefined { - if (!value) return undefined; - const trimmed = value.trim().toLowerCase(); - return trimmed.length > 0 ? trimmed : undefined; -} - -function createEmptyStorage(): AccountStorageV3 { - return { - version: 3, - accounts: [], - activeIndex: 0, - activeIndexByFamily: {}, - }; -} - function cloneStorage(storage: AccountStorageV3): AccountStorageV3 { return { version: 3, @@ -40,325 +16,87 @@ function cloneStorage(storage: AccountStorageV3): AccountStorageV3 { }; } -function buildIndexByAccountId(accounts: AccountMetadataV3[]): Map { - const map = new Map(); - for (let i = 0; i < accounts.length; i += 1) { - const account = accounts[i]; - if (!account?.accountId) continue; - map.set(account.accountId, i); +function normalizeIndexCandidate(value: number, fallback: number): number { + if (!Number.isFinite(value)) { + return Number.isFinite(fallback) ? Math.trunc(fallback) : 0; } - return map; + return Math.trunc(value); } -function buildIndexByRefresh(accounts: AccountMetadataV3[]): Map { - const map = new Map(); - for (let i = 0; i < accounts.length; i += 1) { - const account = accounts[i]; - if (!account?.refreshToken) continue; - map.set(account.refreshToken, i); - } - return map; -} - -function buildIndexByEmail(accounts: AccountMetadataV3[]): Map { - const map = new Map(); - for (let i = 0; i < accounts.length; i += 1) { - const email = normalizeEmail(accounts[i]?.email); - if (!email) continue; - map.set(email, i); - } - return map; -} - -function toStorageAccount(snapshot: CodexCliAccountSnapshot): AccountMetadataV3 | null { - if (!snapshot.refreshToken) return null; - const now = Date.now(); - return { - accountId: snapshot.accountId, - accountIdSource: snapshot.accountId ? "token" : undefined, - email: snapshot.email, - refreshToken: snapshot.refreshToken, - accessToken: snapshot.accessToken, - expiresAt: snapshot.expiresAt, - enabled: true, - addedAt: now, - lastUsed: 0, - }; -} - -function upsertFromSnapshot( - accounts: AccountMetadataV3[], - snapshot: CodexCliAccountSnapshot, -): boolean { - const nextAccount = toStorageAccount(snapshot); - if (!nextAccount) return false; - - const byAccountId = buildIndexByAccountId(accounts); - const byRefresh = buildIndexByRefresh(accounts); - const byEmail = buildIndexByEmail(accounts); - const normalizedEmail = normalizeEmail(snapshot.email); - - let targetIndex: number | undefined; - if (snapshot.accountId && byAccountId.has(snapshot.accountId)) { - targetIndex = byAccountId.get(snapshot.accountId); - } else if (snapshot.refreshToken && byRefresh.has(snapshot.refreshToken)) { - targetIndex = byRefresh.get(snapshot.refreshToken); - } else if (normalizedEmail && byEmail.has(normalizedEmail)) { - targetIndex = byEmail.get(normalizedEmail); - } - - if (targetIndex === undefined) { - accounts.push(nextAccount); - return true; - } - - const current = accounts[targetIndex]; - if (!current) return false; - - const merged: AccountMetadataV3 = { - ...current, - accountId: snapshot.accountId ?? current.accountId, - accountIdSource: - snapshot.accountId - ? current.accountIdSource ?? "token" - : current.accountIdSource, - email: snapshot.email ?? current.email, - refreshToken: snapshot.refreshToken ?? current.refreshToken, - accessToken: snapshot.accessToken ?? current.accessToken, - expiresAt: snapshot.expiresAt ?? current.expiresAt, - }; - - const changed = JSON.stringify(current) !== JSON.stringify(merged); - if (changed) { - accounts[targetIndex] = merged; - } - return changed; -} - -function resolveActiveIndex( - accounts: AccountMetadataV3[], - activeAccountId: string | undefined, - activeEmail: string | undefined, -): number { - if (accounts.length === 0) return 0; - - if (activeAccountId) { - const byId = accounts.findIndex((account) => account.accountId === activeAccountId); - if (byId >= 0) return byId; - } - - const normalizedEmail = normalizeEmail(activeEmail); - if (normalizedEmail) { - const byEmail = accounts.findIndex( - (account) => normalizeEmail(account.email) === normalizedEmail, - ); - if (byEmail >= 0) return byEmail; - } - - return 0; -} - -function writeFamilyIndexes( - storage: AccountStorageV3, - index: number, -): void { - storage.activeIndex = index; - storage.activeIndexByFamily = storage.activeIndexByFamily ?? {}; - for (const family of MODEL_FAMILIES) { - storage.activeIndexByFamily[family] = index; - } -} - -/** - * Normalize and clamp the global and per-family active account indexes to valid ranges. - * - * Mutates `storage` in-place: ensures `activeIndexByFamily` exists, clamps `activeIndex` to - * 0..(accounts.length - 1) (or 0 when there are no accounts), and resolves each family entry - * to a valid index within the same bounds. - * - * Concurrency: callers must synchronize externally when multiple threads/processes may write - * the same storage object. Filesystem notes: no platform-specific IO is performed here; when - * persisted to disk on Windows consumers should still ensure atomic writes. Token handling: - * this function does not read or modify authentication tokens and makes no attempt to redact - * sensitive fields. - * - * @param storage - The account storage object whose indexes will be normalized and clamped - */ function normalizeStoredFamilyIndexes(storage: AccountStorageV3): void { const count = storage.accounts.length; - const clamped = count === 0 ? 0 : Math.max(0, Math.min(storage.activeIndex, count - 1)); + const normalizedActiveIndex = normalizeIndexCandidate(storage.activeIndex, 0); + const clamped = + count === 0 ? 0 : Math.max(0, Math.min(normalizedActiveIndex, count - 1)); if (storage.activeIndex !== clamped) { storage.activeIndex = clamped; } storage.activeIndexByFamily = storage.activeIndexByFamily ?? {}; for (const family of MODEL_FAMILIES) { + const hasFamilyIndex = Object.prototype.hasOwnProperty.call( + storage.activeIndexByFamily, + family, + ); const raw = storage.activeIndexByFamily[family]; const resolved = - typeof raw === "number" && Number.isFinite(raw) ? raw : storage.activeIndex; - storage.activeIndexByFamily[family] = + typeof raw === "number" + ? normalizeIndexCandidate(raw, storage.activeIndex) + : storage.activeIndex; + const familyIndex = count === 0 ? 0 : Math.max(0, Math.min(resolved, count - 1)); + if (!hasFamilyIndex && familyIndex === storage.activeIndex) { + continue; + } + storage.activeIndexByFamily[family] = familyIndex; } } /** - * Return the `accountId` and `email` from the first snapshot marked active. - * - * @param snapshots - Array of Codex CLI account snapshots to search - * @returns The `accountId` and `email` from the first snapshot whose `isActive` is true; properties are omitted if no active snapshot is found - * - * Concurrency: pure and side-effect free; safe to call concurrently. - * Filesystem: behavior is independent of OS/filesystem semantics (including Windows). - * Security: only `accountId` and `email` are returned; other sensitive snapshot fields (for example tokens) are not exposed or returned by this function. - */ -function readActiveFromSnapshots( - snapshots: CodexCliAccountSnapshot[], -): { accountId?: string; email?: string } { - const active = snapshots.find((snapshot) => snapshot.isActive); - return { - accountId: active?.accountId, - email: active?.email, - }; -} - -/** - * Determines whether the Codex CLI's active-account selection should override the local selection. + * Preserves one-way mirror semantics for Codex CLI compatibility state. * - * Considers the state's numeric `syncVersion` or `sourceUpdatedAtMs` and compares the derived Codex timestamp - * against local timestamps from recent account saves and last Codex selection writes. Concurrent writes or - * clock skew can affect this decision; filesystem timestamp granularity on Windows may reduce timestamp precision. - * This function only examines timestamps and identifiers in `state` and does not read or expose token values. + * Multi-auth storage is the canonical source of truth. Codex CLI account files are mirrors only + * and must never seed, merge into, or restore the canonical account pool. This helper is kept for + * older call sites that still use the historical reconcile entry point, but it now only normalizes + * the existing local indexes and never reads or applies Codex CLI account data. * - * @param state - Persisted Codex CLI state (may be undefined); the function reads `syncVersion` and `sourceUpdatedAtMs` when present - * @returns `true` if the Codex CLI selection should be applied (i.e., Codex state is newer or timestamps are unknown), `false` otherwise + * @param current - The current canonical AccountStorageV3, or null when no canonical storage exists. + * @returns The original storage when no local normalization is needed, a normalized clone when index + * values need clamping, or null when canonical storage is missing. */ -function shouldApplyCodexCliSelection(state: Awaited>): boolean { - if (!state) return false; - const hasSyncVersion = - typeof state.syncVersion === "number" && Number.isFinite(state.syncVersion); - const codexVersion = hasSyncVersion - ? (state.syncVersion as number) - : typeof state.sourceUpdatedAtMs === "number" && Number.isFinite(state.sourceUpdatedAtMs) - ? state.sourceUpdatedAtMs - : 0; - const localVersion = Math.max( - getLastAccountsSaveTimestamp(), - getLastCodexCliSelectionWriteTimestamp(), - ); - if (codexVersion <= 0 || localVersion <= 0) return true; - // Keep local selection when plugin wrote more recently than Codex state. - const toleranceMs = hasSyncVersion ? 0 : 1_000; - return codexVersion >= localVersion - toleranceMs; -} - -/** - * Reconciles the provided local account storage with the Codex CLI state and returns the resulting storage and whether it changed. - * - * This operation: - * - Merges accounts from the Codex CLI state into a clone of `current` (or into a new empty storage when `current` is null). - * - May update the active account selection and per-family active indexes when the Codex CLI selection is considered applicable. - * - Preserves secrets and sensitive fields; any tokens written to storage are subject to the project's token-redaction rules and are not exposed in logs or metrics. - * - * Concurrency assumptions: - * - Caller is responsible for serializing concurrent writes to persistent storage; this function only returns an in-memory storage object and does not perform atomic file-level coordination. - * - * Windows filesystem notes: - * - When the caller persists the returned storage to disk on Windows, standard Windows file-locking and path-length semantics apply; this function does not perform Windows-specific path normalization. - * - * @param current - The current local AccountStorageV3, or `null` to indicate none exists. - * @returns An object containing: - * - `storage`: the reconciled AccountStorageV3 to persist (may be the original `current` when no changes were applied). - * - `changed`: `true` if the reconciled storage differs from `current`, `false` otherwise. - */ -export async function syncAccountStorageFromCodexCli( +export function syncAccountStorageFromCodexCli( current: AccountStorageV3 | null, ): Promise<{ storage: AccountStorageV3 | null; changed: boolean }> { incrementCodexCliMetric("reconcileAttempts"); - try { - const state = await loadCodexCliState(); - if (!state) { - incrementCodexCliMetric("reconcileNoops"); - return { storage: current, changed: false }; - } - - const next = current ? cloneStorage(current) : createEmptyStorage(); - let changed = false; - for (const snapshot of state.accounts) { - const updated = upsertFromSnapshot(next.accounts, snapshot); - if (updated) changed = true; - } - - if (next.accounts.length === 0) { - incrementCodexCliMetric(changed ? "reconcileChanges" : "reconcileNoops"); - log.debug("Codex CLI reconcile completed", { - operation: "reconcile-storage", - outcome: changed ? "changed" : "noop", - accountCount: next.accounts.length, - }); - return { - storage: current ?? next, - changed, - }; - } - - const activeFromSnapshots = readActiveFromSnapshots(state.accounts); - const applyActiveFromCodex = shouldApplyCodexCliSelection(state); - if (applyActiveFromCodex) { - const desiredIndex = resolveActiveIndex( - next.accounts, - state.activeAccountId ?? activeFromSnapshots.accountId, - state.activeEmail ?? activeFromSnapshots.email, - ); - - const previousActive = next.activeIndex; - const previousFamilies = JSON.stringify(next.activeIndexByFamily ?? {}); - writeFamilyIndexes(next, desiredIndex); - normalizeStoredFamilyIndexes(next); - if (previousActive !== next.activeIndex) { - changed = true; - } - if (previousFamilies !== JSON.stringify(next.activeIndexByFamily ?? {})) { - changed = true; - } - } else { - const previousActive = next.activeIndex; - const previousFamilies = JSON.stringify(next.activeIndexByFamily ?? {}); - normalizeStoredFamilyIndexes(next); - if (previousActive !== next.activeIndex) { - changed = true; - } - if (previousFamilies !== JSON.stringify(next.activeIndexByFamily ?? {})) { - changed = true; - } - log.debug("Skipped Codex CLI active selection overwrite due to newer local state", { - operation: "reconcile-storage", - outcome: "local-newer", - }); - } - - incrementCodexCliMetric(changed ? "reconcileChanges" : "reconcileNoops"); - log.debug("Codex CLI reconcile completed", { + if (!current) { + incrementCodexCliMetric("reconcileNoops"); + log.debug("Skipped Codex CLI reconcile because canonical storage is missing", { operation: "reconcile-storage", - outcome: changed ? "changed" : "noop", - accountCount: next.accounts.length, - activeAccountRef: makeAccountFingerprint({ - accountId: state.activeAccountId ?? activeFromSnapshots.accountId, - email: state.activeEmail ?? activeFromSnapshots.email, - }), + outcome: "canonical-missing", }); - return { - storage: next, - changed, - }; - } catch (error) { - incrementCodexCliMetric("reconcileFailures"); - log.warn("Codex CLI reconcile failed", { - operation: "reconcile-storage", - outcome: "error", - error: String(error), - }); - return { storage: current, changed: false }; + return Promise.resolve({ storage: null, changed: false }); } + + const next = cloneStorage(current); + const previousActive = next.activeIndex; + const previousFamilies = JSON.stringify(next.activeIndexByFamily ?? {}); + normalizeStoredFamilyIndexes(next); + + const changed = + previousActive !== next.activeIndex || + previousFamilies !== JSON.stringify(next.activeIndexByFamily ?? {}); + + incrementCodexCliMetric(changed ? "reconcileChanges" : "reconcileNoops"); + log.debug("Skipped Codex CLI authority import; canonical storage remains authoritative", { + operation: "reconcile-storage", + outcome: changed ? "normalized-local-indexes" : "canonical-authoritative", + accountCount: next.accounts.length, + }); + + return Promise.resolve({ + storage: changed ? next : current, + changed, + }); } export function getActiveSelectionForFamily( @@ -368,6 +106,10 @@ export function getActiveSelectionForFamily( const count = storage.accounts.length; if (count === 0) return 0; const raw = storage.activeIndexByFamily?.[family]; - const candidate = typeof raw === "number" && Number.isFinite(raw) ? raw : storage.activeIndex; + const normalizedActiveIndex = normalizeIndexCandidate(storage.activeIndex, 0); + const candidate = + typeof raw === "number" + ? normalizeIndexCandidate(raw, normalizedActiveIndex) + : normalizedActiveIndex; return Math.max(0, Math.min(candidate, count - 1)); } diff --git a/test/codex-cli-sync.test.ts b/test/codex-cli-sync.test.ts index 11db5fa4..7d414a57 100644 --- a/test/codex-cli-sync.test.ts +++ b/test/codex-cli-sync.test.ts @@ -6,877 +6,413 @@ import type { AccountStorageV3 } from "../lib/storage.js"; import * as codexCliState from "../lib/codex-cli/state.js"; import { clearCodexCliStateCache } from "../lib/codex-cli/state.js"; import { - getActiveSelectionForFamily, - syncAccountStorageFromCodexCli, + getActiveSelectionForFamily, + syncAccountStorageFromCodexCli, } from "../lib/codex-cli/sync.js"; import { setCodexCliActiveSelection } from "../lib/codex-cli/writer.js"; import { MODEL_FAMILIES } from "../lib/prompts/codex.js"; -describe("codex-cli sync", () => { - let tempDir: string; - let accountsPath: string; - let authPath: string; - let configPath: string; - let previousPath: string | undefined; - let previousAuthPath: string | undefined; - let previousConfigPath: string | undefined; - let previousSync: string | undefined; - let previousEnforceFileStore: string | undefined; - - beforeEach(async () => { - previousPath = process.env.CODEX_CLI_ACCOUNTS_PATH; - previousAuthPath = process.env.CODEX_CLI_AUTH_PATH; - previousConfigPath = process.env.CODEX_CLI_CONFIG_PATH; - previousSync = process.env.CODEX_MULTI_AUTH_SYNC_CODEX_CLI; - previousEnforceFileStore = - process.env.CODEX_MULTI_AUTH_ENFORCE_CLI_FILE_AUTH_STORE; - tempDir = await mkdtemp(join(tmpdir(), "codex-multi-auth-sync-")); - accountsPath = join(tempDir, "accounts.json"); - authPath = join(tempDir, "auth.json"); - configPath = join(tempDir, "config.toml"); - process.env.CODEX_CLI_ACCOUNTS_PATH = accountsPath; - process.env.CODEX_CLI_AUTH_PATH = authPath; - process.env.CODEX_CLI_CONFIG_PATH = configPath; - process.env.CODEX_MULTI_AUTH_SYNC_CODEX_CLI = "1"; - process.env.CODEX_MULTI_AUTH_ENFORCE_CLI_FILE_AUTH_STORE = "1"; - clearCodexCliStateCache(); - }); - - afterEach(async () => { - clearCodexCliStateCache(); - if (previousPath === undefined) delete process.env.CODEX_CLI_ACCOUNTS_PATH; - else process.env.CODEX_CLI_ACCOUNTS_PATH = previousPath; - if (previousAuthPath === undefined) delete process.env.CODEX_CLI_AUTH_PATH; - else process.env.CODEX_CLI_AUTH_PATH = previousAuthPath; - if (previousConfigPath === undefined) delete process.env.CODEX_CLI_CONFIG_PATH; - else process.env.CODEX_CLI_CONFIG_PATH = previousConfigPath; - if (previousSync === undefined) - delete process.env.CODEX_MULTI_AUTH_SYNC_CODEX_CLI; - else process.env.CODEX_MULTI_AUTH_SYNC_CODEX_CLI = previousSync; - if (previousEnforceFileStore === undefined) { - delete process.env.CODEX_MULTI_AUTH_ENFORCE_CLI_FILE_AUTH_STORE; - } else { - process.env.CODEX_MULTI_AUTH_ENFORCE_CLI_FILE_AUTH_STORE = - previousEnforceFileStore; - } - await rm(tempDir, { recursive: true, force: true }); - }); - - it("merges Codex CLI accounts and sets active index", async () => { - await writeFile( - accountsPath, - JSON.stringify( - { - activeAccountId: "acc_c", - accounts: [ - { - accountId: "acc_b", - email: "b@example.com", - auth: { - tokens: { - access_token: "b.access.token", - refresh_token: "refresh-b", - }, - }, - }, - { - accountId: "acc_c", - email: "c@example.com", - auth: { - tokens: { - access_token: "c.access.token", - refresh_token: "refresh-c", - }, - }, - }, - ], - }, - null, - 2, - ), - "utf-8", - ); - - const current: AccountStorageV3 = { - version: 3, - accounts: [ - { - accountId: "acc_a", - email: "a@example.com", - refreshToken: "refresh-a", - addedAt: 1, - lastUsed: 1, - }, - { - accountId: "acc_b", - email: "b@example.com", - refreshToken: "refresh-b-old", - addedAt: 2, - lastUsed: 2, - }, - ], - activeIndex: 0, - activeIndexByFamily: { codex: 0 }, - }; - - const result = await syncAccountStorageFromCodexCli(current); - expect(result.changed).toBe(true); - expect(result.storage?.accounts.length).toBe(3); - - const mergedB = result.storage?.accounts.find( - (account) => account.accountId === "acc_b", - ); - expect(mergedB?.refreshToken).toBe("refresh-b"); - - const active = result.storage?.accounts[result.storage.activeIndex ?? 0]; - expect(active?.accountId).toBe("acc_c"); - }); - - it("creates storage from Codex CLI accounts when local storage is missing", async () => { - await writeFile( - accountsPath, - JSON.stringify( - { - accounts: [ - { - email: "a@example.com", - active: true, - auth: { - tokens: { - access_token: "a.access.token", - refresh_token: "refresh-a", - }, - }, - }, - { - email: "b@example.com", - auth: { - tokens: { - access_token: "b.access.token", - refresh_token: "refresh-b", - }, - }, - }, - ], - }, - null, - 2, - ), - "utf-8", - ); - - const result = await syncAccountStorageFromCodexCli(null); - expect(result.changed).toBe(true); - expect(result.storage?.accounts.length).toBe(2); - expect(result.storage?.accounts[0]?.refreshToken).toBe("refresh-a"); - expect(result.storage?.activeIndex).toBe(0); - }); - - it("matches existing account by normalized email", async () => { - await writeFile( - accountsPath, - JSON.stringify( - { - accounts: [ - { - email: "user@example.com", - auth: { - tokens: { - access_token: "new.access.token", - refresh_token: "refresh-new", - }, - }, - }, - ], - }, - null, - 2, - ), - "utf-8", - ); - - const current: AccountStorageV3 = { - version: 3, - accounts: [ - { - email: "USER@EXAMPLE.COM", - refreshToken: "refresh-old", - addedAt: 1, - lastUsed: 1, - }, - ], - activeIndex: 0, - activeIndexByFamily: { codex: 0 }, - }; - - const result = await syncAccountStorageFromCodexCli(current); - expect(result.changed).toBe(true); - expect(result.storage?.accounts.length).toBe(1); - expect(result.storage?.accounts[0]?.refreshToken).toBe("refresh-new"); - }); - - it("returns unchanged storage when sync is disabled", async () => { - process.env.CODEX_MULTI_AUTH_SYNC_CODEX_CLI = "0"; - clearCodexCliStateCache(); - - const current: AccountStorageV3 = { - version: 3, - accounts: [ - { - accountId: "acc_a", - email: "a@example.com", - refreshToken: "refresh-a", - addedAt: 1, - lastUsed: 1, - }, - ], - activeIndex: 0, - activeIndexByFamily: { codex: 0 }, - }; - - const result = await syncAccountStorageFromCodexCli(current); - expect(result.changed).toBe(false); - expect(result.storage).toBe(current); - }); - - it("keeps local active selection when local write is newer than codex snapshot", async () => { - await writeFile( - authPath, - JSON.stringify( - { - auth_mode: "chatgpt", - OPENAI_API_KEY: null, - tokens: { - access_token: "local.access.token", - refresh_token: "local-refresh-token", - account_id: "acc_a", - }, - }, - null, - 2, - ), - "utf-8", - ); - await setCodexCliActiveSelection({ - accountId: "acc_a", - accessToken: "local.access.token", - refreshToken: "local-refresh-token", - }); - - await writeFile( - accountsPath, - JSON.stringify( - { - codexMultiAuthSyncVersion: Date.now() - 120_000, - activeAccountId: "acc_b", - accounts: [ - { - accountId: "acc_a", - email: "a@example.com", - auth: { - tokens: { - access_token: "a.access", - refresh_token: "refresh-a", - }, - }, - }, - { - accountId: "acc_b", - email: "b@example.com", - auth: { - tokens: { - access_token: "b.access", - refresh_token: "refresh-b", - }, - }, - }, - ], - }, - null, - 2, - ), - "utf-8", - ); - clearCodexCliStateCache(); - - const current: AccountStorageV3 = { - version: 3, - accounts: [ - { - accountId: "acc_a", - email: "a@example.com", - refreshToken: "refresh-a", - addedAt: 1, - lastUsed: 1, - }, - { - accountId: "acc_b", - email: "b@example.com", - refreshToken: "refresh-b", - addedAt: 1, - lastUsed: 1, - }, - ], - activeIndex: 0, - activeIndexByFamily: { codex: 0 }, - }; - - const result = await syncAccountStorageFromCodexCli(current); - expect(result.storage?.activeIndex).toBe(0); - }); - - it("keeps local active selection when local state is newer by sub-second gap and syncVersion exists", async () => { - await writeFile( - authPath, - JSON.stringify( - { - auth_mode: "chatgpt", - OPENAI_API_KEY: null, - tokens: { - access_token: "local.access.token", - refresh_token: "local-refresh-token", - account_id: "acc_a", - }, - }, - null, - 2, - ), - "utf-8", - ); - await setCodexCliActiveSelection({ - accountId: "acc_a", - accessToken: "local.access.token", - refreshToken: "local-refresh-token", - }); - - const staleSyncVersion = Date.now() - 500; - await writeFile( - accountsPath, - JSON.stringify( - { - codexMultiAuthSyncVersion: staleSyncVersion, - activeAccountId: "acc_b", - accounts: [ - { - accountId: "acc_a", - email: "a@example.com", - auth: { - tokens: { - access_token: "a.access", - refresh_token: "refresh-a", - }, - }, - }, - { - accountId: "acc_b", - email: "b@example.com", - auth: { - tokens: { - access_token: "b.access", - refresh_token: "refresh-b", - }, - }, - }, - ], - }, - null, - 2, - ), - "utf-8", - ); - clearCodexCliStateCache(); - - const current: AccountStorageV3 = { - version: 3, - accounts: [ - { - accountId: "acc_a", - email: "a@example.com", - refreshToken: "refresh-a", - addedAt: 1, - lastUsed: 1, - }, - { - accountId: "acc_b", - email: "b@example.com", - refreshToken: "refresh-b", - addedAt: 1, - lastUsed: 1, - }, - ], - activeIndex: 0, - activeIndexByFamily: { codex: 0 }, - }; - - const result = await syncAccountStorageFromCodexCli(current); - expect(result.storage?.activeIndex).toBe(0); - }); - - it("marks changed when local index normalization mutates storage while codex selection is skipped", async () => { - await writeFile( - authPath, - JSON.stringify( - { - auth_mode: "chatgpt", - OPENAI_API_KEY: null, - tokens: { - access_token: "local.access.token", - refresh_token: "local-refresh-token", - account_id: "acc_a", - }, - }, - null, - 2, - ), - "utf-8", - ); - await setCodexCliActiveSelection({ - accountId: "acc_a", - accessToken: "local.access.token", - refreshToken: "local-refresh-token", - }); - - await writeFile( - accountsPath, - JSON.stringify( - { - codexMultiAuthSyncVersion: Date.now() - 120_000, - activeAccountId: "acc_b", - accounts: [ - { - accountId: "acc_a", - email: "a@example.com", - auth: { - tokens: { - access_token: "a.access", - refresh_token: "refresh-a", - }, - }, - }, - { - accountId: "acc_b", - email: "b@example.com", - auth: { - tokens: { - access_token: "b.access", - refresh_token: "refresh-b", - }, - }, - }, - ], - }, - null, - 2, - ), - "utf-8", - ); - clearCodexCliStateCache(); +const RETRYABLE_REMOVE_CODES = new Set(["EBUSY", "EPERM", "ENOTEMPTY", "EACCES", "ETIMEDOUT"]); + +async function removeWithRetry( + targetPath: string, + options: { recursive?: boolean; force?: boolean }, +): Promise { + for (let attempt = 0; attempt < 6; attempt += 1) { + try { + await rm(targetPath, options); + return; + } catch (error) { + const code = (error as NodeJS.ErrnoException).code; + if (code === "ENOENT") { + return; + } + if (!code || !RETRYABLE_REMOVE_CODES.has(code) || attempt === 5) { + throw error; + } + await new Promise((resolve) => setTimeout(resolve, 25 * 2 ** attempt)); + } + } +} - const current: AccountStorageV3 = { - version: 3, - accounts: [ - { - accountId: "acc_a", - email: "a@example.com", - refreshToken: "refresh-a", - addedAt: 1, - lastUsed: 1, - }, - { - accountId: "acc_b", - email: "b@example.com", - refreshToken: "refresh-b", - addedAt: 1, - lastUsed: 1, - }, - ], - activeIndex: 99, - activeIndexByFamily: { codex: 99 }, - }; - - const result = await syncAccountStorageFromCodexCli(current); - expect(result.changed).toBe(true); - expect(result.storage?.activeIndex).toBe(1); - expect(result.storage?.activeIndexByFamily?.codex).toBe(1); - }); - - it("serializes concurrent active-selection writes to keep accounts/auth aligned", async () => { - await writeFile( - accountsPath, - JSON.stringify( - { - accounts: [ - { - accountId: "acc_a", - email: "a@example.com", - auth: { - tokens: { - access_token: "access-a", - id_token: "id-a", - refresh_token: "refresh-a", - }, - }, - }, - { - accountId: "acc_b", - email: "b@example.com", - auth: { - tokens: { - access_token: "access-b", - id_token: "id-b", - refresh_token: "refresh-b", - }, - }, - }, - ], - }, - null, - 2, - ), - "utf-8", - ); - await writeFile( - authPath, - JSON.stringify( - { - auth_mode: "chatgpt", - OPENAI_API_KEY: null, - email: "a@example.com", - tokens: { - access_token: "access-a", - id_token: "id-a", - refresh_token: "refresh-a", - account_id: "acc_a", - }, - }, - null, - 2, - ), - "utf-8", - ); - - const [first, second] = await Promise.all([ - setCodexCliActiveSelection({ accountId: "acc_a" }), - setCodexCliActiveSelection({ accountId: "acc_b" }), - ]); - expect(first).toBe(true); - expect(second).toBe(true); - - const writtenAccounts = JSON.parse( - await readFile(accountsPath, "utf-8"), - ) as { - activeAccountId?: string; - activeEmail?: string; - accounts?: Array<{ accountId?: string; active?: boolean }>; - }; - const writtenAuth = JSON.parse(await readFile(authPath, "utf-8")) as { - email?: string; - tokens?: { account_id?: string }; - }; - - expect(writtenAccounts.activeAccountId).toBe("acc_b"); - expect(writtenAccounts.activeEmail).toBe("b@example.com"); - expect(writtenAccounts.accounts?.[0]?.active).toBe(false); - expect(writtenAccounts.accounts?.[1]?.active).toBe(true); - expect(writtenAuth.tokens?.account_id).toBe("acc_b"); - expect(writtenAuth.email).toBe("b@example.com"); - }); - it("ignores Codex snapshots that do not include refresh tokens", async () => { - await writeFile( - accountsPath, - JSON.stringify( - { - accounts: [ - { - accountId: "acc_a", - email: "a@example.com", - access_token: "access-only", - }, - ], - }, - null, - 2, - ), - "utf-8", - ); - - const result = await syncAccountStorageFromCodexCli(null); - expect(result.changed).toBe(false); - expect(result.storage?.accounts).toHaveLength(0); - expect(result.storage?.activeIndex).toBe(0); - }); - - it("matches existing account by refresh token when accountId is absent", async () => { - await writeFile( - accountsPath, - JSON.stringify( - { - accounts: [ - { - email: "updated@example.com", - auth: { - tokens: { - access_token: "new-access", - refresh_token: "refresh-a", - }, - }, - }, - ], - }, - null, - 2, - ), - "utf-8", - ); - - const current: AccountStorageV3 = { - version: 3, - accounts: [ - { - accountId: "acc_a", - accountIdSource: "token", - email: "a@example.com", - refreshToken: "refresh-a", - accessToken: "old-access", - enabled: true, - addedAt: 1, - lastUsed: 1, - }, - ], - activeIndex: 0, - activeIndexByFamily: { codex: 0 }, - }; - - const result = await syncAccountStorageFromCodexCli(current); - expect(result.changed).toBe(true); - expect(result.storage?.accounts[0]?.accessToken).toBe("new-access"); - expect(result.storage?.accounts[0]?.email).toBe("updated@example.com"); - }); - - it("returns unchanged when Codex state and local selection are already aligned", async () => { - await writeFile( - accountsPath, - JSON.stringify( - { - activeAccountId: "acc_a", - accounts: [ - { - accountId: "acc_a", - email: "a@example.com", - auth: { - tokens: { - access_token: "access-a", - refresh_token: "refresh-a", - }, - }, - }, - ], - }, - null, - 2, - ), - "utf-8", - ); - - const familyIndexes = Object.fromEntries( - MODEL_FAMILIES.map((family) => [family, 0]), - ); - const current: AccountStorageV3 = { - version: 3, - accounts: [ - { - accountId: "acc_a", - accountIdSource: "token", - email: "a@example.com", - refreshToken: "refresh-a", - accessToken: "access-a", - enabled: true, - addedAt: 1, - lastUsed: 1, - }, - ], - activeIndex: 0, - activeIndexByFamily: familyIndexes, - }; - - const result = await syncAccountStorageFromCodexCli(current); - expect(result.changed).toBe(false); - expect(result.storage).toEqual(current); - }); - - it("returns current storage when state loading throws", async () => { - const current: AccountStorageV3 = { - version: 3, - accounts: [ - { - accountId: "acc_a", - email: "a@example.com", - refreshToken: "refresh-a", - addedAt: 1, - lastUsed: 1, - }, - ], - activeIndex: 0, - activeIndexByFamily: { codex: 0 }, - }; - - const loadSpy = vi - .spyOn(codexCliState, "loadCodexCliState") - .mockRejectedValue(new Error("forced load failure")); - - try { - const result = await syncAccountStorageFromCodexCli(current); - expect(result.changed).toBe(false); - expect(result.storage).toBe(current); - } finally { - loadSpy.mockRestore(); - } - }); - - it("applies active selection using normalized email when accountId is absent", async () => { - const current: AccountStorageV3 = { - version: 3, - accounts: [ - { - accountId: "acc_a", - email: "a@example.com", - refreshToken: "refresh-a", - addedAt: 1, - lastUsed: 1, - }, - { - accountId: "acc_b", - email: "b@example.com", - refreshToken: "refresh-b", - addedAt: 1, - lastUsed: 1, - }, - ], - activeIndex: 0, - activeIndexByFamily: { codex: 0 }, - }; - - const loadSpy = vi - .spyOn(codexCliState, "loadCodexCliState") - .mockResolvedValue({ - path: "mock", - accounts: [ - { - accountId: "acc_a", - email: "a@example.com", - accessToken: "a.access.token", - refreshToken: "refresh-a", - }, - { - accountId: "acc_b", - email: "b@example.com", - accessToken: "b.access.token", - refreshToken: "refresh-b", - }, - ], - activeEmail: " B@EXAMPLE.COM ", - }); - - try { - const result = await syncAccountStorageFromCodexCli(current); - expect(result.storage?.activeIndex).toBe(1); - } finally { - loadSpy.mockRestore(); - } - }); - - it("initializes family indexes when local storage omits activeIndexByFamily", async () => { - const current: AccountStorageV3 = { - version: 3, - accounts: [ - { - accountId: "acc_a", - email: "a@example.com", - refreshToken: "refresh-a", - addedAt: 1, - lastUsed: 1, - }, - { - accountId: "acc_b", - email: "b@example.com", - refreshToken: "refresh-b", - addedAt: 1, - lastUsed: 1, - }, - ], - activeIndex: 1, - }; - - const loadSpy = vi - .spyOn(codexCliState, "loadCodexCliState") - .mockResolvedValue({ - path: "mock", - accounts: [ - { - accountId: "acc_a", - email: "a@example.com", - accessToken: "a.access.token", - refreshToken: "refresh-a", - }, - ], - activeAccountId: "acc_a", - syncVersion: undefined, - sourceUpdatedAtMs: undefined, - }); - - try { - const result = await syncAccountStorageFromCodexCli(current); - expect(result.storage?.activeIndex).toBe(0); - for (const family of MODEL_FAMILIES) { - expect(result.storage?.activeIndexByFamily?.[family]).toBe(0); - } - } finally { - loadSpy.mockRestore(); - } - }); - - it("clamps and defaults active selection indexes by model family", () => { - const family = MODEL_FAMILIES[0]; - expect( - getActiveSelectionForFamily( - { - version: 3, - accounts: [], - activeIndex: 99, - activeIndexByFamily: {}, - }, - family, - ), - ).toBe(0); - - expect( - getActiveSelectionForFamily( - { - version: 3, - accounts: [ - { refreshToken: "a", addedAt: 1, lastUsed: 1 }, - { refreshToken: "b", addedAt: 1, lastUsed: 1 }, - ], - activeIndex: 1, - activeIndexByFamily: { [family]: Number.NaN }, - }, - family, - ), - ).toBe(1); - - expect( - getActiveSelectionForFamily( - { - version: 3, - accounts: [ - { refreshToken: "a", addedAt: 1, lastUsed: 1 }, - { refreshToken: "b", addedAt: 1, lastUsed: 1 }, - ], - activeIndex: 1, - activeIndexByFamily: { [family]: -3 }, - }, - family, - ), - ).toBe(0); - }); +describe("codex-cli sync", () => { + let tempDir: string; + let accountsPath: string; + let authPath: string; + let configPath: string; + let previousPath: string | undefined; + let previousAuthPath: string | undefined; + let previousConfigPath: string | undefined; + let previousSync: string | undefined; + let previousEnforceFileStore: string | undefined; + + beforeEach(async () => { + previousPath = process.env.CODEX_CLI_ACCOUNTS_PATH; + previousAuthPath = process.env.CODEX_CLI_AUTH_PATH; + previousConfigPath = process.env.CODEX_CLI_CONFIG_PATH; + previousSync = process.env.CODEX_MULTI_AUTH_SYNC_CODEX_CLI; + previousEnforceFileStore = + process.env.CODEX_MULTI_AUTH_ENFORCE_CLI_FILE_AUTH_STORE; + tempDir = await mkdtemp(join(tmpdir(), "codex-multi-auth-sync-")); + accountsPath = join(tempDir, "accounts.json"); + authPath = join(tempDir, "auth.json"); + configPath = join(tempDir, "config.toml"); + process.env.CODEX_CLI_ACCOUNTS_PATH = accountsPath; + process.env.CODEX_CLI_AUTH_PATH = authPath; + process.env.CODEX_CLI_CONFIG_PATH = configPath; + process.env.CODEX_MULTI_AUTH_SYNC_CODEX_CLI = "1"; + process.env.CODEX_MULTI_AUTH_ENFORCE_CLI_FILE_AUTH_STORE = "1"; + clearCodexCliStateCache(); + }); + + afterEach(async () => { + clearCodexCliStateCache(); + if (previousPath === undefined) delete process.env.CODEX_CLI_ACCOUNTS_PATH; + else process.env.CODEX_CLI_ACCOUNTS_PATH = previousPath; + if (previousAuthPath === undefined) delete process.env.CODEX_CLI_AUTH_PATH; + else process.env.CODEX_CLI_AUTH_PATH = previousAuthPath; + if (previousConfigPath === undefined) delete process.env.CODEX_CLI_CONFIG_PATH; + else process.env.CODEX_CLI_CONFIG_PATH = previousConfigPath; + if (previousSync === undefined) { + delete process.env.CODEX_MULTI_AUTH_SYNC_CODEX_CLI; + } else { + process.env.CODEX_MULTI_AUTH_SYNC_CODEX_CLI = previousSync; + } + if (previousEnforceFileStore === undefined) { + delete process.env.CODEX_MULTI_AUTH_ENFORCE_CLI_FILE_AUTH_STORE; + } else { + process.env.CODEX_MULTI_AUTH_ENFORCE_CLI_FILE_AUTH_STORE = + previousEnforceFileStore; + } + await removeWithRetry(tempDir, { recursive: true, force: true }); + }); + + it("does not seed canonical storage from Codex CLI mirror files", async () => { + await writeFile( + accountsPath, + JSON.stringify( + { + activeAccountId: "acc_mirror", + accounts: [ + { + accountId: "acc_mirror", + email: "mirror@example.com", + auth: { + tokens: { + access_token: "mirror-access", + refresh_token: "mirror-refresh", + }, + }, + }, + ], + }, + null, + 2, + ), + "utf-8", + ); + + const loadSpy = vi.spyOn(codexCliState, "loadCodexCliState"); + try { + const result = await syncAccountStorageFromCodexCli(null); + expect(result.changed).toBe(false); + expect(result.storage).toBeNull(); + expect(loadSpy).not.toHaveBeenCalled(); + } finally { + loadSpy.mockRestore(); + } + }); + + it("does not merge or overwrite canonical storage from Codex CLI mirrors", async () => { + await writeFile( + accountsPath, + JSON.stringify( + { + activeAccountId: "acc_b", + accounts: [ + { + accountId: "acc_b", + email: "b@example.com", + auth: { + tokens: { + access_token: "b.access.token", + refresh_token: "refresh-b", + }, + }, + }, + ], + }, + null, + 2, + ), + "utf-8", + ); + + const current: AccountStorageV3 = { + version: 3, + accounts: [ + { + accountId: "acc_a", + email: "a@example.com", + refreshToken: "refresh-a", + addedAt: 1, + lastUsed: 1, + }, + ], + activeIndex: 0, + activeIndexByFamily: Object.fromEntries( + MODEL_FAMILIES.map((family) => [family, 0]), + ), + }; + + const loadSpy = vi.spyOn(codexCliState, "loadCodexCliState"); + try { + const result = await syncAccountStorageFromCodexCli(current); + expect(result.changed).toBe(false); + expect(result.storage).toBe(current); + expect(result.storage?.accounts).toEqual(current.accounts); + expect(loadSpy).not.toHaveBeenCalled(); + } finally { + loadSpy.mockRestore(); + } + }); + + it("normalizes local indexes without reading Codex CLI mirror state", async () => { + const current: AccountStorageV3 = { + version: 3, + accounts: [ + { + accountId: "acc_a", + email: "a@example.com", + refreshToken: "refresh-a", + addedAt: 1, + lastUsed: 1, + }, + { + accountId: "acc_b", + email: "b@example.com", + refreshToken: "refresh-b", + addedAt: 1, + lastUsed: 1, + }, + ], + activeIndex: 99, + activeIndexByFamily: { codex: 99 }, + }; + + const loadSpy = vi.spyOn(codexCliState, "loadCodexCliState"); + try { + const result = await syncAccountStorageFromCodexCli(current); + expect(result.changed).toBe(true); + expect(result.storage).not.toBe(current); + expect(result.storage?.activeIndex).toBe(1); + expect(result.storage?.activeIndexByFamily?.codex).toBe(1); + for (const family of MODEL_FAMILIES.filter((candidate) => candidate !== "codex")) { + expect(result.storage?.activeIndexByFamily?.[family]).toBeUndefined(); + } + expect(loadSpy).not.toHaveBeenCalled(); + } finally { + loadSpy.mockRestore(); + } + }); + + it("clamps NaN activeIndex to 0 and reports changed", async () => { + const current: AccountStorageV3 = { + version: 3, + accounts: [ + { + accountId: "acc_a", + email: "a@example.com", + refreshToken: "refresh-a", + addedAt: 1, + lastUsed: 1, + }, + ], + activeIndex: Number.NaN, + activeIndexByFamily: {}, + }; + + const result = await syncAccountStorageFromCodexCli(current); + expect(result.changed).toBe(true); + expect(result.storage?.activeIndex).toBe(0); + }); + + it("serializes concurrent active-selection writes to keep accounts/auth aligned", async () => { + await writeFile( + accountsPath, + JSON.stringify( + { + accounts: [ + { + accountId: "acc_a", + email: "a@example.com", + auth: { + tokens: { + access_token: "access-a", + id_token: "id-a", + refresh_token: "refresh-a", + }, + }, + }, + { + accountId: "acc_b", + email: "b@example.com", + auth: { + tokens: { + access_token: "access-b", + id_token: "id-b", + refresh_token: "refresh-b", + }, + }, + }, + ], + }, + null, + 2, + ), + "utf-8", + ); + await writeFile( + authPath, + JSON.stringify( + { + auth_mode: "chatgpt", + OPENAI_API_KEY: null, + email: "a@example.com", + tokens: { + access_token: "access-a", + id_token: "id-a", + refresh_token: "refresh-a", + account_id: "acc_a", + }, + }, + null, + 2, + ), + "utf-8", + ); + + const [first, second] = await Promise.all([ + setCodexCliActiveSelection({ accountId: "acc_a" }), + setCodexCliActiveSelection({ accountId: "acc_b" }), + ]); + expect(first).toBe(true); + expect(second).toBe(true); + + const writtenAccounts = JSON.parse( + await readFile(accountsPath, "utf-8"), + ) as { + activeAccountId?: string; + activeEmail?: string; + accounts?: Array<{ accountId?: string; active?: boolean }>; + }; + const writtenAuth = JSON.parse(await readFile(authPath, "utf-8")) as { + email?: string; + tokens?: { account_id?: string }; + }; + + expect(writtenAccounts.activeAccountId).toBe("acc_b"); + expect(writtenAccounts.activeEmail).toBe("b@example.com"); + expect(writtenAccounts.accounts?.[0]?.active).toBe(false); + expect(writtenAccounts.accounts?.[1]?.active).toBe(true); + expect(writtenAuth.tokens?.account_id).toBe("acc_b"); + expect(writtenAuth.email).toBe("b@example.com"); + }); + + it("clamps and defaults active selection indexes by model family", () => { + const family = MODEL_FAMILIES[0]; + expect( + getActiveSelectionForFamily( + { + version: 3, + accounts: [], + activeIndex: 99, + activeIndexByFamily: {}, + }, + family, + ), + ).toBe(0); + + expect( + getActiveSelectionForFamily( + { + version: 3, + accounts: [ + { refreshToken: "a", addedAt: 1, lastUsed: 1 }, + { refreshToken: "b", addedAt: 1, lastUsed: 1 }, + ], + activeIndex: 1, + activeIndexByFamily: { [family]: Number.NaN }, + }, + family, + ), + ).toBe(1); + + expect( + getActiveSelectionForFamily( + { + version: 3, + accounts: [ + { refreshToken: "a", addedAt: 1, lastUsed: 1 }, + { refreshToken: "b", addedAt: 1, lastUsed: 1 }, + ], + activeIndex: 1, + activeIndexByFamily: { [family]: -3 }, + }, + family, + ), + ).toBe(0); + + expect( + getActiveSelectionForFamily( + { + version: 3, + accounts: [ + { refreshToken: "a", addedAt: 1, lastUsed: 1 }, + { refreshToken: "b", addedAt: 1, lastUsed: 1 }, + ], + activeIndex: 1.9, + activeIndexByFamily: { [family]: 1.9 }, + }, + family, + ), + ).toBe(1); + + expect( + getActiveSelectionForFamily( + { + version: 3, + accounts: [ + { refreshToken: "a", addedAt: 1, lastUsed: 1 }, + { refreshToken: "b", addedAt: 1, lastUsed: 1 }, + { refreshToken: "c", addedAt: 1, lastUsed: 1 }, + ], + activeIndex: 1.9, + activeIndexByFamily: { [family]: Number.NaN }, + }, + family, + ), + ).toBe(1); + }); + + it("does not report changes when missing family indexes already resolve to the active index", async () => { + const current: AccountStorageV3 = { + version: 3, + accounts: [ + { + accountId: "acc_a", + email: "a@example.com", + refreshToken: "refresh-a", + addedAt: 1, + lastUsed: 1, + }, + ], + activeIndex: 0, + activeIndexByFamily: {}, + }; + + const result = await syncAccountStorageFromCodexCli(current); + expect(result.changed).toBe(false); + expect(result.storage).toBe(current); + }); });