From 4978f297027345ac0137e846186ff2e25f701ed4 Mon Sep 17 00:00:00 2001 From: ndycode Date: Tue, 10 Mar 2026 19:19:32 +0800 Subject: [PATCH 01/15] feat: add codex multi-auth sync flow Add the sync engine, auth-menu wiring, capacity-aware prune flow, and integration tests on top of the storage/config foundation. Co-authored-by: Codex --- index.ts | 437 ++++++- lib/cli.ts | 139 ++- lib/codex-multi-auth-sync.ts | 1236 +++++++++++++++++++ lib/ui/auth-menu.ts | 32 + test/codex-multi-auth-sync.test.ts | 1798 ++++++++++++++++++++++++++++ test/index.test.ts | 3 + 6 files changed, 3640 insertions(+), 5 deletions(-) create mode 100644 lib/codex-multi-auth-sync.ts create mode 100644 test/codex-multi-auth-sync.test.ts diff --git a/index.ts b/index.ts index 5ff94c0c..74ceeb37 100644 --- a/index.ts +++ b/index.ts @@ -24,6 +24,8 @@ */ import { tool } from "@opencode-ai/plugin/tool"; +import { promises as fsPromises } from "node:fs"; +import { dirname } from "node:path"; import type { Plugin, PluginInput } from "@opencode-ai/plugin"; import type { Auth } from "@opencode-ai/sdk"; import { @@ -35,7 +37,7 @@ import { import { queuedRefresh, getRefreshQueueMetrics } from "./lib/refresh-queue.js"; import { openBrowserUrl } from "./lib/auth/browser.js"; import { startLocalOAuthServer } from "./lib/auth/server.js"; -import { promptAddAnotherAccount, promptLoginMode } from "./lib/cli.js"; +import { promptAddAnotherAccount, promptCodexMultiAuthSyncPrune, promptLoginMode } from "./lib/cli.js"; import { getCodexMode, getRequestTransformMode, @@ -65,7 +67,9 @@ import { getCodexTuiColorProfile, getCodexTuiGlyphMode, getBeginnerSafeMode, + getSyncFromCodexMultiAuthEnabled, loadPluginConfig, + setSyncFromCodexMultiAuthEnabled, } from "./lib/config.js"; import { AUTH_LABELS, @@ -115,11 +119,14 @@ import { importAccounts, previewImportAccounts, createTimestampedBackupPath, + loadAccountAndFlaggedStorageSnapshot, loadFlaggedAccounts, + normalizeAccountStorage, saveFlaggedAccounts, clearFlaggedAccounts, StorageError, formatStorageErrorHint, + withFlaggedAccountsTransaction, type AccountStorageV3, type FlaggedAccountMetadataV1, } from "./lib/storage.js"; @@ -151,6 +158,7 @@ import { import { addJitter } from "./lib/rotation.js"; import { buildTableHeader, buildTableRow, type TableOptions } from "./lib/table-formatter.js"; import { setUiRuntimeOptions, type UiRuntimeOptions } from "./lib/ui/runtime.js"; +import { confirm } from "./lib/ui/confirm.js"; import { paintUiText, formatUiBadge, formatUiHeader, formatUiItem, formatUiKeyValue, formatUiSection } from "./lib/ui/format.js"; import { buildBeginnerChecklist, @@ -182,6 +190,16 @@ import { detectErrorType, getRecoveryToastContent, } from "./lib/recovery.js"; +import { + CodexMultiAuthSyncCapacityError, + cleanupCodexMultiAuthSyncedOverlaps, + isCodexMultiAuthSourceTooLargeForCapacity, + loadCodexMultiAuthSourceStorage, + previewCodexMultiAuthSyncedOverlapCleanup, + previewSyncFromCodexMultiAuth, + syncFromCodexMultiAuth, +} from "./lib/codex-multi-auth-sync.js"; +import { createSyncPruneBackupPayload } from "./lib/sync-prune-backup.js"; /** * OpenAI Codex OAuth authentication plugin for opencode @@ -3337,6 +3355,410 @@ while (attempted.size < Math.max(1, accountCount)) { console.log(""); }; + type SyncRemovalTarget = { + refreshToken: string; + organizationId?: string; + accountId?: string; + }; + type SyncRemovalSuggestion = SyncRemovalTarget & { + index: number; + email?: string; + accountLabel?: string; + isCurrentAccount: boolean; + score: number; + reason: string; + }; + + const getSyncRemovalTargetKey = (target: SyncRemovalTarget): string => { + return `${target.organizationId ?? ""}|${target.accountId ?? ""}|${target.refreshToken}`; + }; + + const findAccountIndexByExactIdentity = ( + accounts: AccountStorageV3["accounts"], + target: SyncRemovalTarget | null | undefined, + ): number => { + if (!target || !target.refreshToken) return -1; + const targetKey = getSyncRemovalTargetKey(target); + return accounts.findIndex((account) => + getSyncRemovalTargetKey({ + refreshToken: account.refreshToken, + organizationId: account.organizationId, + accountId: account.accountId, + }) === targetKey, + ); + }; + + const toggleCodexMultiAuthSyncSetting = async (): Promise => { + try { + const currentConfig = loadPluginConfig(); + const enabled = getSyncFromCodexMultiAuthEnabled(currentConfig); + await setSyncFromCodexMultiAuthEnabled(!enabled); + console.log(`\nSync from codex-multi-auth ${!enabled ? "enabled" : "disabled"}.\n`); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + console.log(`\nFailed to update sync setting: ${message}\n`); + } + }; + + const createSyncPruneBackup = async (): Promise<{ + backupPath: string; + restore: () => Promise; + }> => { + const { accounts: loadedAccountsStorage, flagged: currentFlaggedStorage } = + await loadAccountAndFlaggedStorageSnapshot(); + const currentAccountsStorage = + loadedAccountsStorage ?? + ({ + version: 3, + accounts: [], + activeIndex: 0, + activeIndexByFamily: {}, + } satisfies AccountStorageV3); + const backupPath = createTimestampedBackupPath("codex-sync-prune-backup"); + await fsPromises.mkdir(dirname(backupPath), { recursive: true }); + const backupPayload = createSyncPruneBackupPayload( + currentAccountsStorage, + currentFlaggedStorage, + ); + const restoreAccountsSnapshot = structuredClone(currentAccountsStorage); + const restoreFlaggedSnapshot = structuredClone(currentFlaggedStorage); + const tempBackupPath = `${backupPath}.${Date.now()}.tmp`; + try { + await fsPromises.writeFile(tempBackupPath, `${JSON.stringify(backupPayload, null, 2)}\n`, { + encoding: "utf-8", + mode: 0o600, + }); + await fsPromises.rename(tempBackupPath, backupPath); + } catch (error) { + try { + await fsPromises.unlink(tempBackupPath); + } catch { + // best-effort cleanup + } + throw error; + } + return { + backupPath, + restore: async () => { + const normalizedAccounts = normalizeAccountStorage(restoreAccountsSnapshot); + if (!normalizedAccounts) { + throw new Error("Prune backup account snapshot failed validation."); + } + await withAccountStorageTransaction(async (_current, persist) => { + await persist(normalizedAccounts); + }); + await saveFlaggedAccounts( + restoreFlaggedSnapshot as { version: 1; accounts: FlaggedAccountMetadataV1[] }, + ); + invalidateAccountManagerCache(); + }, + }; + }; + + const removeAccountsForSync = async (targets: SyncRemovalTarget[]): Promise => { + const targetKeySet = new Set( + targets + .filter((target) => target.refreshToken.length > 0) + .map((target) => getSyncRemovalTargetKey(target)), + ); + let removedTargets: Array<{ + index: number; + account: AccountStorageV3["accounts"][number]; + }> = []; + await withAccountStorageTransaction(async (loadedStorage, persist) => { + const currentStorage = + loadedStorage ?? + ({ + version: 3, + accounts: [], + activeIndex: 0, + activeIndexByFamily: {}, + } satisfies AccountStorageV3); + removedTargets = currentStorage.accounts + .map((account, index) => ({ index, account })) + .filter((entry) => + targetKeySet.has( + getSyncRemovalTargetKey({ + refreshToken: entry.account.refreshToken, + organizationId: entry.account.organizationId, + accountId: entry.account.accountId, + }), + ), + ); + if (removedTargets.length === 0) { + return; + } + + const activeAccountIdentity = { + refreshToken: + currentStorage.accounts[currentStorage.activeIndex]?.refreshToken ?? "", + organizationId: + currentStorage.accounts[currentStorage.activeIndex]?.organizationId, + accountId: currentStorage.accounts[currentStorage.activeIndex]?.accountId, + } satisfies SyncRemovalTarget; + const familyActiveIdentities = Object.fromEntries( + MODEL_FAMILIES.map((family) => { + const familyIndex = currentStorage.activeIndexByFamily?.[family] ?? currentStorage.activeIndex; + const familyAccount = currentStorage.accounts[familyIndex]; + return [ + family, + familyAccount + ? ({ + refreshToken: familyAccount.refreshToken, + organizationId: familyAccount.organizationId, + accountId: familyAccount.accountId, + } satisfies SyncRemovalTarget) + : null, + ]; + }), + ) as Partial>; + + currentStorage.accounts = currentStorage.accounts.filter( + (account) => + !targetKeySet.has( + getSyncRemovalTargetKey({ + refreshToken: account.refreshToken, + organizationId: account.organizationId, + accountId: account.accountId, + }), + ), + ); + const remappedActiveIndex = findAccountIndexByExactIdentity( + currentStorage.accounts, + activeAccountIdentity, + ); + currentStorage.activeIndex = + remappedActiveIndex >= 0 + ? remappedActiveIndex + : Math.min(currentStorage.activeIndex, Math.max(0, currentStorage.accounts.length - 1)); + currentStorage.activeIndexByFamily = currentStorage.activeIndexByFamily ?? {}; + for (const family of MODEL_FAMILIES) { + const remappedFamilyIndex = findAccountIndexByExactIdentity( + currentStorage.accounts, + familyActiveIdentities[family] ?? null, + ); + currentStorage.activeIndexByFamily[family] = + remappedFamilyIndex >= 0 ? remappedFamilyIndex : currentStorage.activeIndex; + } + clampActiveIndices(currentStorage); + await persist(currentStorage); + }); + + if (removedTargets.length > 0) { + const removedFlaggedKeys = new Set( + removedTargets.map((entry) => + getSyncRemovalTargetKey({ + refreshToken: entry.account.refreshToken, + organizationId: entry.account.organizationId, + accountId: entry.account.accountId, + }), + ), + ); + await withFlaggedAccountsTransaction(async (currentFlaggedStorage, persist) => { + await persist({ + version: 1, + accounts: currentFlaggedStorage.accounts.filter( + (flagged) => + !removedFlaggedKeys.has( + getSyncRemovalTargetKey({ + refreshToken: flagged.refreshToken, + organizationId: flagged.organizationId, + accountId: flagged.accountId, + }), + ), + ), + }); + }); + invalidateAccountManagerCache(); + } + }; + + const buildSyncRemovalPlan = ( + indexes: number[], + suggestions: SyncRemovalSuggestion[], + ): { + previewLines: string[]; + targets: SyncRemovalTarget[]; + } => { + const byIndex = new Map(suggestions.map((suggestion) => [suggestion.index, suggestion])); + const candidates = [...indexes] + .sort((left, right) => left - right) + .map((index) => { + const suggestion = byIndex.get(index); + if (!suggestion) { + throw new Error( + `Selected account ${index + 1} changed before confirmation. Re-run sync and confirm again.`, + ); + } + const label = suggestion.email ?? suggestion.accountLabel ?? `Account ${index + 1}`; + const currentSuffix = suggestion.isCurrentAccount ? " | current" : ""; + return { + previewLine: `${index + 1}. ${label}${currentSuffix}`, + target: { + refreshToken: suggestion.refreshToken, + organizationId: suggestion.organizationId, + accountId: suggestion.accountId, + } satisfies SyncRemovalTarget, + }; + }); + return { + previewLines: candidates.map((candidate) => candidate.previewLine), + targets: candidates.map((candidate) => candidate.target), + }; + }; + + const runCodexMultiAuthSync = async (): Promise => { + const currentConfig = loadPluginConfig(); + if (!getSyncFromCodexMultiAuthEnabled(currentConfig)) { + console.log("\nEnable sync from codex-multi-auth in Sync tools first.\n"); + return; + } + + let pruneBackup: { backupPath: string; restore: () => Promise } | null = null; + const restorePruneBackup = async (): Promise => { + const currentBackup = pruneBackup; + if (!currentBackup) return; + await currentBackup.restore(); + pruneBackup = null; + }; + + while (true) { + try { + const loadedSource = await loadCodexMultiAuthSourceStorage(process.cwd()); + const preview = await previewSyncFromCodexMultiAuth(process.cwd(), loadedSource); + console.log(""); + console.log(`codex-multi-auth source: ${preview.accountsPath}`); + console.log(`Scope: ${preview.scope}`); + console.log(`Preview: +${preview.imported} new, ${preview.skipped} skipped, ${preview.total} total`); + + if (preview.imported <= 0) { + await restorePruneBackup(); + console.log("No new accounts to import.\n"); + return; + } + + if (!(await confirm(`Import ${preview.imported} new account(s) from codex-multi-auth?`))) { + await restorePruneBackup(); + console.log("\nSync cancelled.\n"); + return; + } + + const result = await syncFromCodexMultiAuth(process.cwd(), loadedSource); + pruneBackup = null; + invalidateAccountManagerCache(); + const backupLabel = + result.backupStatus === "created" + ? result.backupPath ?? "created" + : result.backupStatus === "skipped" + ? "skipped" + : result.backupError ?? "failed"; + console.log(""); + console.log("Sync complete."); + console.log(`Source: ${result.accountsPath}`); + console.log(`Imported: ${result.imported}`); + console.log(`Skipped: ${result.skipped}`); + console.log(`Total: ${result.total}`); + console.log(`Auto-backup: ${backupLabel}`); + console.log(""); + return; + } catch (error) { + if (error instanceof CodexMultiAuthSyncCapacityError) { + const { details } = error; + console.log(""); + console.log("Sync blocked by account limit."); + console.log(`Source: ${details.accountsPath}`); + console.log(`Scope: ${details.scope}`); + console.log(`Current accounts: ${details.currentCount}`); + console.log(`Importable new accounts: ${details.importableNewAccounts}`); + console.log(`Maximum allowed: ${details.maxAccounts}`); + if (isCodexMultiAuthSourceTooLargeForCapacity(details)) { + await restorePruneBackup(); + console.log("Source alone exceeds the configured maximum.\n"); + return; + } + console.log(`Remove at least ${details.needToRemove} account(s) first.`); + const indexesToRemove = await promptCodexMultiAuthSyncPrune( + details.needToRemove, + details.suggestedRemovals, + ); + if (!indexesToRemove || indexesToRemove.length === 0) { + await restorePruneBackup(); + console.log("Sync cancelled.\n"); + return; + } + const removalPlan = buildSyncRemovalPlan( + indexesToRemove, + details.suggestedRemovals as SyncRemovalSuggestion[], + ); + console.log("Dry run removal:"); + for (const line of removalPlan.previewLines) { + console.log(` ${line}`); + } + if (!(await confirm(`Remove ${indexesToRemove.length} selected account(s) and retry sync?`))) { + await restorePruneBackup(); + console.log("Sync cancelled.\n"); + return; + } + if (!pruneBackup) { + pruneBackup = await createSyncPruneBackup(); + } + try { + await removeAccountsForSync(removalPlan.targets); + } catch (removalError) { + await restorePruneBackup(); + throw removalError; + } + continue; + } + + const message = error instanceof Error ? error.message : String(error); + await restorePruneBackup().catch((restoreError) => { + const restoreMessage = + restoreError instanceof Error ? restoreError.message : String(restoreError); + logWarn(`[${PLUGIN_NAME}] Failed to restore sync prune backup: ${restoreMessage}`); + }); + console.log(`\nSync failed: ${message}\n`); + return; + } + } + }; + + const runCodexMultiAuthOverlapCleanup = async (): Promise => { + let backupPath: string | undefined; + try { + const preview = await previewCodexMultiAuthSyncedOverlapCleanup(); + if (preview.removed <= 0 && preview.updated <= 0) { + console.log("\nNo synced overlaps found.\n"); + return; + } + console.log(""); + console.log("Cleanup preview."); + console.log(`Before: ${preview.before}`); + console.log(`After: ${preview.after}`); + console.log(`Would remove overlaps: ${preview.removed}`); + console.log(`Would update synced records: ${preview.updated}`); + if (!(await confirm("Create a backup and apply synced overlap cleanup?"))) { + console.log("\nCleanup cancelled.\n"); + return; + } + backupPath = createTimestampedBackupPath("codex-maintenance-overlap-backup"); + const result = await cleanupCodexMultiAuthSyncedOverlaps(backupPath); + invalidateAccountManagerCache(); + console.log(""); + console.log("Cleanup complete."); + console.log(`Before: ${result.before}`); + console.log(`After: ${result.after}`); + console.log(`Removed overlaps: ${result.removed}`); + console.log(`Updated synced records: ${result.updated}`); + console.log(`Backup: ${backupPath}`); + console.log(""); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + const backupHint = backupPath ? `\nBackup: ${backupPath}` : ""; + console.log(`\nCleanup failed: ${message}${backupHint}\n`); + } + }; + if (!explicitLoginMode) { while (true) { const loadedStorage = await hydrateEmails(await loadAccounts()); @@ -3388,6 +3810,7 @@ while (attempted.size < Math.max(1, accountCount)) { const menuResult = await promptLoginMode(existingAccounts, { flaggedCount: flaggedStorage.accounts.length, + syncFromCodexMultiAuthEnabled: getSyncFromCodexMultiAuthEnabled(loadPluginConfig()), }); if (menuResult.mode === "cancel") { @@ -3414,6 +3837,18 @@ while (attempted.size < Math.max(1, accountCount)) { await verifyFlaggedAccounts(); continue; } + if (menuResult.mode === "experimental-toggle-sync") { + await toggleCodexMultiAuthSyncSetting(); + continue; + } + if (menuResult.mode === "experimental-sync-now") { + await runCodexMultiAuthSync(); + continue; + } + if (menuResult.mode === "experimental-cleanup-overlaps") { + await runCodexMultiAuthOverlapCleanup(); + continue; + } if (menuResult.mode === "manage") { if (typeof menuResult.deleteAccountIndex === "number") { diff --git a/lib/cli.ts b/lib/cli.ts index 1bd6656f..fe567907 100644 --- a/lib/cli.ts +++ b/lib/cli.ts @@ -4,6 +4,7 @@ import type { AccountIdSource } from "./types.js"; import { showAuthMenu, showAccountDetails, + showSyncToolsMenu, isTTY, type AccountStatus, } from "./ui/auth-menu.js"; @@ -46,6 +47,9 @@ export type LoginMode = | "check" | "deep-check" | "verify-flagged" + | "experimental-toggle-sync" + | "experimental-sync-now" + | "experimental-cleanup-overlaps" | "cancel"; export interface ExistingAccountInfo { @@ -62,6 +66,7 @@ export interface ExistingAccountInfo { export interface LoginMenuOptions { flaggedCount?: number; + syncFromCodexMultiAuthEnabled?: boolean; } export interface LoginMenuResult { @@ -101,7 +106,117 @@ async function promptDeleteAllTypedConfirm(): Promise { } } -async function promptLoginModeFallback(existingAccounts: ExistingAccountInfo[]): Promise { +async function promptSyncToolsFallback( + rl: ReturnType, + syncEnabled: boolean, +): Promise { + while (true) { + const syncState = syncEnabled ? "enabled" : "disabled"; + const answer = await rl.question( + `Sync tools: (t)oggle [${syncState}], (i)mport now, (o)verlap cleanup, (b)ack [t/i/o/b]: `, + ); + const normalized = answer.trim().toLowerCase(); + if (normalized === "t" || normalized === "toggle") return { mode: "experimental-toggle-sync" }; + if (normalized === "i" || normalized === "import") return { mode: "experimental-sync-now" }; + if (normalized === "o" || normalized === "overlap") return { mode: "experimental-cleanup-overlaps" }; + if (normalized === "b" || normalized === "back") return null; + console.log("Please enter one of: t, i, o, b."); + } +} + +export interface SyncPruneCandidate { + index: number; + email?: string; + accountLabel?: string; + isCurrentAccount?: boolean; + reason?: string; +} + +function formatPruneCandidate(candidate: SyncPruneCandidate): string { + const label = formatAccountLabel( + { + index: candidate.index, + email: candidate.email, + accountLabel: candidate.accountLabel, + isCurrentAccount: candidate.isCurrentAccount, + }, + candidate.index, + ); + const details: string[] = []; + if (candidate.isCurrentAccount) details.push("current"); + if (candidate.reason) details.push(candidate.reason); + return details.length > 0 ? `${label} | ${details.join(" | ")}` : label; +} + +export async function promptCodexMultiAuthSyncPrune( + neededCount: number, + candidates: SyncPruneCandidate[], +): Promise { + if (isNonInteractiveMode()) { + return null; + } + + const suggested = candidates + .filter((candidate) => candidate.isCurrentAccount !== true) + .slice(0, neededCount) + .map((candidate) => candidate.index); + + const rl = createInterface({ input, output }); + try { + console.log(""); + console.log(`Sync needs ${neededCount} free slot(s).`); + console.log("Suggested removals:"); + for (const candidate of candidates) { + console.log(` ${formatPruneCandidate(candidate)}`); + } + console.log(""); + console.log( + suggested.length >= neededCount + ? "Press Enter to remove the suggested accounts, or enter comma-separated numbers." + : "Enter comma-separated account numbers to remove, or Q to cancel.", + ); + + while (true) { + const answer = await rl.question(`Remove at least ${neededCount} account(s): `); + const normalized = answer.trim(); + if (!normalized) { + if (suggested.length >= neededCount) { + return suggested; + } + console.log("No default suggestion is available. Enter one or more account numbers."); + continue; + } + + if (normalized.toLowerCase() === "q" || normalized.toLowerCase() === "quit") { + return null; + } + + const tokens = normalized.split(",").map((value) => value.trim()); + if (tokens.length === 0 || tokens.some((value) => !/^\d+$/.test(value))) { + console.log("Enter comma-separated account numbers (for example: 1,2)."); + continue; + } + const allowedIndexes = new Set(candidates.map((candidate) => candidate.index)); + const unique = Array.from(new Set(tokens.map((value) => Number.parseInt(value, 10) - 1))); + if (unique.some((index) => !allowedIndexes.has(index))) { + console.log("Enter only account numbers shown above."); + continue; + } + if (unique.length < neededCount) { + console.log(`Select at least ${neededCount} unique account number(s).`); + continue; + } + return unique; + } + } finally { + rl.close(); + } +} + +async function promptLoginModeFallback( + existingAccounts: ExistingAccountInfo[], + options: LoginMenuOptions, +): Promise { const rl = createInterface({ input, output }); try { if (existingAccounts.length > 0) { @@ -113,15 +228,23 @@ async function promptLoginModeFallback(existingAccounts: ExistingAccountInfo[]): } while (true) { - const answer = await rl.question("(a)dd, (f)resh, (c)heck, (d)eep, (v)erify flagged, or (q)uit? [a/f/c/d/v/q]: "); + const answer = await rl.question("(a)dd, (f)resh, (c)heck, (d)eep, (v)erify flagged, s(y)nc tools, or (q)uit? [a/f/c/d/v/s/q]: "); const normalized = answer.trim().toLowerCase(); if (normalized === "a" || normalized === "add") return { mode: "add" }; if (normalized === "f" || normalized === "fresh") return { mode: "fresh", deleteAll: true }; if (normalized === "c" || normalized === "check") return { mode: "check" }; if (normalized === "d" || normalized === "deep") return { mode: "deep-check" }; if (normalized === "v" || normalized === "verify") return { mode: "verify-flagged" }; + if (normalized === "s" || normalized === "sync" || normalized === "y") { + const syncAction = await promptSyncToolsFallback( + rl, + options.syncFromCodexMultiAuthEnabled === true, + ); + if (syncAction) return syncAction; + continue; + } if (normalized === "q" || normalized === "quit") return { mode: "cancel" }; - console.log("Please enter one of: a, f, c, d, v, q."); + console.log("Please enter one of: a, f, c, d, v, s, q."); } } finally { rl.close(); @@ -137,12 +260,13 @@ export async function promptLoginMode( } if (!isTTY()) { - return promptLoginModeFallback(existingAccounts); + return promptLoginModeFallback(existingAccounts, options); } while (true) { const action = await showAuthMenu(existingAccounts, { flaggedCount: options.flaggedCount ?? 0, + syncFromCodexMultiAuthEnabled: options.syncFromCodexMultiAuthEnabled === true, }); switch (action.type) { @@ -160,6 +284,13 @@ export async function promptLoginMode( return { mode: "deep-check" }; case "verify-flagged": return { mode: "verify-flagged" }; + case "sync-tools": { + const syncAction = await showSyncToolsMenu(options.syncFromCodexMultiAuthEnabled === true); + if (syncAction === "toggle-sync") return { mode: "experimental-toggle-sync" }; + if (syncAction === "sync-now") return { mode: "experimental-sync-now" }; + if (syncAction === "cleanup-overlaps") return { mode: "experimental-cleanup-overlaps" }; + continue; + } case "select-account": { const accountAction = await showAccountDetails(action.account); if (accountAction === "delete") { diff --git a/lib/codex-multi-auth-sync.ts b/lib/codex-multi-auth-sync.ts new file mode 100644 index 00000000..f5a1f123 --- /dev/null +++ b/lib/codex-multi-auth-sync.ts @@ -0,0 +1,1236 @@ +import { existsSync, readdirSync, promises as fs } from "node:fs"; +import { homedir } from "node:os"; +import { dirname, join, win32 } from "node:path"; +import { ACCOUNT_LIMITS } from "./constants.js"; +import { logWarn } from "./logger.js"; +import { + deduplicateAccounts, + deduplicateAccountsByEmail, + importAccounts, + loadAccounts, + normalizeAccountStorage, + previewImportAccountsWithExistingStorage, + withAccountStorageTransaction, + type AccountStorageV3, + type ImportAccountsResult, +} from "./storage.js"; +import { findProjectRoot, getProjectStorageKey } from "./storage/paths.js"; + +const EXTERNAL_ROOT_SUFFIX = "multi-auth"; +const EXTERNAL_ACCOUNT_FILE_NAMES = [ + "openai-codex-accounts.json", + "codex-accounts.json", +]; +const SYNC_ACCOUNT_TAG = "codex-multi-auth-sync"; +const SYNC_MAX_ACCOUNTS_OVERRIDE_ENV = "CODEX_AUTH_SYNC_MAX_ACCOUNTS"; +const NORMALIZED_IMPORT_TEMP_PREFIX = "oc-chatgpt-multi-auth-sync-"; +const STALE_NORMALIZED_IMPORT_MAX_AGE_MS = 10 * 60 * 1000; + +export interface CodexMultiAuthResolvedSource { + rootDir: string; + accountsPath: string; + scope: "project" | "global"; +} + +export interface LoadedCodexMultiAuthSourceStorage extends CodexMultiAuthResolvedSource { + storage: AccountStorageV3; +} + +export interface CodexMultiAuthSyncPreview extends CodexMultiAuthResolvedSource { + imported: number; + skipped: number; + total: number; +} + +export interface CodexMultiAuthSyncResult extends CodexMultiAuthSyncPreview { + backupStatus: ImportAccountsResult["backupStatus"]; + backupPath?: string; + backupError?: string; +} + +export interface CodexMultiAuthCleanupResult { + before: number; + after: number; + removed: number; + updated: number; +} + +export interface CodexMultiAuthSyncCapacityDetails extends CodexMultiAuthResolvedSource { + currentCount: number; + sourceCount: number; + sourceDedupedTotal: number; + dedupedTotal: number; + maxAccounts: number; + needToRemove: number; + importableNewAccounts: number; + skippedOverlaps: number; + suggestedRemovals: Array<{ + index: number; + email?: string; + accountLabel?: string; + refreshToken: string; + organizationId?: string; + accountId?: string; + isCurrentAccount: boolean; + score: number; + reason: string; + }>; +} + +function normalizeTrimmedIdentity(value: string | undefined): string | undefined { + const trimmed = value?.trim(); + return trimmed && trimmed.length > 0 ? trimmed : undefined; +} + +function normalizeSourceStorage(storage: AccountStorageV3): AccountStorageV3 { + const normalizedAccounts = storage.accounts.map((account) => { + const accountId = account.accountId?.trim(); + const organizationId = account.organizationId?.trim(); + const inferredOrganizationId = + !organizationId && + account.accountIdSource === "org" && + accountId && + accountId.startsWith("org-") + ? accountId + : organizationId; + + if (inferredOrganizationId && inferredOrganizationId !== organizationId) { + return { + ...account, + organizationId: inferredOrganizationId, + }; + } + return account; + }); + + return { + ...storage, + accounts: normalizedAccounts, + }; +} + +type NormalizedImportFileOptions = { + postSuccessCleanupFailureMode?: "throw" | "warn"; + onPostSuccessCleanupFailure?: (details: { tempDir: string; tempPath: string; message: string }) => void; +}; + +interface PreparedCodexMultiAuthPreviewStorage { + resolved: CodexMultiAuthResolvedSource & { storage: AccountStorageV3 }; + existing: AccountStorageV3; +} + +const TEMP_CLEANUP_RETRY_DELAYS_MS = [100, 250, 500] as const; +const STALE_TEMP_CLEANUP_RETRY_DELAY_MS = 150; + +function sleepAsync(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +async function removeNormalizedImportTempDir( + tempDir: string, + tempPath: string, + options: NormalizedImportFileOptions, +): Promise { + const retryableCodes = new Set(["EBUSY", "EAGAIN", "ENOTEMPTY", "EACCES", "EPERM"]); + let lastMessage = "unknown cleanup failure"; + for (let attempt = 0; attempt <= TEMP_CLEANUP_RETRY_DELAYS_MS.length; attempt += 1) { + try { + await fs.rm(tempDir, { recursive: true, force: true }); + return; + } catch (cleanupError) { + const code = (cleanupError as NodeJS.ErrnoException).code; + lastMessage = cleanupError instanceof Error ? cleanupError.message : String(cleanupError); + if ((!code || retryableCodes.has(code)) && attempt < TEMP_CLEANUP_RETRY_DELAYS_MS.length) { + const delayMs = TEMP_CLEANUP_RETRY_DELAYS_MS[attempt]; + if (delayMs !== undefined) { + await sleepAsync(delayMs); + } + continue; + } + break; + } + } + + logWarn(`Failed to remove temporary codex sync directory ${tempDir}: ${lastMessage}`); + options.onPostSuccessCleanupFailure?.({ tempDir, tempPath, message: lastMessage }); + if (options.postSuccessCleanupFailureMode !== "warn") { + throw new Error(`Failed to remove temporary codex sync directory ${tempDir}: ${lastMessage}`); + } +} + +function normalizeCleanupRateLimitResetTimes( + value: AccountStorageV3["accounts"][number]["rateLimitResetTimes"], +): Array<[string, number]> { + return Object.entries(value ?? {}) + .filter((entry): entry is [string, number] => typeof entry[1] === "number" && Number.isFinite(entry[1])) + .sort(([left], [right]) => left.localeCompare(right)); +} + +function normalizeCleanupTags(tags: string[] | undefined): string[] { + return [...(tags ?? [])].sort((left, right) => left.localeCompare(right)); +} + +function cleanupComparableAccount(account: AccountStorageV3["accounts"][number]): Record { + return { + refreshToken: account.refreshToken, + accessToken: account.accessToken, + expiresAt: account.expiresAt, + accountId: account.accountId, + organizationId: account.organizationId, + accountIdSource: account.accountIdSource, + accountLabel: account.accountLabel, + email: account.email, + enabled: account.enabled, + addedAt: account.addedAt, + lastUsed: account.lastUsed, + coolingDownUntil: account.coolingDownUntil, + cooldownReason: account.cooldownReason, + lastSwitchReason: account.lastSwitchReason, + accountNote: account.accountNote, + accountTags: normalizeCleanupTags(account.accountTags), + rateLimitResetTimes: normalizeCleanupRateLimitResetTimes(account.rateLimitResetTimes), + }; +} + +function accountsEqualForCleanup( + left: AccountStorageV3["accounts"][number], + right: AccountStorageV3["accounts"][number], +): boolean { + return JSON.stringify(cleanupComparableAccount(left)) === JSON.stringify(cleanupComparableAccount(right)); +} + +function storagesEqualForCleanup(left: AccountStorageV3, right: AccountStorageV3): boolean { + if (left.activeIndex !== right.activeIndex) return false; + + const leftFamilyIndices = (left.activeIndexByFamily ?? {}) as Record; + const rightFamilyIndices = (right.activeIndexByFamily ?? {}) as Record; + const familyKeys = new Set([...Object.keys(leftFamilyIndices), ...Object.keys(rightFamilyIndices)]); + + for (const family of familyKeys) { + if ((leftFamilyIndices[family] ?? left.activeIndex) !== (rightFamilyIndices[family] ?? right.activeIndex)) { + return false; + } + } + + if (left.accounts.length !== right.accounts.length) return false; + return left.accounts.every((account, index) => { + const candidate = right.accounts[index]; + return candidate ? accountsEqualForCleanup(account, candidate) : false; + }); +} + +function createCleanupRedactedStorage(storage: AccountStorageV3): AccountStorageV3 { + return { + ...storage, + accounts: storage.accounts.map((account) => ({ + ...account, + refreshToken: "__redacted__", + accessToken: undefined, + idToken: undefined, + })), + }; +} + +async function redactNormalizedImportTempFile(tempPath: string, storage: AccountStorageV3): Promise { + try { + const redactedStorage = createCleanupRedactedStorage(storage); + await fs.writeFile(tempPath, `${JSON.stringify(redactedStorage, null, 2)}\n`, { + encoding: "utf-8", + mode: 0o600, + flag: "w", + }); + } catch (error) { + logWarn( + `Failed to redact temporary codex sync file ${tempPath} before cleanup: ${ + error instanceof Error ? error.message : String(error) + }`, + ); + } +} + +async function withNormalizedImportFile( + storage: AccountStorageV3, + handler: (filePath: string) => Promise, + options: NormalizedImportFileOptions = {}, +): Promise { + const runWithTempDir = async (tempDir: string): Promise => { + await fs.chmod(tempDir, 0o700).catch(() => undefined); + const tempPath = join(tempDir, "accounts.json"); + try { + await fs.writeFile(tempPath, `${JSON.stringify(storage, null, 2)}\n`, { + encoding: "utf-8", + mode: 0o600, + flag: "wx", + }); + const result = await handler(tempPath); + await redactNormalizedImportTempFile(tempPath, storage); + await removeNormalizedImportTempDir(tempDir, tempPath, options); + return result; + } catch (error) { + await redactNormalizedImportTempFile(tempPath, storage); + try { + await removeNormalizedImportTempDir(tempDir, tempPath, { postSuccessCleanupFailureMode: "warn" }); + } catch (cleanupError) { + const message = cleanupError instanceof Error ? cleanupError.message : String(cleanupError); + logWarn(`Failed to remove temporary codex sync directory ${tempDir}: ${message}`); + } + throw error; + } + }; + + const secureTempRoot = join(getResolvedUserHomeDir(), ".opencode", "tmp"); + // On Windows the mode/chmod calls are ignored; the home-directory ACLs remain + // the actual isolation boundary for this temporary token material. + await fs.mkdir(secureTempRoot, { recursive: true, mode: 0o700 }); + await cleanupStaleNormalizedImportTempDirs(secureTempRoot); + const tempDir = await fs.mkdtemp(join(secureTempRoot, NORMALIZED_IMPORT_TEMP_PREFIX)); + return runWithTempDir(tempDir); +} + +async function cleanupStaleNormalizedImportTempDirs( + secureTempRoot: string, + now = Date.now(), +): Promise { + try { + const entries = await fs.readdir(secureTempRoot, { withFileTypes: true }); + for (const entry of entries) { + if (!entry.isDirectory() || !entry.name.startsWith(NORMALIZED_IMPORT_TEMP_PREFIX)) { + continue; + } + + const candidateDir = join(secureTempRoot, entry.name); + try { + const stats = await fs.stat(candidateDir); + if (now - stats.mtimeMs < STALE_NORMALIZED_IMPORT_MAX_AGE_MS) { + continue; + } + await fs.rm(candidateDir, { recursive: true, force: true }); + } catch (error) { + let code = (error as NodeJS.ErrnoException).code; + if (code === "ENOENT") { + continue; + } + let message = error instanceof Error ? error.message : String(error); + if (code === "EBUSY" || code === "EACCES" || code === "EPERM") { + await sleepAsync(STALE_TEMP_CLEANUP_RETRY_DELAY_MS); + try { + await fs.rm(candidateDir, { recursive: true, force: true }); + continue; + } catch (retryError) { + code = (retryError as NodeJS.ErrnoException).code; + if (code === "ENOENT") { + continue; + } + message = retryError instanceof Error ? retryError.message : String(retryError); + } + } + logWarn(`Failed to sweep stale codex sync temp directory ${candidateDir}: ${message}`); + } + } + } catch (error) { + const code = (error as NodeJS.ErrnoException).code; + if (code === "ENOENT") { + return; + } + const message = error instanceof Error ? error.message : String(error); + logWarn(`Failed to list codex sync temp root ${secureTempRoot}: ${message}`); + } +} + +function deduplicateAccountsForSync(storage: AccountStorageV3): AccountStorageV3 { + return { + ...storage, + accounts: deduplicateAccountsByEmail(deduplicateAccounts(storage.accounts)), + }; +} + +function selectNewestByTimestamp( + current: T, + candidate: T, +): T { + const currentLastUsed = current.lastUsed ?? 0; + const candidateLastUsed = candidate.lastUsed ?? 0; + if (candidateLastUsed > currentLastUsed) return candidate; + if (candidateLastUsed < currentLastUsed) return current; + const currentAddedAt = current.addedAt ?? 0; + const candidateAddedAt = candidate.addedAt ?? 0; + return candidateAddedAt >= currentAddedAt ? candidate : current; +} + +function deduplicateSourceAccountsByEmail( + accounts: AccountStorageV3["accounts"], +): AccountStorageV3["accounts"] { + const deduplicatedInput = deduplicateAccounts(accounts); + const deduplicated: AccountStorageV3["accounts"] = []; + const emailToIndex = new Map(); + + for (const account of deduplicatedInput) { + if (normalizeIdentity(account.organizationId) || normalizeIdentity(account.accountId)) { + deduplicated.push(account); + continue; + } + const normalizedEmail = normalizeIdentity(account.email); + if (!normalizedEmail) { + deduplicated.push(account); + continue; + } + + const existingIndex = emailToIndex.get(normalizedEmail); + if (existingIndex === undefined) { + emailToIndex.set(normalizedEmail, deduplicated.length); + deduplicated.push(account); + continue; + } + + const existing = deduplicated[existingIndex]; + if (!existing) continue; + const newest = selectNewestByTimestamp(existing, account); + const older = newest === existing ? account : existing; + deduplicated[existingIndex] = { + ...older, + ...newest, + email: newest.email ?? older.email, + accountLabel: newest.accountLabel ?? older.accountLabel, + accountId: newest.accountId ?? older.accountId, + organizationId: newest.organizationId ?? older.organizationId, + accountIdSource: newest.accountIdSource ?? older.accountIdSource, + refreshToken: newest.refreshToken ?? older.refreshToken, + }; + } + + return deduplicated; +} + +function buildExistingSyncIdentityState(existingAccounts: AccountStorageV3["accounts"]): { + organizationIds: Set; + accountIds: Set; + refreshTokens: Set; + emails: Set; +} { + const organizationIds = new Set(); + const accountIds = new Set(); + const refreshTokens = new Set(); + const emails = new Set(); + + for (const account of existingAccounts) { + const organizationId = normalizeIdentity(account.organizationId); + const accountId = normalizeIdentity(account.accountId); + const refreshToken = normalizeTrimmedIdentity(account.refreshToken); + const email = normalizeIdentity(account.email); + if (organizationId) organizationIds.add(organizationId); + if (accountId) accountIds.add(accountId); + if (refreshToken) refreshTokens.add(refreshToken); + if (email) emails.add(email); + } + + return { + organizationIds, + accountIds, + refreshTokens, + emails, + }; +} + +function filterSourceAccountsAgainstExistingEmails( + sourceStorage: AccountStorageV3, + existingAccounts: AccountStorageV3["accounts"], +): AccountStorageV3 { + const existingState = buildExistingSyncIdentityState(existingAccounts); + + return { + ...sourceStorage, + accounts: deduplicateSourceAccountsByEmail(sourceStorage.accounts).filter((account) => { + const organizationId = normalizeIdentity(account.organizationId); + if (organizationId) { + return !existingState.organizationIds.has(organizationId); + } + const accountId = normalizeIdentity(account.accountId); + if (accountId) { + return !existingState.accountIds.has(accountId); + } + const refreshToken = normalizeTrimmedIdentity(account.refreshToken); + if (refreshToken && existingState.refreshTokens.has(refreshToken)) { + return false; + } + const normalizedEmail = normalizeIdentity(account.email); + if (normalizedEmail) { + return !existingState.emails.has(normalizedEmail); + } + return true; + }), + }; +} + +function buildMergedDedupedAccounts( + currentAccounts: AccountStorageV3["accounts"], + sourceAccounts: AccountStorageV3["accounts"], +): AccountStorageV3["accounts"] { + return deduplicateAccountsForSync({ + version: 3, + accounts: [...currentAccounts, ...sourceAccounts], + activeIndex: 0, + activeIndexByFamily: {}, + }).accounts; +} + +function computeSyncCapacityDetails( + resolved: CodexMultiAuthResolvedSource, + sourceStorage: AccountStorageV3, + existing: AccountStorageV3, + maxAccounts: number, +): CodexMultiAuthSyncCapacityDetails | null { + const sourceDedupedTotal = buildMergedDedupedAccounts([], sourceStorage.accounts).length; + const mergedAccounts = buildMergedDedupedAccounts(existing.accounts, sourceStorage.accounts); + if (mergedAccounts.length <= maxAccounts) { + return null; + } + + const currentCount = existing.accounts.length; + const sourceCount = sourceStorage.accounts.length; + const dedupedTotal = mergedAccounts.length; + const importableNewAccounts = Math.max(0, dedupedTotal - currentCount); + const skippedOverlaps = Math.max(0, sourceCount - importableNewAccounts); + if (sourceDedupedTotal > maxAccounts) { + return { + rootDir: resolved.rootDir, + accountsPath: resolved.accountsPath, + scope: resolved.scope, + currentCount, + sourceCount, + sourceDedupedTotal, + dedupedTotal: sourceDedupedTotal, + maxAccounts, + needToRemove: sourceDedupedTotal - maxAccounts, + importableNewAccounts: 0, + skippedOverlaps: Math.max(0, sourceCount - sourceDedupedTotal), + suggestedRemovals: [], + }; + } + + const sourceIdentities = buildSourceIdentitySet(sourceStorage); + const suggestedRemovals = existing.accounts + .map((account, index) => { + const matchesSource = accountMatchesSource(account, sourceIdentities); + const isCurrentAccount = index === existing.activeIndex; + const hypotheticalAccounts = existing.accounts.filter((_, candidateIndex) => candidateIndex !== index); + const hypotheticalTotal = buildMergedDedupedAccounts(hypotheticalAccounts, sourceStorage.accounts).length; + const capacityRelief = Math.max(0, dedupedTotal - hypotheticalTotal); + return { + index, + email: account.email, + accountLabel: account.accountLabel, + refreshToken: account.refreshToken, + organizationId: account.organizationId, + accountId: account.accountId, + isCurrentAccount, + enabled: account.enabled !== false, + matchesSource, + lastUsed: account.lastUsed ?? 0, + capacityRelief, + score: buildRemovalScore(account, { matchesSource, isCurrentAccount, capacityRelief }), + reason: buildRemovalExplanation(account, { matchesSource, capacityRelief }), + }; + }) + .sort((left, right) => { + if (left.score !== right.score) { + return right.score - left.score; + } + if (left.lastUsed !== right.lastUsed) { + return left.lastUsed - right.lastUsed; + } + return left.index - right.index; + }) + .slice(0, Math.max(5, dedupedTotal - maxAccounts)) + .map(({ index, email, accountLabel, refreshToken, organizationId, accountId, isCurrentAccount, score, reason }) => ({ + index, + email, + accountLabel, + refreshToken, + organizationId, + accountId, + isCurrentAccount, + score, + reason, + })); + + return { + rootDir: resolved.rootDir, + accountsPath: resolved.accountsPath, + scope: resolved.scope, + currentCount, + sourceCount, + sourceDedupedTotal, + dedupedTotal, + maxAccounts, + needToRemove: dedupedTotal - maxAccounts, + importableNewAccounts, + skippedOverlaps, + suggestedRemovals, + }; +} + +function normalizeIdentity(value: string | undefined): string | undefined { + const trimmed = value?.trim(); + return trimmed && trimmed.length > 0 ? trimmed.toLowerCase() : undefined; +} + +function toCleanupIdentityKeys(account: { + organizationId?: string; + accountId?: string; + refreshToken: string; +}): string[] { + const keys: string[] = []; + const organizationId = normalizeIdentity(account.organizationId); + if (organizationId) keys.push(`org:${organizationId}`); + const accountId = normalizeIdentity(account.accountId); + if (accountId) keys.push(`account:${accountId}`); + const refreshToken = normalizeTrimmedIdentity(account.refreshToken); + if (refreshToken) keys.push(`refresh:${refreshToken}`); + return keys; +} + +function extractCleanupActiveKeys( + accounts: AccountStorageV3["accounts"], + activeIndex: number, +): string[] { + const candidate = accounts[activeIndex]; + if (!candidate) return []; + return toCleanupIdentityKeys({ + organizationId: candidate.organizationId, + accountId: candidate.accountId, + refreshToken: candidate.refreshToken, + }); +} + +function findCleanupAccountIndexByIdentityKeys( + accounts: AccountStorageV3["accounts"], + identityKeys: string[], +): number { + if (identityKeys.length === 0) return -1; + for (const identityKey of identityKeys) { + const index = accounts.findIndex((account) => + toCleanupIdentityKeys({ + organizationId: account.organizationId, + accountId: account.accountId, + refreshToken: account.refreshToken, + }).includes(identityKey), + ); + if (index >= 0) return index; + } + return -1; +} + +function buildSourceIdentitySet(storage: AccountStorageV3): Set { + const identities = new Set(); + for (const account of storage.accounts) { + const organizationId = normalizeIdentity(account.organizationId); + const accountId = normalizeIdentity(account.accountId); + const email = normalizeIdentity(account.email); + const refreshToken = normalizeTrimmedIdentity(account.refreshToken); + if (organizationId) identities.add(`org:${organizationId}`); + if (accountId) identities.add(`account:${accountId}`); + if (email) identities.add(`email:${email}`); + if (refreshToken) identities.add(`refresh:${refreshToken}`); + } + return identities; +} + +function accountMatchesSource(account: AccountStorageV3["accounts"][number], sourceIdentities: Set): boolean { + const organizationId = normalizeIdentity(account.organizationId); + const accountId = normalizeIdentity(account.accountId); + const email = normalizeIdentity(account.email); + const refreshToken = normalizeTrimmedIdentity(account.refreshToken); + return ( + (organizationId ? sourceIdentities.has(`org:${organizationId}`) : false) || + (accountId ? sourceIdentities.has(`account:${accountId}`) : false) || + (email ? sourceIdentities.has(`email:${email}`) : false) || + (refreshToken ? sourceIdentities.has(`refresh:${refreshToken}`) : false) + ); +} + +function buildRemovalScore( + account: AccountStorageV3["accounts"][number], + options: { matchesSource: boolean; isCurrentAccount: boolean; capacityRelief: number }, +): number { + let score = 0; + if (options.isCurrentAccount) { + score -= 1000; + } + score += options.capacityRelief * 1000; + if (account.enabled === false) { + score += 120; + } + if (!options.matchesSource) { + score += 80; + } + const lastUsed = account.lastUsed ?? 0; + if (lastUsed > 0) { + const ageDays = Math.max(0, Math.floor((Date.now() - lastUsed) / 86_400_000)); + score += Math.min(60, ageDays); + } else { + score += 40; + } + return score; +} + +function buildRemovalExplanation( + account: AccountStorageV3["accounts"][number], + options: { matchesSource: boolean; capacityRelief: number }, +): string { + const details: string[] = []; + if (options.capacityRelief > 0) { + details.push(`frees ${options.capacityRelief} sync slot${options.capacityRelief === 1 ? "" : "s"}`); + } + if (account.enabled === false) { + details.push("disabled"); + } + if (!options.matchesSource) { + details.push("not present in codex-multi-auth source"); + } + if (details.length === 0) { + details.push("least recently used"); + } + return details.join(", "); +} + +function firstNonEmpty(values: Array): string | null { + for (const value of values) { + const trimmed = (value ?? "").trim(); + if (trimmed.length > 0) { + return trimmed; + } + } + return null; +} + +function getResolvedUserHomeDir(): string { + if (process.platform === "win32") { + const homeDrive = (process.env.HOMEDRIVE ?? "").trim(); + const homePath = (process.env.HOMEPATH ?? "").trim(); + const drivePathHome = + homeDrive.length > 0 && homePath.length > 0 + ? win32.resolve(`${homeDrive}\\`, homePath) + : undefined; + return ( + firstNonEmpty([ + process.env.USERPROFILE, + process.env.HOME, + drivePathHome, + homedir(), + ]) ?? homedir() + ); + } + return firstNonEmpty([process.env.HOME, homedir()]) ?? homedir(); +} + +function deduplicatePaths(paths: string[]): string[] { + const seen = new Set(); + const result: string[] = []; + for (const candidate of paths) { + const trimmed = candidate.trim(); + if (trimmed.length === 0) continue; + const key = process.platform === "win32" ? trimmed.toLowerCase() : trimmed; + if (seen.has(key)) continue; + seen.add(key); + result.push(trimmed); + } + return result; +} + +function hasStorageSignals(dir: string): boolean { + for (const fileName of [...EXTERNAL_ACCOUNT_FILE_NAMES, "settings.json", "dashboard-settings.json", "config.json"]) { + if (existsSync(join(dir, fileName))) { + return true; + } + } + return existsSync(join(dir, "projects")); +} + +function hasProjectScopedAccountsStorage(dir: string): boolean { + const projectsDir = join(dir, "projects"); + try { + for (const entry of readdirSync(projectsDir, { withFileTypes: true })) { + if (!entry.isDirectory()) { + continue; + } + for (const fileName of EXTERNAL_ACCOUNT_FILE_NAMES) { + if (existsSync(join(projectsDir, entry.name, fileName))) { + return true; + } + } + } + } catch { + // best-effort probe; missing or unreadable project roots simply mean "no signal" + } + return false; +} + +function hasAccountsStorage(dir: string): boolean { + return ( + EXTERNAL_ACCOUNT_FILE_NAMES.some((fileName) => existsSync(join(dir, fileName))) || + hasProjectScopedAccountsStorage(dir) + ); +} + +function getCodexHomeDir(): string { + const fromEnv = (process.env.CODEX_HOME ?? "").trim(); + return fromEnv.length > 0 ? fromEnv : join(getResolvedUserHomeDir(), ".codex"); +} + +function getCodexMultiAuthRootCandidates(userHome: string): string[] { + const candidates = [ + join(userHome, "DevTools", "config", "codex", EXTERNAL_ROOT_SUFFIX), + join(userHome, ".codex", EXTERNAL_ROOT_SUFFIX), + ]; + const explicitCodexHome = (process.env.CODEX_HOME ?? "").trim(); + if (explicitCodexHome.length > 0) { + candidates.unshift(join(getCodexHomeDir(), EXTERNAL_ROOT_SUFFIX)); + } + return deduplicatePaths(candidates); +} + +function validateCodexMultiAuthRootDir(pathValue: string): string { + const trimmed = pathValue.trim(); + if (trimmed.length === 0) { + throw new Error("CODEX_MULTI_AUTH_DIR must not be empty"); + } + if (process.platform === "win32") { + const normalized = trimmed.replace(/\//g, "\\"); + const isExtendedDrivePath = /^\\\\[?.]\\[a-zA-Z]:\\/.test(normalized); + if (normalized.startsWith("\\\\") && !isExtendedDrivePath) { + throw new Error("CODEX_MULTI_AUTH_DIR must use a local absolute path, not a UNC network share"); + } + if (!/^[a-zA-Z]:\\/.test(normalized) && !isExtendedDrivePath) { + throw new Error("CODEX_MULTI_AUTH_DIR must be an absolute local path"); + } + return normalized; + } + if (!trimmed.startsWith("/")) { + throw new Error("CODEX_MULTI_AUTH_DIR must be an absolute path"); + } + return trimmed; +} + +function tagSyncedAccounts(storage: AccountStorageV3): AccountStorageV3 { + return { + ...storage, + accounts: storage.accounts.map((account) => { + const existingTags = Array.isArray(account.accountTags) ? account.accountTags : []; + return { + ...account, + accountTags: existingTags.includes(SYNC_ACCOUNT_TAG) + ? existingTags + : [...existingTags, SYNC_ACCOUNT_TAG], + }; + }), + }; +} + +export function getCodexMultiAuthSourceRootDir(): string { + const fromEnv = (process.env.CODEX_MULTI_AUTH_DIR ?? "").trim(); + if (fromEnv.length > 0) { + return validateCodexMultiAuthRootDir(fromEnv); + } + + const userHome = getResolvedUserHomeDir(); + const candidates = getCodexMultiAuthRootCandidates(userHome); + + for (const candidate of candidates) { + if (hasAccountsStorage(candidate)) { + return candidate; + } + } + + for (const candidate of candidates) { + if (hasStorageSignals(candidate)) { + return candidate; + } + } + + return candidates[0] ?? join(userHome, ".codex", EXTERNAL_ROOT_SUFFIX); +} + +function getProjectScopedAccountsPath(rootDir: string, projectPath: string): string | undefined { + const projectRoot = findProjectRoot(projectPath); + if (!projectRoot) { + return undefined; + } + + const candidateKey = getProjectStorageKey(projectRoot); + for (const fileName of EXTERNAL_ACCOUNT_FILE_NAMES) { + const candidate = join(rootDir, "projects", candidateKey, fileName); + if (existsSync(candidate)) { + return candidate; + } + } + return undefined; +} + +function getGlobalAccountsPath(rootDir: string): string | undefined { + for (const fileName of EXTERNAL_ACCOUNT_FILE_NAMES) { + const candidate = join(rootDir, fileName); + if (existsSync(candidate)) { + return candidate; + } + } + return undefined; +} + +export function resolveCodexMultiAuthAccountsSource(projectPath = process.cwd()): CodexMultiAuthResolvedSource { + const fromEnv = (process.env.CODEX_MULTI_AUTH_DIR ?? "").trim(); + const userHome = getResolvedUserHomeDir(); + const candidates = + fromEnv.length > 0 + ? [validateCodexMultiAuthRootDir(fromEnv)] + : getCodexMultiAuthRootCandidates(userHome); + + for (const rootDir of candidates) { + const projectScopedPath = getProjectScopedAccountsPath(rootDir, projectPath); + if (projectScopedPath) { + return { + rootDir, + accountsPath: projectScopedPath, + scope: "project", + }; + } + + const globalPath = getGlobalAccountsPath(rootDir); + if (globalPath) { + return { + rootDir, + accountsPath: globalPath, + scope: "global", + }; + } + } + + const hintedRoot = candidates.find((candidate) => hasAccountsStorage(candidate) || hasStorageSignals(candidate)) ?? candidates[0]; + throw new Error(`No codex-multi-auth accounts file found under ${hintedRoot}`); +} + +function getSyncCapacityLimit(): number { + const override = (process.env[SYNC_MAX_ACCOUNTS_OVERRIDE_ENV] ?? "").trim(); + if (override.length === 0) { + return ACCOUNT_LIMITS.MAX_ACCOUNTS; + } + if (/^\d+$/.test(override)) { + const parsed = Number.parseInt(override, 10); + if (parsed > 0) { + return Number.isFinite(ACCOUNT_LIMITS.MAX_ACCOUNTS) + ? Math.min(parsed, ACCOUNT_LIMITS.MAX_ACCOUNTS) + : parsed; + } + } + const message = `${SYNC_MAX_ACCOUNTS_OVERRIDE_ENV} override value "${override}" is not a positive integer; ignoring.`; + logWarn(message); + try { + process.stderr.write(`${message}\n`); + } catch { + // best-effort warning for non-interactive shells + } + return ACCOUNT_LIMITS.MAX_ACCOUNTS; +} + +export async function loadCodexMultiAuthSourceStorage( + projectPath = process.cwd(), +): Promise { + const resolved = resolveCodexMultiAuthAccountsSource(projectPath); + const raw = await fs.readFile(resolved.accountsPath, "utf-8"); + let parsed: unknown; + try { + parsed = JSON.parse(raw) as unknown; + } catch { + throw new Error(`Invalid JSON in codex-multi-auth accounts file: ${resolved.accountsPath}`); + } + + const storage = normalizeAccountStorage(parsed); + if (!storage) { + throw new Error(`Invalid codex-multi-auth account storage format: ${resolved.accountsPath}`); + } + + return { + ...resolved, + storage: normalizeSourceStorage(storage), + }; +} + +function createEmptyAccountStorage(): AccountStorageV3 { + return { + version: 3, + accounts: [], + activeIndex: 0, + activeIndexByFamily: {}, + }; +} + +async function prepareCodexMultiAuthPreviewStorage( + resolved: CodexMultiAuthResolvedSource & { storage: AccountStorageV3 }, +): Promise { + const current = await loadAccounts(); + const existing = current ?? createEmptyAccountStorage(); + const preparedStorage = filterSourceAccountsAgainstExistingEmails( + resolved.storage, + existing.accounts, + ); + const maxAccounts = getSyncCapacityLimit(); + // Infinity is the sentinel for the default unlimited-account mode. + if (Number.isFinite(maxAccounts)) { + const details = computeSyncCapacityDetails(resolved, preparedStorage, existing, maxAccounts); + if (details) { + throw new CodexMultiAuthSyncCapacityError(details); + } + } + return { + resolved: { + ...resolved, + storage: preparedStorage, + }, + existing, + }; +} + +export async function previewSyncFromCodexMultiAuth( + projectPath = process.cwd(), + loadedSource?: LoadedCodexMultiAuthSourceStorage, +): Promise { + const source = loadedSource ?? (await loadCodexMultiAuthSourceStorage(projectPath)); + const { resolved, existing } = await prepareCodexMultiAuthPreviewStorage(source); + const preview = await withNormalizedImportFile( + resolved.storage, + (filePath) => previewImportAccountsWithExistingStorage(filePath, existing), + { postSuccessCleanupFailureMode: "warn" }, + ); + return { + rootDir: resolved.rootDir, + accountsPath: resolved.accountsPath, + scope: resolved.scope, + ...preview, + }; +} + +export async function syncFromCodexMultiAuth( + projectPath = process.cwd(), + loadedSource?: LoadedCodexMultiAuthSourceStorage, +): Promise { + const resolved = loadedSource ?? (await loadCodexMultiAuthSourceStorage(projectPath)); + const result: ImportAccountsResult = await withNormalizedImportFile( + tagSyncedAccounts(resolved.storage), + (filePath) => { + const maxAccounts = getSyncCapacityLimit(); + return importAccounts( + filePath, + { + preImportBackupPrefix: "codex-multi-auth-sync-backup", + backupMode: "required", + }, + (normalizedStorage, existing) => { + const filteredStorage = filterSourceAccountsAgainstExistingEmails( + normalizedStorage, + existing?.accounts ?? [], + ); + // Infinity is the sentinel for the default unlimited-account mode. + if (Number.isFinite(maxAccounts)) { + const details = computeSyncCapacityDetails( + resolved, + filteredStorage, + existing ?? + ({ + version: 3, + accounts: [], + activeIndex: 0, + activeIndexByFamily: {}, + } satisfies AccountStorageV3), + maxAccounts, + ); + if (details) { + throw new CodexMultiAuthSyncCapacityError(details); + } + } + return filteredStorage; + }, + ); + }, + { postSuccessCleanupFailureMode: "warn" }, + ); + return { + rootDir: resolved.rootDir, + accountsPath: resolved.accountsPath, + scope: resolved.scope, + backupStatus: result.backupStatus, + backupPath: result.backupPath, + backupError: result.backupError, + imported: result.imported, + skipped: result.skipped, + total: result.total, + }; +} + +function buildCodexMultiAuthOverlapCleanupPlan(existing: AccountStorageV3): { + result: CodexMultiAuthCleanupResult; + nextStorage?: AccountStorageV3; +} { + const before = existing.accounts.length; + const syncedAccounts = existing.accounts.filter((account) => + Array.isArray(account.accountTags) && account.accountTags.includes(SYNC_ACCOUNT_TAG), + ); + if (syncedAccounts.length === 0) { + return { + result: { + before, + after: before, + removed: 0, + updated: 0, + }, + }; + } + const preservedAccounts = existing.accounts.filter( + (account) => !(Array.isArray(account.accountTags) && account.accountTags.includes(SYNC_ACCOUNT_TAG)), + ); + const normalizedSyncedStorage = normalizeAccountStorage( + normalizeSourceStorage({ + ...existing, + accounts: syncedAccounts, + }), + ); + if (!normalizedSyncedStorage) { + return { + result: { + before, + after: before, + removed: 0, + updated: 0, + }, + }; + } + const filteredSyncedAccounts = filterSourceAccountsAgainstExistingEmails( + normalizedSyncedStorage, + preservedAccounts, + ).accounts; + const deduplicatedSyncedAccounts = deduplicateAccounts(filteredSyncedAccounts); + const normalized = { + ...existing, + accounts: [...preservedAccounts, ...deduplicatedSyncedAccounts], + } satisfies AccountStorageV3; + const existingActiveKeys = extractCleanupActiveKeys(existing.accounts, existing.activeIndex); + const mappedActiveIndex = (() => { + const byIdentity = findCleanupAccountIndexByIdentityKeys(normalized.accounts, existingActiveKeys); + return byIdentity >= 0 + ? byIdentity + : Math.min(existing.activeIndex, Math.max(0, normalized.accounts.length - 1)); + })(); + const activeIndexByFamily = Object.fromEntries( + Object.entries(existing.activeIndexByFamily ?? {}).map(([family, index]) => { + const identityKeys = extractCleanupActiveKeys(existing.accounts, index); + const mappedIndex = findCleanupAccountIndexByIdentityKeys(normalized.accounts, identityKeys); + return [family, mappedIndex >= 0 ? mappedIndex : mappedActiveIndex]; + }), + ) as AccountStorageV3["activeIndexByFamily"]; + normalized.activeIndex = mappedActiveIndex; + normalized.activeIndexByFamily = activeIndexByFamily; + + const after = normalized.accounts.length; + const removed = Math.max(0, before - after); + const originalAccountsByKey = new Map(); + for (const account of existing.accounts) { + const key = toCleanupIdentityKeys(account)[0]; + if (key) { + originalAccountsByKey.set(key, account); + } + } + const updated = normalized.accounts.reduce((count, account) => { + const key = toCleanupIdentityKeys(account)[0]; + if (!key) return count; + const original = originalAccountsByKey.get(key); + if (!original) return count; + return accountsEqualForCleanup(original, account) ? count : count + 1; + }, 0); + const changed = removed > 0 || after !== before || !storagesEqualForCleanup(normalized, existing); + + return { + result: { + before, + after, + removed, + updated, + }, + nextStorage: changed ? normalized : undefined, + }; +} + +function sourceExceedsCapacityWithoutLocalRelief(details: CodexMultiAuthSyncCapacityDetails): boolean { + return ( + details.sourceDedupedTotal > details.maxAccounts && + details.importableNewAccounts === 0 && + details.suggestedRemovals.length === 0 + ); +} + +export function isCodexMultiAuthSourceTooLargeForCapacity( + details: CodexMultiAuthSyncCapacityDetails, +): boolean { + return sourceExceedsCapacityWithoutLocalRelief(details); +} + +export function getCodexMultiAuthCapacityErrorMessage( + details: CodexMultiAuthSyncCapacityDetails, +): string { + if (sourceExceedsCapacityWithoutLocalRelief(details)) { + return ( + `Sync source alone exceeds the maximum of ${details.maxAccounts} accounts ` + + `(${details.sourceDedupedTotal} deduped source accounts). Reduce the source set or raise ${SYNC_MAX_ACCOUNTS_OVERRIDE_ENV}.` + ); + } + return ( + `Sync would exceed the maximum of ${details.maxAccounts} accounts ` + + `(current ${details.currentCount}, source ${details.sourceCount}, deduped total ${details.dedupedTotal}). ` + + `Remove at least ${details.needToRemove} account(s) before syncing.` + ); +} + +export class CodexMultiAuthSyncCapacityError extends Error { + readonly details: CodexMultiAuthSyncCapacityDetails; + + constructor(details: CodexMultiAuthSyncCapacityDetails) { + super(getCodexMultiAuthCapacityErrorMessage(details)); + this.name = "CodexMultiAuthSyncCapacityError"; + this.details = details; + } +} + +export async function previewCodexMultiAuthSyncedOverlapCleanup(): Promise { + return withAccountStorageTransaction((current) => { + const fallback = current ?? { + version: 3 as const, + accounts: [], + activeIndex: 0, + activeIndexByFamily: {}, + }; + return Promise.resolve(buildCodexMultiAuthOverlapCleanupPlan(fallback).result); + }); +} + +export async function cleanupCodexMultiAuthSyncedOverlaps( + backupPath?: string, +): Promise { + return withAccountStorageTransaction(async (current, persist) => { + const fallback = current ?? { + version: 3 as const, + accounts: [], + activeIndex: 0, + activeIndexByFamily: {}, + }; + if (backupPath) { + await fs.mkdir(dirname(backupPath), { recursive: true }); + await fs.writeFile(backupPath, `${JSON.stringify(fallback, null, 2)}\n`, { + encoding: "utf-8", + mode: 0o600, + flag: "wx", + }); + } + const plan = buildCodexMultiAuthOverlapCleanupPlan(fallback); + if (plan.nextStorage) { + await persist(plan.nextStorage); + } + return plan.result; + }); +} diff --git a/lib/ui/auth-menu.ts b/lib/ui/auth-menu.ts index 12007a4e..6af6f164 100644 --- a/lib/ui/auth-menu.ts +++ b/lib/ui/auth-menu.ts @@ -28,6 +28,7 @@ export interface AccountInfo { export interface AuthMenuOptions { flaggedCount?: number; + syncFromCodexMultiAuthEnabled?: boolean; } export type AuthMenuAction = @@ -36,10 +37,13 @@ export type AuthMenuAction = | { type: "check" } | { type: "deep-check" } | { type: "verify-flagged" } + | { type: "sync-tools" } | { type: "select-account"; account: AccountInfo } | { type: "delete-all" } | { type: "cancel" }; +export type SyncToolsAction = "toggle-sync" | "sync-now" | "cleanup-overlaps" | "back" | "cancel"; + export type AccountAction = "back" | "delete" | "refresh" | "toggle" | "cancel"; function formatRelativeTime(timestamp: number | undefined): string { @@ -132,10 +136,12 @@ export async function showAuthMenu( ): Promise { const ui = getUiRuntimeOptions(); const flaggedCount = options.flaggedCount ?? 0; + const syncEnabled = options.syncFromCodexMultiAuthEnabled === true; const verifyLabel = flaggedCount > 0 ? `Verify flagged accounts (${flaggedCount})` : "Verify flagged accounts"; + const syncLabel = syncEnabled ? "Sync tools [enabled]" : "Sync tools [disabled]"; const items: MenuItem[] = [ { label: "Actions", value: { type: "cancel" }, kind: "heading" }, @@ -143,6 +149,7 @@ export async function showAuthMenu( { label: "Check quotas", value: { type: "check" }, color: "cyan" }, { label: "Deep check accounts", value: { type: "deep-check" }, color: "cyan" }, { label: verifyLabel, value: { type: "verify-flagged" }, color: "cyan" }, + { label: syncLabel, value: { type: "sync-tools" }, color: syncEnabled ? "green" : "yellow" }, { label: "Start fresh", value: { type: "fresh" }, color: "yellow" }, { label: "", value: { type: "cancel" }, separator: true }, { label: "Accounts", value: { type: "cancel" }, kind: "heading" }, @@ -186,6 +193,31 @@ export async function showAuthMenu( } } +export async function showSyncToolsMenu(syncEnabled: boolean): Promise { + const ui = getUiRuntimeOptions(); + const action = await select( + [ + { + label: syncEnabled ? "Disable sync from codex-multi-auth" : "Enable sync from codex-multi-auth", + value: "toggle-sync", + color: syncEnabled ? "yellow" : "green", + }, + { label: "Sync now", value: "sync-now", color: "cyan" }, + { label: "Cleanup synced overlaps", value: "cleanup-overlaps", color: "cyan" }, + { label: "Back", value: "back" }, + ], + { + message: "Sync tools", + subtitle: syncEnabled ? "codex-multi-auth sync enabled" : "codex-multi-auth sync disabled", + clearScreen: true, + variant: ui.v2Enabled ? "codex" : "legacy", + theme: ui.theme, + }, + ); + + return action ?? "cancel"; +} + export async function showAccountDetails(account: AccountInfo): Promise { const ui = getUiRuntimeOptions(); const header = diff --git a/test/codex-multi-auth-sync.test.ts b/test/codex-multi-auth-sync.test.ts new file mode 100644 index 00000000..87b0a139 --- /dev/null +++ b/test/codex-multi-auth-sync.test.ts @@ -0,0 +1,1798 @@ +import { beforeEach, afterEach, describe, expect, it, vi } from "vitest"; +import * as fs from "node:fs"; +import * as os from "node:os"; +import { join, win32 as pathWin32 } from "node:path"; +import { findProjectRoot, getProjectStorageKey } from "../lib/storage/paths.js"; +import type { AccountStorageV3 } from "../lib/storage.js"; + +vi.mock("../lib/logger.js", () => ({ + logWarn: vi.fn(), +})); + +vi.mock("node:fs", async () => { + const actual = await vi.importActual("node:fs"); + return { + ...actual, + existsSync: vi.fn(), + readdirSync: vi.fn(() => []), + readFileSync: vi.fn(), + statSync: vi.fn(), + }; +}); + +vi.mock("../lib/storage.js", () => ({ + deduplicateAccounts: vi.fn((accounts) => accounts), + deduplicateAccountsByEmail: vi.fn((accounts) => accounts), + getStoragePath: vi.fn(() => "/tmp/opencode-accounts.json"), + loadAccounts: vi.fn(async () => ({ + version: 3, + activeIndex: 0, + activeIndexByFamily: {}, + accounts: [ + { + accountId: "org-example123", + accountIdSource: "org", + refreshToken: "sync-refresh", + addedAt: 1, + lastUsed: 1, + }, + { + accountId: "org-example123", + organizationId: "org-example123", + accountIdSource: "org", + refreshToken: "sync-refresh", + addedAt: 2, + lastUsed: 2, + }, + ], + })), + saveAccounts: vi.fn(async () => {}), + clearAccounts: vi.fn(async () => {}), + previewImportAccounts: vi.fn(async () => ({ imported: 2, skipped: 0, total: 4 })), + previewImportAccountsWithExistingStorage: vi.fn(async () => ({ imported: 2, skipped: 0, total: 4 })), + importAccounts: vi.fn(async () => ({ + imported: 2, + skipped: 0, + total: 4, + backupStatus: "created", + backupPath: "/tmp/codex-multi-auth-sync-backup.json", + })), + normalizeAccountStorage: vi.fn((value: unknown) => value), + withAccountStorageTransaction: vi.fn(async (handler) => + handler( + { + version: 3, + activeIndex: 0, + activeIndexByFamily: {}, + accounts: [ + { + accountId: "org-example123", + accountIdSource: "org", + refreshToken: "sync-refresh", + addedAt: 1, + lastUsed: 1, + }, + { + accountId: "org-example123", + organizationId: "org-example123", + accountIdSource: "org", + refreshToken: "sync-refresh", + addedAt: 2, + lastUsed: 2, + }, + ], + }, + vi.fn(async () => {}), + ), + ), +})); + +describe("codex-multi-auth sync", () => { + const mockExistsSync = vi.mocked(fs.existsSync); + const mockReaddirSync = vi.mocked(fs.readdirSync); + const mockReadFileSync = vi.mocked(fs.readFileSync); + const mockStatSync = vi.mocked(fs.statSync); + const originalReadFile = fs.promises.readFile.bind(fs.promises); + const mockReadFile = vi.spyOn(fs.promises, "readFile"); + const originalEnv = { + CODEX_MULTI_AUTH_DIR: process.env.CODEX_MULTI_AUTH_DIR, + CODEX_HOME: process.env.CODEX_HOME, + USERPROFILE: process.env.USERPROFILE, + HOME: process.env.HOME, + }; + const mockSourceStorageFile = (expectedPath: string, content: string) => { + mockReadFile.mockImplementation(async (filePath, options) => { + if (String(filePath) === expectedPath) { + return content; + } + return originalReadFile( + filePath as Parameters[0], + options as never, + ); + }); + }; + const defaultTransactionalStorage = (): AccountStorageV3 => ({ + version: 3, + activeIndex: 0, + activeIndexByFamily: {}, + accounts: [ + { + accountId: "org-example123", + accountIdSource: "org", + refreshToken: "sync-refresh", + addedAt: 1, + lastUsed: 1, + }, + { + accountId: "org-example123", + organizationId: "org-example123", + accountIdSource: "org", + refreshToken: "sync-refresh", + addedAt: 2, + lastUsed: 2, + }, + ], + }); + + beforeEach(async () => { + vi.resetModules(); + vi.clearAllMocks(); + mockExistsSync.mockReset(); + mockExistsSync.mockReturnValue(false); + mockReaddirSync.mockReset(); + mockReaddirSync.mockReturnValue([] as ReturnType); + mockReadFileSync.mockReset(); + mockReadFileSync.mockImplementation((candidate) => { + throw new Error(`unexpected read: ${String(candidate)}`); + }); + mockStatSync.mockReset(); + mockStatSync.mockImplementation(() => ({ + isDirectory: () => false, + }) as ReturnType); + mockReadFile.mockReset(); + mockReadFile.mockImplementation((path, options) => + originalReadFile(path as Parameters[0], options as never), + ); + const storageModule = await import("../lib/storage.js"); + vi.mocked(storageModule.previewImportAccounts).mockReset(); + vi.mocked(storageModule.previewImportAccounts).mockResolvedValue({ imported: 2, skipped: 0, total: 4 }); + vi.mocked(storageModule.previewImportAccountsWithExistingStorage).mockReset(); + vi.mocked(storageModule.previewImportAccountsWithExistingStorage).mockResolvedValue({ + imported: 2, + skipped: 0, + total: 4, + }); + vi.mocked(storageModule.importAccounts).mockReset(); + vi.mocked(storageModule.importAccounts).mockResolvedValue({ + imported: 2, + skipped: 0, + total: 4, + backupStatus: "created", + backupPath: "/tmp/codex-multi-auth-sync-backup.json", + }); + vi.mocked(storageModule.loadAccounts).mockReset(); + vi.mocked(storageModule.loadAccounts).mockResolvedValue(defaultTransactionalStorage()); + vi.mocked(storageModule.normalizeAccountStorage).mockReset(); + vi.mocked(storageModule.normalizeAccountStorage).mockImplementation((value: unknown) => value as never); + vi.mocked(storageModule.withAccountStorageTransaction).mockReset(); + vi.mocked(storageModule.withAccountStorageTransaction).mockImplementation(async (handler) => + handler(defaultTransactionalStorage(), vi.fn(async () => {})), + ); + delete process.env.CODEX_MULTI_AUTH_DIR; + delete process.env.CODEX_HOME; + }); + + afterEach(() => { + if (originalEnv.CODEX_MULTI_AUTH_DIR === undefined) delete process.env.CODEX_MULTI_AUTH_DIR; + else process.env.CODEX_MULTI_AUTH_DIR = originalEnv.CODEX_MULTI_AUTH_DIR; + if (originalEnv.CODEX_HOME === undefined) delete process.env.CODEX_HOME; + else process.env.CODEX_HOME = originalEnv.CODEX_HOME; + if (originalEnv.USERPROFILE === undefined) delete process.env.USERPROFILE; + else process.env.USERPROFILE = originalEnv.USERPROFILE; + if (originalEnv.HOME === undefined) delete process.env.HOME; + else process.env.HOME = originalEnv.HOME; + delete process.env.CODEX_AUTH_SYNC_MAX_ACCOUNTS; + }); + + it("prefers a project-scoped codex-multi-auth accounts file when present", async () => { + const rootDir = join(process.cwd(), ".tmp-codex-multi-auth"); + process.env.CODEX_MULTI_AUTH_DIR = rootDir; + const projectKey = "fixed-test-project-key"; + vi.spyOn(await import("../lib/storage/paths.js"), "getProjectStorageKey").mockReturnValue(projectKey); + const projectPath = join(rootDir, "projects", projectKey, "openai-codex-accounts.json"); + const globalPath = join(rootDir, "openai-codex-accounts.json"); + const repoPackageJson = join(process.cwd(), "package.json"); + + mockExistsSync.mockImplementation((candidate) => { + return ( + String(candidate) === projectPath || + String(candidate) === globalPath || + String(candidate) === repoPackageJson + ); + }); + + const { resolveCodexMultiAuthAccountsSource } = await import("../lib/codex-multi-auth-sync.js"); + const resolved = resolveCodexMultiAuthAccountsSource(process.cwd()); + + expect(resolved).toEqual({ + rootDir, + accountsPath: projectPath, + scope: "project", + }); + }); + + it("falls back to the global accounts file when no project-scoped file exists", async () => { + const rootDir = join(process.cwd(), ".tmp-codex-multi-auth"); + process.env.CODEX_MULTI_AUTH_DIR = rootDir; + const globalPath = join(rootDir, "openai-codex-accounts.json"); + mockExistsSync.mockImplementation((candidate) => String(candidate) === globalPath); + + const { resolveCodexMultiAuthAccountsSource } = await import("../lib/codex-multi-auth-sync.js"); + const resolved = resolveCodexMultiAuthAccountsSource(process.cwd()); + + expect(resolved).toEqual({ + rootDir, + accountsPath: globalPath, + scope: "global", + }); + }); + + it("probes the DevTools fallback root when no env override is set", async () => { + process.env.USERPROFILE = "C:\\Users\\tester"; + process.env.HOME = "C:\\Users\\tester"; + const devToolsGlobalPath = pathWin32.join( + "C:\\Users\\tester", + "DevTools", + "config", + "codex", + "multi-auth", + "openai-codex-accounts.json", + ); + mockExistsSync.mockImplementation((candidate) => String(candidate) === devToolsGlobalPath); + + const { getCodexMultiAuthSourceRootDir } = await import("../lib/codex-multi-auth-sync.js"); + expect(getCodexMultiAuthSourceRootDir()).toBe( + pathWin32.join("C:\\Users\\tester", "DevTools", "config", "codex", "multi-auth"), + ); + }); + + it("prefers the DevTools root over ~/.codex when CODEX_HOME is not set", async () => { + process.env.USERPROFILE = "C:\\Users\\tester"; + process.env.HOME = "C:\\Users\\tester"; + const devToolsGlobalPath = pathWin32.join( + "C:\\Users\\tester", + "DevTools", + "config", + "codex", + "multi-auth", + "openai-codex-accounts.json", + ); + const dotCodexGlobalPath = pathWin32.join( + "C:\\Users\\tester", + ".codex", + "multi-auth", + "openai-codex-accounts.json", + ); + mockExistsSync.mockImplementation((candidate) => { + const path = String(candidate); + return path === devToolsGlobalPath || path === dotCodexGlobalPath; + }); + + const { getCodexMultiAuthSourceRootDir } = await import("../lib/codex-multi-auth-sync.js"); + expect(getCodexMultiAuthSourceRootDir()).toBe( + pathWin32.join("C:\\Users\\tester", "DevTools", "config", "codex", "multi-auth"), + ); + }); + + it("skips WAL-only roots when a later candidate has a real accounts file", async () => { + process.env.USERPROFILE = "C:\\Users\\tester"; + process.env.HOME = "C:\\Users\\tester"; + process.env.CODEX_HOME = "C:\\Users\\tester\\.codex"; + const walOnlyPath = pathWin32.join( + "C:\\Users\\tester", + ".codex", + "multi-auth", + "openai-codex-accounts.json.wal", + ); + const laterRealJson = pathWin32.join( + "C:\\Users\\tester", + "DevTools", + "config", + "codex", + "multi-auth", + "openai-codex-accounts.json", + ); + mockExistsSync.mockImplementation((candidate) => { + const path = String(candidate); + return path === walOnlyPath || path === laterRealJson; + }); + + const { getCodexMultiAuthSourceRootDir } = await import("../lib/codex-multi-auth-sync.js"); + expect(getCodexMultiAuthSourceRootDir()).toBe( + pathWin32.join("C:\\Users\\tester", "DevTools", "config", "codex", "multi-auth"), + ); + }); + + it("delegates preview and apply to the existing importer", async () => { + const rootDir = join(process.cwd(), ".tmp-codex-multi-auth"); + process.env.CODEX_MULTI_AUTH_DIR = rootDir; + const globalPath = join(rootDir, "openai-codex-accounts.json"); + mockExistsSync.mockImplementation((candidate) => String(candidate) === globalPath); + mockSourceStorageFile(globalPath, + JSON.stringify({ + version: 3, + activeIndex: 0, + accounts: [{ refreshToken: "sync-refresh", addedAt: 1, lastUsed: 1 }], + }), + ); + + const storageModule = await import("../lib/storage.js"); + const { previewSyncFromCodexMultiAuth, syncFromCodexMultiAuth } = await import("../lib/codex-multi-auth-sync.js"); + + await expect(previewSyncFromCodexMultiAuth(process.cwd())).resolves.toMatchObject({ + accountsPath: globalPath, + imported: 2, + skipped: 0, + total: 4, + }); + await expect(syncFromCodexMultiAuth(process.cwd())).resolves.toMatchObject({ + accountsPath: globalPath, + imported: 2, + backupStatus: "created", + }); + + expect(vi.mocked(storageModule.previewImportAccountsWithExistingStorage)).toHaveBeenCalledWith( + expect.stringContaining("oc-chatgpt-multi-auth-sync-"), + expect.any(Object), + ); + expect(vi.mocked(storageModule.importAccounts)).toHaveBeenCalledWith( + expect.stringContaining("oc-chatgpt-multi-auth-sync-"), + { + preImportBackupPrefix: "codex-multi-auth-sync-backup", + backupMode: "required", + }, + expect.any(Function), + ); + }); + + it("rejects CODEX_MULTI_AUTH_DIR values that are not local absolute paths on Windows", async () => { + process.env.CODEX_MULTI_AUTH_DIR = "\\\\server\\share\\multi-auth"; + process.env.USERPROFILE = "C:\\Users\\tester"; + process.env.HOME = "C:\\Users\\tester"; + + const { getCodexMultiAuthSourceRootDir } = await import("../lib/codex-multi-auth-sync.js"); + expect(() => getCodexMultiAuthSourceRootDir()).toThrow(/local absolute path/i); + }); + + it("accepts extended-length local Windows paths for CODEX_MULTI_AUTH_DIR", async () => { + process.env.USERPROFILE = "C:\\Users\\tester"; + process.env.HOME = "C:\\Users\\tester"; + + const { getCodexMultiAuthSourceRootDir } = await import("../lib/codex-multi-auth-sync.js"); + + process.env.CODEX_MULTI_AUTH_DIR = "\\\\?\\C:\\Users\\tester\\multi-auth"; + expect(getCodexMultiAuthSourceRootDir()).toBe("\\\\?\\C:\\Users\\tester\\multi-auth"); + + process.env.CODEX_MULTI_AUTH_DIR = "\\\\.\\C:\\Users\\tester\\multi-auth"; + expect(getCodexMultiAuthSourceRootDir()).toBe("\\\\.\\C:\\Users\\tester\\multi-auth"); + }); + + it("rejects extended UNC Windows paths for CODEX_MULTI_AUTH_DIR", async () => { + process.env.CODEX_MULTI_AUTH_DIR = "\\\\?\\UNC\\server\\share\\multi-auth"; + process.env.USERPROFILE = "C:\\Users\\tester"; + process.env.HOME = "C:\\Users\\tester"; + + const { getCodexMultiAuthSourceRootDir } = await import("../lib/codex-multi-auth-sync.js"); + expect(() => getCodexMultiAuthSourceRootDir()).toThrow(/UNC network share/i); + }); + + it("keeps preview sync on the read-only path without the storage transaction lock", async () => { + const rootDir = join(process.cwd(), ".tmp-codex-multi-auth"); + process.env.CODEX_MULTI_AUTH_DIR = rootDir; + const globalPath = join(rootDir, "openai-codex-accounts.json"); + mockExistsSync.mockImplementation((candidate) => String(candidate) === globalPath); + mockSourceStorageFile( + globalPath, + JSON.stringify({ + version: 3, + activeIndex: 0, + activeIndexByFamily: {}, + accounts: [{ refreshToken: "sync-refresh", addedAt: 1, lastUsed: 1 }], + }), + ); + + const storageModule = await import("../lib/storage.js"); + vi.mocked(storageModule.withAccountStorageTransaction).mockImplementationOnce(async () => { + throw new Error("preview should not take write transaction lock"); + }); + + const { previewSyncFromCodexMultiAuth } = await import("../lib/codex-multi-auth-sync.js"); + await expect(previewSyncFromCodexMultiAuth(process.cwd())).resolves.toMatchObject({ + accountsPath: globalPath, + imported: 2, + }); + }); + + it("takes the same transaction-backed path for overlap cleanup preview as cleanup", async () => { + const storageModule = await import("../lib/storage.js"); + vi.mocked(storageModule.withAccountStorageTransaction).mockImplementationOnce(async (handler) => + handler( + { + version: 3, + activeIndex: 0, + activeIndexByFamily: {}, + accounts: [ + { + accountId: "org-sync", + organizationId: "org-sync", + accountIdSource: "org", + accountTags: ["codex-multi-auth-sync"], + refreshToken: "sync-token", + addedAt: 2, + lastUsed: 2, + }, + ], + }, + vi.fn(async () => {}), + ), + ); + vi.mocked(storageModule.loadAccounts).mockResolvedValue({ + version: 3, + activeIndex: 0, + activeIndexByFamily: {}, + accounts: [ + { + accountId: "org-sync", + organizationId: "org-sync", + accountIdSource: "org", + accountTags: ["codex-multi-auth-sync"], + refreshToken: "sync-token", + addedAt: 2, + lastUsed: 2, + }, + ], + }); + + const { previewCodexMultiAuthSyncedOverlapCleanup } = await import("../lib/codex-multi-auth-sync.js"); + await expect(previewCodexMultiAuthSyncedOverlapCleanup()).resolves.toEqual({ + before: 1, + after: 1, + removed: 0, + updated: 0, + }); + expect(storageModule.withAccountStorageTransaction).toHaveBeenCalledTimes(1); + expect(storageModule.loadAccounts).not.toHaveBeenCalled(); + }); + + it("uses a single account snapshot for preview capacity filtering and preview counts", async () => { + const rootDir = join(process.cwd(), ".tmp-codex-multi-auth"); + process.env.CODEX_MULTI_AUTH_DIR = rootDir; + const globalPath = join(rootDir, "openai-codex-accounts.json"); + mockExistsSync.mockImplementation((candidate) => String(candidate) === globalPath); + mockSourceStorageFile( + globalPath, + JSON.stringify({ + version: 3, + activeIndex: 0, + activeIndexByFamily: {}, + accounts: [ + { email: "existing@example.com", refreshToken: "rt-source-1", addedAt: 1, lastUsed: 1 }, + { email: "new@example.com", refreshToken: "rt-source-2", addedAt: 2, lastUsed: 2 }, + ], + }), + ); + + const storageModule = await import("../lib/storage.js"); + const firstSnapshot = { + version: 3 as const, + activeIndex: 0, + activeIndexByFamily: {}, + accounts: [ + { + email: "existing@example.com", + refreshToken: "rt-existing", + addedAt: 10, + lastUsed: 10, + }, + ], + }; + const secondSnapshot = { + version: 3 as const, + activeIndex: 0, + activeIndexByFamily: {}, + accounts: [], + }; + vi.mocked(storageModule.loadAccounts) + .mockResolvedValueOnce(firstSnapshot) + .mockResolvedValueOnce(secondSnapshot); + vi.mocked(storageModule.previewImportAccountsWithExistingStorage).mockImplementationOnce(async (_filePath, existing) => { + expect(existing).toBe(firstSnapshot); + return { imported: 1, skipped: 0, total: 2 }; + }); + + const { previewSyncFromCodexMultiAuth } = await import("../lib/codex-multi-auth-sync.js"); + await expect(previewSyncFromCodexMultiAuth(process.cwd())).resolves.toMatchObject({ + accountsPath: globalPath, + imported: 1, + total: 2, + }); + expect(vi.mocked(storageModule.loadAccounts)).toHaveBeenCalledTimes(1); + }); + + it("reuses a previewed source snapshot during sync even if the source file changes later", async () => { + const rootDir = join(process.cwd(), ".tmp-codex-multi-auth"); + process.env.CODEX_MULTI_AUTH_DIR = rootDir; + const globalPath = join(rootDir, "openai-codex-accounts.json"); + mockExistsSync.mockImplementation((candidate) => String(candidate) === globalPath); + mockSourceStorageFile( + globalPath, + JSON.stringify({ + version: 3, + activeIndex: 0, + activeIndexByFamily: {}, + accounts: [ + { accountId: "org-source-1", organizationId: "org-source-1", accountIdSource: "org", refreshToken: "rt-source-1", addedAt: 1, lastUsed: 1 }, + ], + }), + ); + + const storageModule = await import("../lib/storage.js"); + const syncModule = await import("../lib/codex-multi-auth-sync.js"); + const loadedSource = await syncModule.loadCodexMultiAuthSourceStorage(process.cwd()); + + mockSourceStorageFile( + globalPath, + JSON.stringify({ + version: 3, + activeIndex: 0, + activeIndexByFamily: {}, + accounts: [ + { accountId: "org-source-1", organizationId: "org-source-1", accountIdSource: "org", refreshToken: "rt-source-1", addedAt: 1, lastUsed: 1 }, + { accountId: "org-source-2", organizationId: "org-source-2", accountIdSource: "org", refreshToken: "rt-source-2", addedAt: 2, lastUsed: 2 }, + ], + }), + ); + + vi.mocked(storageModule.previewImportAccountsWithExistingStorage).mockImplementationOnce(async (filePath) => { + const raw = await fs.promises.readFile(filePath, "utf8"); + const parsed = JSON.parse(raw) as { accounts: Array<{ refreshToken?: string }> }; + expect(parsed.accounts).toHaveLength(1); + expect(parsed.accounts[0]?.refreshToken).toBe("rt-source-1"); + return { imported: 1, skipped: 0, total: 1 }; + }); + vi.mocked(storageModule.importAccounts).mockImplementationOnce(async (filePath) => { + const raw = await fs.promises.readFile(filePath, "utf8"); + const parsed = JSON.parse(raw) as { accounts: Array<{ refreshToken?: string }> }; + expect(parsed.accounts).toHaveLength(1); + expect(parsed.accounts[0]?.refreshToken).toBe("rt-source-1"); + return { + imported: 1, + skipped: 0, + total: 1, + backupStatus: "created", + backupPath: "/tmp/codex-multi-auth-sync-backup.json", + }; + }); + + await expect(syncModule.previewSyncFromCodexMultiAuth(process.cwd(), loadedSource)).resolves.toMatchObject({ + accountsPath: globalPath, + imported: 1, + total: 1, + }); + await expect(syncModule.syncFromCodexMultiAuth(process.cwd(), loadedSource)).resolves.toMatchObject({ + accountsPath: globalPath, + imported: 1, + backupStatus: "created", + }); + }); + + it("uses the locked transaction snapshot for overlap preview", async () => { + const storageModule = await import("../lib/storage.js"); + vi.mocked(storageModule.deduplicateAccounts).mockImplementationOnce((accounts) => + accounts.length > 1 ? [accounts[0] ?? accounts[1]].filter(Boolean) : accounts, + ); + vi.mocked(storageModule.withAccountStorageTransaction).mockImplementationOnce(async (handler) => + handler( + { + version: 3, + activeIndex: 0, + activeIndexByFamily: {}, + accounts: [ + { + accountId: "org-sync", + organizationId: "org-sync", + accountIdSource: "org", + accountTags: ["codex-multi-auth-sync"], + email: "sync@example.com", + refreshToken: "sync-token", + addedAt: 2, + lastUsed: 2, + }, + { + accountId: "org-sync", + accountIdSource: "org", + email: "sync@example.com", + refreshToken: "sync-token", + addedAt: 1, + lastUsed: 1, + }, + ], + }, + vi.fn(async () => {}), + ), + ); + + const { previewCodexMultiAuthSyncedOverlapCleanup } = await import("../lib/codex-multi-auth-sync.js"); + await expect(previewCodexMultiAuthSyncedOverlapCleanup()).resolves.toEqual({ + before: 2, + after: 2, + removed: 0, + updated: 0, + }); + expect(storageModule.withAccountStorageTransaction).toHaveBeenCalledTimes(1); + expect(storageModule.loadAccounts).not.toHaveBeenCalled(); + }); + + it("does not retry through a fallback temp directory when the handler throws", async () => { + const rootDir = join(process.cwd(), ".tmp-codex-multi-auth"); + process.env.CODEX_MULTI_AUTH_DIR = rootDir; + const globalPath = join(rootDir, "openai-codex-accounts.json"); + mockExistsSync.mockImplementation((candidate) => String(candidate) === globalPath); + mockSourceStorageFile(globalPath, + JSON.stringify({ + version: 3, + activeIndex: 0, + accounts: [{ refreshToken: "sync-refresh", addedAt: 1, lastUsed: 1 }], + }), + ); + + const storageModule = await import("../lib/storage.js"); + vi.mocked(storageModule.previewImportAccountsWithExistingStorage).mockRejectedValueOnce( + new Error("preview failed"), + ); + + const { previewSyncFromCodexMultiAuth } = await import("../lib/codex-multi-auth-sync.js"); + await expect(previewSyncFromCodexMultiAuth(process.cwd())).rejects.toThrow("preview failed"); + expect(vi.mocked(storageModule.previewImportAccountsWithExistingStorage)).toHaveBeenCalledTimes(1); + }); + + it("surfaces secure temp directory creation failures instead of falling back to system tmpdir", async () => { + const rootDir = join(process.cwd(), ".tmp-codex-multi-auth"); + process.env.CODEX_MULTI_AUTH_DIR = rootDir; + const globalPath = join(rootDir, "openai-codex-accounts.json"); + mockExistsSync.mockImplementation((candidate) => String(candidate) === globalPath); + mockSourceStorageFile(globalPath, + JSON.stringify({ + version: 3, + activeIndex: 0, + accounts: [{ refreshToken: "sync-refresh", addedAt: 1, lastUsed: 1 }], + }), + ); + + const mkdtempSpy = vi.spyOn(fs.promises, "mkdtemp").mockRejectedValue(new Error("mkdtemp failed")); + const storageModule = await import("../lib/storage.js"); + + try { + const { previewSyncFromCodexMultiAuth } = await import("../lib/codex-multi-auth-sync.js"); + await expect(previewSyncFromCodexMultiAuth(process.cwd())).rejects.toThrow("mkdtemp failed"); + expect(mkdtempSpy).toHaveBeenCalledTimes(1); + expect(vi.mocked(storageModule.previewImportAccountsWithExistingStorage)).not.toHaveBeenCalled(); + } finally { + mkdtempSpy.mockRestore(); + } + }); + + it("warns instead of failing when secure temp cleanup blocks preview cleanup", async () => { + const rootDir = join(process.cwd(), ".tmp-codex-multi-auth"); + process.env.CODEX_MULTI_AUTH_DIR = rootDir; + const globalPath = join(rootDir, "openai-codex-accounts.json"); + mockExistsSync.mockImplementation((candidate) => String(candidate) === globalPath); + mockSourceStorageFile(globalPath, + JSON.stringify({ + version: 3, + activeIndex: 0, + accounts: [{ refreshToken: "sync-refresh", addedAt: 1, lastUsed: 1 }], + }), + ); + + const rmSpy = vi.spyOn(fs.promises, "rm").mockRejectedValue(new Error("cleanup blocked")); + const loggerModule = await import("../lib/logger.js"); + + try { + const { previewSyncFromCodexMultiAuth } = await import("../lib/codex-multi-auth-sync.js"); + await expect(previewSyncFromCodexMultiAuth(process.cwd())).resolves.toMatchObject({ + accountsPath: globalPath, + imported: 2, + skipped: 0, + total: 4, + }); + expect(vi.mocked(loggerModule.logWarn)).toHaveBeenCalledWith( + expect.stringContaining("Failed to remove temporary codex sync directory"), + ); + } finally { + rmSpy.mockRestore(); + } + }); + + it.each(["EACCES", "EPERM"] as const)( + "retries Windows-style %s temp cleanup locks until they clear", + async (code) => { + const rootDir = join(process.cwd(), ".tmp-codex-multi-auth"); + process.env.CODEX_MULTI_AUTH_DIR = rootDir; + const globalPath = join(rootDir, "openai-codex-accounts.json"); + mockExistsSync.mockImplementation((candidate) => String(candidate) === globalPath); + mockSourceStorageFile( + globalPath, + JSON.stringify({ + version: 3, + activeIndex: 0, + activeIndexByFamily: {}, + accounts: [{ refreshToken: "sync-refresh", addedAt: 1, lastUsed: 1 }], + }), + ); + + const rmSpy = vi + .spyOn(fs.promises, "rm") + .mockRejectedValueOnce(Object.assign(new Error("permission denied"), { code })) + .mockRejectedValueOnce(Object.assign(new Error("permission denied"), { code })) + .mockResolvedValueOnce(undefined); + const loggerModule = await import("../lib/logger.js"); + + try { + const { previewSyncFromCodexMultiAuth } = await import("../lib/codex-multi-auth-sync.js"); + await expect(previewSyncFromCodexMultiAuth(process.cwd())).resolves.toMatchObject({ + accountsPath: globalPath, + imported: 2, + skipped: 0, + total: 4, + }); + expect(rmSpy).toHaveBeenCalledTimes(3); + expect(vi.mocked(loggerModule.logWarn)).not.toHaveBeenCalledWith( + expect.stringContaining("Failed to remove temporary codex sync directory"), + ); + } finally { + rmSpy.mockRestore(); + } + }, + ); + + it("fails fast on non-retryable temp cleanup errors", async () => { + const rootDir = join(process.cwd(), ".tmp-codex-multi-auth"); + process.env.CODEX_MULTI_AUTH_DIR = rootDir; + const globalPath = join(rootDir, "openai-codex-accounts.json"); + mockExistsSync.mockImplementation((candidate) => String(candidate) === globalPath); + mockSourceStorageFile( + globalPath, + JSON.stringify({ + version: 3, + activeIndex: 0, + activeIndexByFamily: {}, + accounts: [{ refreshToken: "sync-refresh", addedAt: 1, lastUsed: 1 }], + }), + ); + + const rmSpy = vi + .spyOn(fs.promises, "rm") + .mockRejectedValue(Object.assign(new Error("invalid temp dir"), { code: "EINVAL" })); + const loggerModule = await import("../lib/logger.js"); + + try { + const { previewSyncFromCodexMultiAuth } = await import("../lib/codex-multi-auth-sync.js"); + await expect(previewSyncFromCodexMultiAuth(process.cwd())).resolves.toMatchObject({ + accountsPath: globalPath, + imported: 2, + skipped: 0, + total: 4, + }); + expect(rmSpy.mock.calls.length).toBeGreaterThanOrEqual(1); + expect(vi.mocked(loggerModule.logWarn)).toHaveBeenCalledWith( + expect.stringContaining("Failed to remove temporary codex sync directory"), + ); + } finally { + rmSpy.mockRestore(); + } + }); + + it("retries Windows-style EBUSY temp cleanup until it succeeds", async () => { + const rootDir = join(process.cwd(), ".tmp-codex-multi-auth"); + process.env.CODEX_MULTI_AUTH_DIR = rootDir; + const globalPath = join(rootDir, "openai-codex-accounts.json"); + mockExistsSync.mockImplementation((candidate) => String(candidate) === globalPath); + mockSourceStorageFile( + globalPath, + JSON.stringify({ + version: 3, + activeIndex: 0, + activeIndexByFamily: {}, + accounts: [{ refreshToken: "sync-refresh", addedAt: 1, lastUsed: 1 }], + }), + ); + + const rmSpy = vi + .spyOn(fs.promises, "rm") + .mockRejectedValueOnce(Object.assign(new Error("busy"), { code: "EBUSY" })) + .mockRejectedValueOnce(Object.assign(new Error("busy"), { code: "EBUSY" })) + .mockResolvedValueOnce(undefined); + const loggerModule = await import("../lib/logger.js"); + + try { + const { previewSyncFromCodexMultiAuth } = await import("../lib/codex-multi-auth-sync.js"); + await expect(previewSyncFromCodexMultiAuth(process.cwd())).resolves.toMatchObject({ + accountsPath: globalPath, + imported: 2, + skipped: 0, + total: 4, + }); + expect(rmSpy).toHaveBeenCalledTimes(3); + expect(vi.mocked(loggerModule.logWarn)).not.toHaveBeenCalledWith( + expect.stringContaining("Failed to remove temporary codex sync directory"), + ); + } finally { + rmSpy.mockRestore(); + } + }); + + it.each(["EACCES", "EPERM", "EBUSY"] as const)( + "redacts temp tokens before warning when Windows-style %s cleanup exhausts retries", + async (code) => { + const rootDir = join(process.cwd(), ".tmp-codex-multi-auth"); + const fakeHome = await fs.promises.mkdtemp(join(os.tmpdir(), "codex-sync-home-")); + process.env.CODEX_MULTI_AUTH_DIR = rootDir; + process.env.HOME = fakeHome; + process.env.USERPROFILE = fakeHome; + const globalPath = join(rootDir, "openai-codex-accounts.json"); + const tempRoot = join(fakeHome, ".opencode", "tmp"); + mockExistsSync.mockImplementation((candidate) => String(candidate) === globalPath); + mockSourceStorageFile( + globalPath, + JSON.stringify({ + version: 3, + activeIndex: 0, + activeIndexByFamily: {}, + accounts: [ + { + refreshToken: "sync-refresh-secret", + accessToken: "sync-access-secret", + idToken: "sync-id-secret", + addedAt: 1, + lastUsed: 1, + }, + ], + }), + ); + + const rmSpy = vi + .spyOn(fs.promises, "rm") + .mockRejectedValue(Object.assign(new Error("cleanup still locked"), { code })); + const loggerModule = await import("../lib/logger.js"); + + try { + const { previewSyncFromCodexMultiAuth } = await import("../lib/codex-multi-auth-sync.js"); + await expect(previewSyncFromCodexMultiAuth(process.cwd())).resolves.toMatchObject({ + accountsPath: globalPath, + imported: 2, + skipped: 0, + total: 4, + }); + expect(rmSpy).toHaveBeenCalledTimes(4); + expect(vi.mocked(loggerModule.logWarn)).toHaveBeenCalledWith( + expect.stringContaining("Failed to remove temporary codex sync directory"), + ); + + const tempEntries = await fs.promises.readdir(tempRoot, { withFileTypes: true }); + const syncDir = tempEntries.find( + (entry) => entry.isDirectory() && entry.name.startsWith("oc-chatgpt-multi-auth-sync-"), + ); + expect(syncDir).toBeDefined(); + const leakedTempPath = join(tempRoot, syncDir!.name, "accounts.json"); + const leakedContent = await fs.promises.readFile(leakedTempPath, "utf8"); + expect(leakedContent).not.toContain("sync-refresh-secret"); + expect(leakedContent).not.toContain("sync-access-secret"); + expect(leakedContent).not.toContain("sync-id-secret"); + expect(leakedContent).toContain("__redacted__"); + } finally { + rmSpy.mockRestore(); + await fs.promises.rm(fakeHome, { recursive: true, force: true }); + } + }, + ); + + + it("warns and returns preview results when secure temp cleanup leaves sync data on disk", async () => { + const rootDir = join(process.cwd(), ".tmp-codex-multi-auth"); + process.env.CODEX_MULTI_AUTH_DIR = rootDir; + const globalPath = join(rootDir, "openai-codex-accounts.json"); + mockExistsSync.mockImplementation((candidate) => String(candidate) === globalPath); + mockSourceStorageFile( + globalPath, + JSON.stringify({ + version: 3, + activeIndex: 0, + accounts: [{ refreshToken: "sync-refresh", addedAt: 1, lastUsed: 1 }], + }), + ); + + const rmSpy = vi.spyOn(fs.promises, "rm").mockRejectedValue(new Error("cleanup blocked")); + + try { + const { previewSyncFromCodexMultiAuth } = await import("../lib/codex-multi-auth-sync.js"); + await expect(previewSyncFromCodexMultiAuth(process.cwd())).resolves.toMatchObject({ + accountsPath: globalPath, + imported: 2, + skipped: 0, + total: 4, + }); + } finally { + rmSpy.mockRestore(); + } + }); + + it("sweeps stale sync temp directories before creating a new import temp dir", async () => { + const rootDir = join(process.cwd(), ".tmp-codex-multi-auth"); + const fakeHome = await fs.promises.mkdtemp(join(os.tmpdir(), "codex-sync-home-")); + process.env.CODEX_MULTI_AUTH_DIR = rootDir; + process.env.HOME = fakeHome; + process.env.USERPROFILE = fakeHome; + const globalPath = join(rootDir, "openai-codex-accounts.json"); + const tempRoot = join(fakeHome, ".opencode", "tmp"); + const staleDir = join(tempRoot, "oc-chatgpt-multi-auth-sync-stale-test"); + const staleFile = join(staleDir, "accounts.json"); + const recentDir = join(tempRoot, "oc-chatgpt-multi-auth-sync-recent-test"); + const recentFile = join(recentDir, "accounts.json"); + mockExistsSync.mockImplementation((candidate) => String(candidate) === globalPath); + mockSourceStorageFile( + globalPath, + JSON.stringify({ + version: 3, + activeIndex: 0, + activeIndexByFamily: {}, + accounts: [{ accountId: "org-source", organizationId: "org-source", refreshToken: "rt-source", addedAt: 1, lastUsed: 1 }], + }), + ); + + try { + await fs.promises.mkdir(staleDir, { recursive: true }); + await fs.promises.writeFile(staleFile, "sensitive", "utf8"); + await fs.promises.mkdir(recentDir, { recursive: true }); + await fs.promises.writeFile(recentFile, "recent", "utf8"); + const oldTime = new Date(Date.now() - (15 * 60 * 1000)); + const recentTime = new Date(Date.now() - (2 * 60 * 1000)); + await fs.promises.utimes(staleDir, oldTime, oldTime); + await fs.promises.utimes(staleFile, oldTime, oldTime); + await fs.promises.utimes(recentDir, recentTime, recentTime); + await fs.promises.utimes(recentFile, recentTime, recentTime); + + const { previewSyncFromCodexMultiAuth } = await import("../lib/codex-multi-auth-sync.js"); + await expect(previewSyncFromCodexMultiAuth(process.cwd())).resolves.toMatchObject({ + rootDir, + accountsPath: globalPath, + scope: "global", + }); + + await expect(fs.promises.stat(staleDir)).rejects.toThrow(); + await expect(fs.promises.stat(recentDir)).resolves.toBeTruthy(); + } finally { + await fs.promises.rm(fakeHome, { recursive: true, force: true }); + } + }); + + it("retries stale temp sweep once on transient Windows lock errors", async () => { + const rootDir = join(process.cwd(), ".tmp-codex-multi-auth"); + const fakeHome = await fs.promises.mkdtemp(join(os.tmpdir(), "codex-sync-home-")); + process.env.CODEX_MULTI_AUTH_DIR = rootDir; + process.env.HOME = fakeHome; + process.env.USERPROFILE = fakeHome; + const globalPath = join(rootDir, "openai-codex-accounts.json"); + const tempRoot = join(fakeHome, ".opencode", "tmp"); + const staleDir = join(tempRoot, "oc-chatgpt-multi-auth-sync-stale-retry-test"); + const staleFile = join(staleDir, "accounts.json"); + mockExistsSync.mockImplementation((candidate) => String(candidate) === globalPath); + mockSourceStorageFile( + globalPath, + JSON.stringify({ + version: 3, + activeIndex: 0, + activeIndexByFamily: {}, + accounts: [{ accountId: "org-source", organizationId: "org-source", refreshToken: "rt-source", addedAt: 1, lastUsed: 1 }], + }), + ); + + const originalRm = fs.promises.rm.bind(fs.promises); + let staleSweepBlocked = false; + const rmSpy = vi.spyOn(fs.promises, "rm").mockImplementation(async (path, options) => { + if (!staleSweepBlocked && String(path) === staleDir) { + staleSweepBlocked = true; + throw Object.assign(new Error("busy"), { code: "EBUSY" }); + } + return originalRm(path, options as never); + }); + const loggerModule = await import("../lib/logger.js"); + + try { + await fs.promises.mkdir(staleDir, { recursive: true }); + await fs.promises.writeFile(staleFile, "sensitive", "utf8"); + const oldTime = new Date(Date.now() - (15 * 60 * 1000)); + await fs.promises.utimes(staleDir, oldTime, oldTime); + await fs.promises.utimes(staleFile, oldTime, oldTime); + + const { previewSyncFromCodexMultiAuth } = await import("../lib/codex-multi-auth-sync.js"); + await expect(previewSyncFromCodexMultiAuth(process.cwd())).resolves.toMatchObject({ + rootDir, + accountsPath: globalPath, + scope: "global", + }); + + expect(staleSweepBlocked).toBe(true); + expect(rmSpy.mock.calls.filter(([path]) => String(path) === staleDir)).toHaveLength(2); + expect(vi.mocked(loggerModule.logWarn)).not.toHaveBeenCalledWith( + expect.stringContaining("Failed to sweep stale codex sync temp directory"), + ); + await expect(fs.promises.stat(staleDir)).rejects.toThrow(); + } finally { + rmSpy.mockRestore(); + await fs.promises.rm(fakeHome, { recursive: true, force: true }); + } + }); + + it("skips source accounts whose emails already exist locally during sync", async () => { + const rootDir = join(process.cwd(), ".tmp-codex-multi-auth"); + process.env.CODEX_MULTI_AUTH_DIR = rootDir; + const globalPath = join(rootDir, "openai-codex-accounts.json"); + mockExistsSync.mockImplementation((candidate) => String(candidate) === globalPath); + mockSourceStorageFile(globalPath, + JSON.stringify({ + version: 3, + activeIndex: 0, + activeIndexByFamily: {}, + accounts: [ + { + email: "shared@example.com", + refreshToken: "rt-shared-a", + addedAt: 1, + lastUsed: 1, + }, + { + email: "shared@example.com", + refreshToken: "rt-shared-b", + addedAt: 2, + lastUsed: 2, + }, + { + accountId: "org-new", + organizationId: "org-new", + accountIdSource: "org", + email: "new@example.com", + refreshToken: "rt-new", + addedAt: 3, + lastUsed: 3, + }, + ], + }), + ); + + const storageModule = await import("../lib/storage.js"); + const currentStorage = { + version: 3 as const, + activeIndex: 0, + activeIndexByFamily: {}, + accounts: [ + { + email: "shared@example.com", + refreshToken: "rt-existing", + addedAt: 10, + lastUsed: 10, + }, + ], + } satisfies AccountStorageV3; + vi.mocked(storageModule.loadAccounts).mockResolvedValue(currentStorage); + + vi.mocked(storageModule.previewImportAccountsWithExistingStorage).mockImplementationOnce(async (filePath) => { + const raw = await fs.promises.readFile(filePath, "utf8"); + const parsed = JSON.parse(raw) as { accounts: Array<{ email?: string }> }; + expect(parsed.accounts.map((account) => account.email)).toEqual([ + "new@example.com", + ]); + return { imported: 1, skipped: 0, total: 1 }; + }); + vi.mocked(storageModule.importAccounts).mockImplementationOnce(async (filePath, _options, prepare) => { + const raw = await fs.promises.readFile(filePath, "utf8"); + const parsed = JSON.parse(raw) as AccountStorageV3; + const prepared = prepare ? prepare(parsed, currentStorage) : parsed; + expect(prepared.accounts.map((account) => account.email)).toEqual([ + "new@example.com", + ]); + return { + imported: 1, + skipped: 0, + total: 1, + backupStatus: "created", + backupPath: "/tmp/filtered-sync-backup.json", + }; + }); + + const { previewSyncFromCodexMultiAuth, syncFromCodexMultiAuth } = await import("../lib/codex-multi-auth-sync.js"); + + await expect(previewSyncFromCodexMultiAuth(process.cwd())).resolves.toMatchObject({ + accountsPath: globalPath, + imported: 1, + total: 1, + skipped: 0, + }); + await expect(syncFromCodexMultiAuth(process.cwd())).resolves.toMatchObject({ + accountsPath: globalPath, + imported: 1, + total: 1, + skipped: 0, + }); + }); + + it("treats refresh tokens as case-sensitive identities during sync filtering", async () => { + const rootDir = join(process.cwd(), ".tmp-codex-multi-auth"); + process.env.CODEX_MULTI_AUTH_DIR = rootDir; + const globalPath = join(rootDir, "openai-codex-accounts.json"); + mockExistsSync.mockImplementation((candidate) => String(candidate) === globalPath); + mockSourceStorageFile( + globalPath, + JSON.stringify({ + version: 3, + activeIndex: 0, + activeIndexByFamily: {}, + accounts: [ + { + refreshToken: "abc-token", + addedAt: 1, + lastUsed: 1, + }, + ], + }), + ); + + const storageModule = await import("../lib/storage.js"); + const currentStorage = { + version: 3 as const, + activeIndex: 0, + activeIndexByFamily: {}, + accounts: [ + { + refreshToken: "ABC-token", + addedAt: 10, + lastUsed: 10, + }, + ], + } satisfies AccountStorageV3; + vi.mocked(storageModule.loadAccounts).mockResolvedValue(currentStorage); + vi.mocked(storageModule.previewImportAccountsWithExistingStorage).mockImplementationOnce(async (filePath) => { + const raw = await fs.promises.readFile(filePath, "utf8"); + const parsed = JSON.parse(raw) as { accounts: Array<{ refreshToken?: string }> }; + expect(parsed.accounts).toHaveLength(1); + expect(parsed.accounts[0]?.refreshToken).toBe("abc-token"); + return { imported: 1, skipped: 0, total: 2 }; + }); + + const { previewSyncFromCodexMultiAuth } = await import("../lib/codex-multi-auth-sync.js"); + await expect(previewSyncFromCodexMultiAuth(process.cwd())).resolves.toMatchObject({ + accountsPath: globalPath, + imported: 1, + total: 2, + skipped: 0, + }); + }); + + it("deduplicates email-less source accounts by identity before import", async () => { + const rootDir = join(process.cwd(), ".tmp-codex-multi-auth"); + process.env.CODEX_MULTI_AUTH_DIR = rootDir; + const globalPath = join(rootDir, "openai-codex-accounts.json"); + mockExistsSync.mockImplementation((candidate) => String(candidate) === globalPath); + mockSourceStorageFile(globalPath, + JSON.stringify({ + version: 3, + activeIndex: 0, + activeIndexByFamily: {}, + accounts: [ + { + accountId: "org-shared", + organizationId: "org-shared", + accountIdSource: "org", + refreshToken: "rt-shared", + addedAt: 1, + lastUsed: 1, + }, + { + accountId: "org-shared", + organizationId: "org-shared", + accountIdSource: "org", + refreshToken: "rt-shared", + addedAt: 2, + lastUsed: 2, + }, + ], + }), + ); + + const storageModule = await import("../lib/storage.js"); + vi.mocked(storageModule.deduplicateAccounts).mockImplementationOnce((accounts) => [accounts[1]]); + vi.mocked(storageModule.previewImportAccountsWithExistingStorage).mockImplementationOnce(async (filePath) => { + const raw = await fs.promises.readFile(filePath, "utf8"); + const parsed = JSON.parse(raw) as { accounts: Array<{ refreshToken?: string }> }; + expect(parsed.accounts).toHaveLength(1); + expect(parsed.accounts[0]?.refreshToken).toBe("rt-shared"); + return { imported: 1, skipped: 0, total: 1 }; + }); + + const { previewSyncFromCodexMultiAuth } = await import("../lib/codex-multi-auth-sync.js"); + await expect(previewSyncFromCodexMultiAuth(process.cwd())).resolves.toMatchObject({ + accountsPath: globalPath, + imported: 1, + total: 1, + skipped: 0, + }); + }); + + it("normalizes org-scoped source accounts to include organizationId before import", async () => { + const rootDir = join(process.cwd(), ".tmp-codex-multi-auth"); + process.env.CODEX_MULTI_AUTH_DIR = rootDir; + const globalPath = join(rootDir, "openai-codex-accounts.json"); + mockExistsSync.mockImplementation((candidate) => String(candidate) === globalPath); + mockSourceStorageFile(globalPath, + JSON.stringify({ + version: 3, + activeIndex: 0, + accounts: [ + { + accountId: "org-example123", + accountIdSource: "org", + refreshToken: "sync-refresh", + addedAt: 1, + lastUsed: 1, + }, + ], + }), + ); + + const { loadCodexMultiAuthSourceStorage } = await import("../lib/codex-multi-auth-sync.js"); + const resolved = await loadCodexMultiAuthSourceStorage(process.cwd()); + + expect(resolved.storage.accounts[0]?.organizationId).toBe("org-example123"); + }); + + it("throws for invalid JSON in the external accounts file", async () => { + const rootDir = join(process.cwd(), ".tmp-codex-multi-auth"); + process.env.CODEX_MULTI_AUTH_DIR = rootDir; + const globalPath = join(rootDir, "openai-codex-accounts.json"); + mockExistsSync.mockImplementation((candidate) => String(candidate) === globalPath); + mockSourceStorageFile(globalPath, "not valid json"); + + const { loadCodexMultiAuthSourceStorage } = await import("../lib/codex-multi-auth-sync.js"); + await expect(loadCodexMultiAuthSourceStorage(process.cwd())).rejects.toThrow(/Invalid JSON/); + }); + + it("enforces finite sync capacity override for prune-capable flows", async () => { + const rootDir = join(process.cwd(), ".tmp-codex-multi-auth"); + process.env.CODEX_MULTI_AUTH_DIR = rootDir; + process.env.CODEX_AUTH_SYNC_MAX_ACCOUNTS = "2"; + const globalPath = join(rootDir, "openai-codex-accounts.json"); + mockExistsSync.mockImplementation((candidate) => String(candidate) === globalPath); + mockSourceStorageFile(globalPath, + JSON.stringify({ + version: 3, + activeIndex: 0, + activeIndexByFamily: {}, + accounts: [ + { + accountId: "org-new-1", + organizationId: "org-new-1", + accountIdSource: "org", + email: "new-1@example.com", + refreshToken: "rt-new-1", + addedAt: 1, + lastUsed: 1, + }, + { + accountId: "org-new-2", + organizationId: "org-new-2", + accountIdSource: "org", + email: "new-2@example.com", + refreshToken: "rt-new-2", + addedAt: 2, + lastUsed: 2, + }, + ], + }), + ); + + const storageModule = await import("../lib/storage.js"); + vi.mocked(storageModule.loadAccounts).mockResolvedValue({ + version: 3, + activeIndex: 0, + activeIndexByFamily: {}, + accounts: [ + { + accountId: "org-existing", + organizationId: "org-existing", + accountIdSource: "org", + email: "existing@example.com", + refreshToken: "rt-existing", + addedAt: 10, + lastUsed: 10, + }, + ], + }); + + const { previewSyncFromCodexMultiAuth, CodexMultiAuthSyncCapacityError } = await import("../lib/codex-multi-auth-sync.js"); + await expect(previewSyncFromCodexMultiAuth(process.cwd())).rejects.toBeInstanceOf( + CodexMultiAuthSyncCapacityError, + ); + }); + + it("enforces finite sync capacity override during apply", async () => { + const rootDir = join(process.cwd(), ".tmp-codex-multi-auth"); + process.env.CODEX_MULTI_AUTH_DIR = rootDir; + process.env.CODEX_AUTH_SYNC_MAX_ACCOUNTS = "2"; + const globalPath = join(rootDir, "openai-codex-accounts.json"); + mockExistsSync.mockImplementation((candidate) => String(candidate) === globalPath); + mockSourceStorageFile( + globalPath, + JSON.stringify({ + version: 3, + activeIndex: 0, + activeIndexByFamily: {}, + accounts: [ + { + accountId: "org-new-1", + organizationId: "org-new-1", + accountIdSource: "org", + email: "new-1@example.com", + refreshToken: "rt-new-1", + addedAt: 1, + lastUsed: 1, + }, + { + accountId: "org-new-2", + organizationId: "org-new-2", + accountIdSource: "org", + email: "new-2@example.com", + refreshToken: "rt-new-2", + addedAt: 2, + lastUsed: 2, + }, + ], + }), + ); + + const storageModule = await import("../lib/storage.js"); + const currentStorage = { + version: 3 as const, + activeIndex: 0, + activeIndexByFamily: {}, + accounts: [ + { + accountId: "org-existing", + organizationId: "org-existing", + accountIdSource: "org", + email: "existing@example.com", + refreshToken: "rt-existing", + addedAt: 10, + lastUsed: 10, + }, + ], + } satisfies AccountStorageV3; + vi.mocked(storageModule.importAccounts).mockImplementationOnce(async (filePath, _options, prepare) => { + const raw = await fs.promises.readFile(filePath, "utf8"); + const parsed = JSON.parse(raw) as AccountStorageV3; + if (prepare) { + prepare(parsed, currentStorage); + } + return { + imported: 2, + skipped: 0, + total: 4, + backupStatus: "created", + backupPath: "/tmp/codex-multi-auth-sync-backup.json", + }; + }); + + const { syncFromCodexMultiAuth, CodexMultiAuthSyncCapacityError } = await import("../lib/codex-multi-auth-sync.js"); + await expect(syncFromCodexMultiAuth(process.cwd())).rejects.toBeInstanceOf( + CodexMultiAuthSyncCapacityError, + ); + }); + + it("ignores a zero sync capacity override and warns instead of disabling sync", async () => { + const rootDir = join(process.cwd(), ".tmp-codex-multi-auth"); + process.env.CODEX_MULTI_AUTH_DIR = rootDir; + process.env.CODEX_AUTH_SYNC_MAX_ACCOUNTS = "0"; + const globalPath = join(rootDir, "openai-codex-accounts.json"); + mockExistsSync.mockImplementation((candidate) => String(candidate) === globalPath); + mockSourceStorageFile( + globalPath, + JSON.stringify({ + version: 3, + activeIndex: 0, + activeIndexByFamily: {}, + accounts: [ + { + accountId: "org-new-1", + organizationId: "org-new-1", + accountIdSource: "org", + email: "new-1@example.com", + refreshToken: "rt-new-1", + addedAt: 1, + lastUsed: 1, + }, + ], + }), + ); + + const loggerModule = await import("../lib/logger.js"); + const { previewSyncFromCodexMultiAuth } = await import("../lib/codex-multi-auth-sync.js"); + + await expect(previewSyncFromCodexMultiAuth(process.cwd())).resolves.toMatchObject({ + accountsPath: globalPath, + }); + expect(vi.mocked(loggerModule.logWarn)).toHaveBeenCalledWith( + expect.stringContaining('CODEX_AUTH_SYNC_MAX_ACCOUNTS override value "0" is not a positive integer; ignoring.'), + ); + }); + + it("reports when the source alone exceeds a finite sync capacity", async () => { + const rootDir = join(process.cwd(), ".tmp-codex-multi-auth"); + process.env.CODEX_MULTI_AUTH_DIR = rootDir; + process.env.CODEX_AUTH_SYNC_MAX_ACCOUNTS = "2"; + const globalPath = join(rootDir, "openai-codex-accounts.json"); + mockExistsSync.mockImplementation((candidate) => String(candidate) === globalPath); + mockSourceStorageFile( + globalPath, + JSON.stringify({ + version: 3, + activeIndex: 0, + activeIndexByFamily: {}, + accounts: [ + { + accountId: "org-new-1", + organizationId: "org-new-1", + accountIdSource: "org", + email: "new-1@example.com", + refreshToken: "rt-new-1", + addedAt: 1, + lastUsed: 1, + }, + { + accountId: "org-new-2", + organizationId: "org-new-2", + accountIdSource: "org", + email: "new-2@example.com", + refreshToken: "rt-new-2", + addedAt: 2, + lastUsed: 2, + }, + { + accountId: "org-new-3", + organizationId: "org-new-3", + accountIdSource: "org", + email: "new-3@example.com", + refreshToken: "rt-new-3", + addedAt: 3, + lastUsed: 3, + }, + ], + }), + ); + + const storageModule = await import("../lib/storage.js"); + vi.mocked(storageModule.loadAccounts).mockResolvedValue({ + version: 3, + activeIndex: 0, + activeIndexByFamily: {}, + accounts: [], + }); + + const { previewSyncFromCodexMultiAuth, CodexMultiAuthSyncCapacityError } = await import("../lib/codex-multi-auth-sync.js"); + let thrown: unknown; + try { + await previewSyncFromCodexMultiAuth(process.cwd()); + } catch (error) { + thrown = error; + } + expect(thrown).toBeInstanceOf(CodexMultiAuthSyncCapacityError); + expect(thrown).toMatchObject({ + name: "CodexMultiAuthSyncCapacityError", + details: expect.objectContaining({ + sourceDedupedTotal: 3, + importableNewAccounts: 0, + needToRemove: 1, + suggestedRemovals: [], + }), + }); + }); + + it("cleans up tagged synced overlaps by normalizing org-scoped identities first", async () => { + const storageModule = await import("../lib/storage.js"); + vi.mocked(storageModule.withAccountStorageTransaction).mockImplementationOnce(async (handler) => + handler( + { + version: 3, + activeIndex: 0, + activeIndexByFamily: {}, + accounts: [ + { + accountId: "org-example123", + accountIdSource: "org", + accountTags: ["codex-multi-auth-sync"], + refreshToken: "sync-refresh", + addedAt: 1, + lastUsed: 1, + }, + { + accountId: "org-example123", + organizationId: "org-example123", + accountIdSource: "org", + accountTags: ["codex-multi-auth-sync"], + refreshToken: "sync-refresh", + addedAt: 2, + lastUsed: 2, + }, + ], + }, + vi.fn(async () => {}), + ), + ); + vi.mocked(storageModule.normalizeAccountStorage).mockImplementationOnce((value: unknown): AccountStorageV3 => { + const record = value as { + version: 3; + activeIndex: number; + activeIndexByFamily: Record; + accounts: AccountStorageV3["accounts"]; + }; + return { + ...record, + accounts: [record.accounts[1]], + }; + }); + const { cleanupCodexMultiAuthSyncedOverlaps } = await import("../lib/codex-multi-auth-sync.js"); + await expect(cleanupCodexMultiAuthSyncedOverlaps()).resolves.toEqual({ + before: 2, + after: 1, + removed: 1, + updated: 0, + }); + }); + + it("does not count synced overlap records as updated when only key order differs", async () => { + const storageModule = await import("../lib/storage.js"); + const persist = vi.fn(async () => {}); + vi.mocked(storageModule.withAccountStorageTransaction).mockImplementationOnce(async (handler) => + handler( + { + version: 3, + activeIndex: 0, + activeIndexByFamily: {}, + accounts: [ + { + refreshToken: "sync-token", + accountTags: ["codex-multi-auth-sync"], + organizationId: "org-sync", + accountId: "org-sync", + accountIdSource: "org", + addedAt: 2, + lastUsed: 2, + }, + ], + }, + persist, + ), + ); + mockSourceStorageFile( + "/tmp/opencode-accounts.json", + JSON.stringify({ + version: 3, + activeIndex: 0, + activeIndexByFamily: {}, + accounts: [ + { + accountId: "org-sync", + organizationId: "org-sync", + accountIdSource: "org", + accountTags: ["codex-multi-auth-sync"], + refreshToken: "sync-token", + addedAt: 2, + lastUsed: 2, + }, + ], + }), + ); + + const { cleanupCodexMultiAuthSyncedOverlaps } = await import("../lib/codex-multi-auth-sync.js"); + await expect(cleanupCodexMultiAuthSyncedOverlaps()).resolves.toEqual({ + before: 1, + after: 1, + removed: 0, + updated: 0, + }); + expect(persist).not.toHaveBeenCalled(); + }); + + + it("limits overlap cleanup to accounts tagged from codex-multi-auth sync", async () => { + const storageModule = await import("../lib/storage.js"); + vi.mocked(storageModule.withAccountStorageTransaction).mockImplementationOnce(async (handler) => + handler( + { + version: 3, + activeIndex: 0, + activeIndexByFamily: {}, + accounts: [ + { + refreshToken: "legacy-a", + email: "shared@example.com", + addedAt: 1, + lastUsed: 1, + }, + { + refreshToken: "legacy-b", + email: "shared@example.com", + addedAt: 2, + lastUsed: 2, + }, + { + accountId: "org-sync", + organizationId: "org-sync", + accountIdSource: "org", + accountTags: ["codex-multi-auth-sync"], + email: "sync@example.com", + refreshToken: "sync-token", + addedAt: 3, + lastUsed: 3, + }, + { + accountId: "org-sync", + organizationId: "org-sync", + accountIdSource: "org", + accountTags: ["codex-multi-auth-sync"], + email: "sync@example.com", + refreshToken: "sync-token", + addedAt: 4, + lastUsed: 4, + }, + ], + }, + vi.fn(async () => {}), + ), + ); + + const { cleanupCodexMultiAuthSyncedOverlaps } = await import("../lib/codex-multi-auth-sync.js"); + await expect(cleanupCodexMultiAuthSyncedOverlaps()).resolves.toEqual({ + before: 4, + after: 4, + removed: 0, + updated: 1, + }); + }); + + it("removes synced accounts that overlap preserved local accounts", async () => { + const storageModule = await import("../lib/storage.js"); + const persist = vi.fn(async (_next: AccountStorageV3) => {}); + vi.mocked(storageModule.withAccountStorageTransaction).mockImplementationOnce(async (handler) => + handler( + { + version: 3, + activeIndex: 0, + activeIndexByFamily: {}, + accounts: [ + { + accountId: "org-local", + organizationId: "org-local", + accountIdSource: "org", + email: "shared@example.com", + refreshToken: "rt-local", + addedAt: 5, + lastUsed: 5, + }, + { + accountTags: ["codex-multi-auth-sync"], + email: "shared@example.com", + refreshToken: "rt-sync", + addedAt: 4, + lastUsed: 4, + }, + ], + }, + persist, + ), + ); + + const { cleanupCodexMultiAuthSyncedOverlaps } = await import("../lib/codex-multi-auth-sync.js"); + await expect(cleanupCodexMultiAuthSyncedOverlaps()).resolves.toEqual({ + before: 2, + after: 1, + removed: 1, + updated: 0, + }); + const saved = persist.mock.calls[0]?.[0]; + if (!saved) { + throw new Error("Expected persisted overlap cleanup result"); + } + expect(saved.accounts).toHaveLength(1); + expect(saved.accounts[0]?.accountId).toBe("org-local"); + }); + + it("remaps active indices when synced overlap cleanup reorders accounts", async () => { + const storageModule = await import("../lib/storage.js"); + const persist = vi.fn(async (_next: AccountStorageV3) => {}); + vi.mocked(storageModule.withAccountStorageTransaction).mockImplementationOnce(async (handler) => + handler( + { + version: 3, + activeIndex: 0, + activeIndexByFamily: { codex: 0 }, + accounts: [ + { + accountId: "org-sync", + organizationId: "org-sync", + accountIdSource: "org", + accountTags: ["codex-multi-auth-sync"], + email: "sync@example.com", + refreshToken: "sync-token", + addedAt: 3, + lastUsed: 3, + }, + { + accountId: "org-local", + organizationId: "org-local", + accountIdSource: "org", + email: "local@example.com", + refreshToken: "local-token", + addedAt: 4, + lastUsed: 4, + }, + ], + }, + persist, + ), + ); + + const { cleanupCodexMultiAuthSyncedOverlaps } = await import("../lib/codex-multi-auth-sync.js"); + await cleanupCodexMultiAuthSyncedOverlaps(); + + const saved = persist.mock.calls[0]?.[0]; + if (!saved) { + throw new Error("Expected persisted overlap cleanup result"); + } + expect(saved.accounts.map((account) => account.accountId)).toEqual(["org-local", "org-sync"]); + expect(saved.activeIndex).toBe(1); + expect(saved.activeIndexByFamily?.codex).toBe(1); + }); + + it("warns instead of failing when post-success temp cleanup cannot remove sync data", async () => { + const rootDir = join(process.cwd(), ".tmp-codex-multi-auth"); + process.env.CODEX_MULTI_AUTH_DIR = rootDir; + const globalPath = join(rootDir, "openai-codex-accounts.json"); + mockExistsSync.mockImplementation((candidate) => String(candidate) === globalPath); + mockSourceStorageFile( + globalPath, + JSON.stringify({ + version: 3, + activeIndex: 0, + activeIndexByFamily: {}, + accounts: [{ refreshToken: "sync-refresh", addedAt: 1, lastUsed: 1 }], + }), + ); + const rmSpy = vi.spyOn(fs.promises, "rm").mockRejectedValue(new Error("rm failed")); + const loggerModule = await import("../lib/logger.js"); + const storageModule = await import("../lib/storage.js"); + try { + const { syncFromCodexMultiAuth } = await import("../lib/codex-multi-auth-sync.js"); + + await expect(syncFromCodexMultiAuth(process.cwd())).resolves.toMatchObject({ + accountsPath: globalPath, + imported: 2, + backupStatus: "created", + }); + expect(vi.mocked(loggerModule.logWarn)).toHaveBeenCalledWith( + expect.stringContaining("Failed to remove temporary codex sync directory"), + ); + } finally { + rmSpy.mockRestore(); + } + }); + +}); diff --git a/test/index.test.ts b/test/index.test.ts index daf55c6c..26a2f4a8 100644 --- a/test/index.test.ts +++ b/test/index.test.ts @@ -77,6 +77,7 @@ vi.mock("../lib/auth/server.js", () => ({ vi.mock("../lib/cli.js", () => ({ promptLoginMode: vi.fn(async () => ({ mode: "add" })), promptAddAnotherAccount: vi.fn(async () => false), + promptCodexMultiAuthSyncPrune: vi.fn(async () => null), })); vi.mock("../lib/config.js", () => ({ @@ -109,6 +110,8 @@ vi.mock("../lib/config.js", () => ({ getCodexTuiColorProfile: () => "ansi16", getCodexTuiGlyphMode: () => "ascii", getBeginnerSafeMode: () => false, + getSyncFromCodexMultiAuthEnabled: () => false, + setSyncFromCodexMultiAuthEnabled: vi.fn(async () => {}), loadPluginConfig: () => ({}), })); From b1133164c65bc59e54fcd57c4212397d88a2aecd Mon Sep 17 00:00:00 2001 From: ndycode Date: Tue, 10 Mar 2026 19:54:47 +0800 Subject: [PATCH 02/15] fix: address sync flow review findings Make overlap cleanup backups atomic, pin the Windows-path parser tests to win32, and align the sync-tools prompt text with accepted input. Co-authored-by: Codex --- lib/cli.ts | 10 ++-- lib/codex-multi-auth-sync.ts | 20 +++++-- test/codex-multi-auth-sync.test.ts | 84 +++++++++++++++++++++--------- 3 files changed, 78 insertions(+), 36 deletions(-) diff --git a/lib/cli.ts b/lib/cli.ts index fe567907..061b2938 100644 --- a/lib/cli.ts +++ b/lib/cli.ts @@ -227,8 +227,8 @@ async function promptLoginModeFallback( console.log(""); } - while (true) { - const answer = await rl.question("(a)dd, (f)resh, (c)heck, (d)eep, (v)erify flagged, s(y)nc tools, or (q)uit? [a/f/c/d/v/s/q]: "); + while (true) { + const answer = await rl.question("(a)dd, (f)resh, (c)heck, (d)eep, (v)erify flagged, s(y)nc tools, or (q)uit? [a/f/c/d/v/s/y/q]: "); const normalized = answer.trim().toLowerCase(); if (normalized === "a" || normalized === "add") return { mode: "add" }; if (normalized === "f" || normalized === "fresh") return { mode: "fresh", deleteAll: true }; @@ -244,10 +244,10 @@ async function promptLoginModeFallback( continue; } if (normalized === "q" || normalized === "quit") return { mode: "cancel" }; - console.log("Please enter one of: a, f, c, d, v, s, q."); - } + console.log("Please enter one of: a, f, c, d, v, s, y, q."); + } } finally { - rl.close(); + rl.close(); } } diff --git a/lib/codex-multi-auth-sync.ts b/lib/codex-multi-auth-sync.ts index f5a1f123..baadf5c5 100644 --- a/lib/codex-multi-auth-sync.ts +++ b/lib/codex-multi-auth-sync.ts @@ -1221,11 +1221,21 @@ export async function cleanupCodexMultiAuthSyncedOverlaps( }; if (backupPath) { await fs.mkdir(dirname(backupPath), { recursive: true }); - await fs.writeFile(backupPath, `${JSON.stringify(fallback, null, 2)}\n`, { - encoding: "utf-8", - mode: 0o600, - flag: "wx", - }); + const tempBackupPath = `${backupPath}.${Date.now()}.${Math.random().toString(36).slice(2, 8)}.tmp`; + try { + await fs.writeFile(tempBackupPath, `${JSON.stringify(fallback, null, 2)}\n`, { + encoding: "utf-8", + mode: 0o600, + }); + await fs.rename(tempBackupPath, backupPath); + } catch (error) { + try { + await fs.unlink(tempBackupPath); + } catch { + // Best effort temp-backup cleanup. + } + throw error; + } } const plan = buildCodexMultiAuthOverlapCleanupPlan(fallback); if (plan.nextStorage) { diff --git a/test/codex-multi-auth-sync.test.ts b/test/codex-multi-auth-sync.test.ts index 87b0a139..b41f1f7a 100644 --- a/test/codex-multi-auth-sync.test.ts +++ b/test/codex-multi-auth-sync.test.ts @@ -95,11 +95,12 @@ describe("codex-multi-auth sync", () => { const originalReadFile = fs.promises.readFile.bind(fs.promises); const mockReadFile = vi.spyOn(fs.promises, "readFile"); const originalEnv = { - CODEX_MULTI_AUTH_DIR: process.env.CODEX_MULTI_AUTH_DIR, - CODEX_HOME: process.env.CODEX_HOME, - USERPROFILE: process.env.USERPROFILE, - HOME: process.env.HOME, + CODEX_MULTI_AUTH_DIR: process.env.CODEX_MULTI_AUTH_DIR, + CODEX_HOME: process.env.CODEX_HOME, + USERPROFILE: process.env.USERPROFILE, + HOME: process.env.HOME, }; + const originalPlatform = process.platform; const mockSourceStorageFile = (expectedPath: string, content: string) => { mockReadFile.mockImplementation(async (filePath, options) => { if (String(filePath) === expectedPath) { @@ -183,15 +184,16 @@ describe("codex-multi-auth sync", () => { }); afterEach(() => { - if (originalEnv.CODEX_MULTI_AUTH_DIR === undefined) delete process.env.CODEX_MULTI_AUTH_DIR; - else process.env.CODEX_MULTI_AUTH_DIR = originalEnv.CODEX_MULTI_AUTH_DIR; - if (originalEnv.CODEX_HOME === undefined) delete process.env.CODEX_HOME; - else process.env.CODEX_HOME = originalEnv.CODEX_HOME; - if (originalEnv.USERPROFILE === undefined) delete process.env.USERPROFILE; - else process.env.USERPROFILE = originalEnv.USERPROFILE; - if (originalEnv.HOME === undefined) delete process.env.HOME; - else process.env.HOME = originalEnv.HOME; - delete process.env.CODEX_AUTH_SYNC_MAX_ACCOUNTS; + if (originalEnv.CODEX_MULTI_AUTH_DIR === undefined) delete process.env.CODEX_MULTI_AUTH_DIR; + else process.env.CODEX_MULTI_AUTH_DIR = originalEnv.CODEX_MULTI_AUTH_DIR; + if (originalEnv.CODEX_HOME === undefined) delete process.env.CODEX_HOME; + else process.env.CODEX_HOME = originalEnv.CODEX_HOME; + if (originalEnv.USERPROFILE === undefined) delete process.env.USERPROFILE; + else process.env.USERPROFILE = originalEnv.USERPROFILE; + if (originalEnv.HOME === undefined) delete process.env.HOME; + else process.env.HOME = originalEnv.HOME; + delete process.env.CODEX_AUTH_SYNC_MAX_ACCOUNTS; + Object.defineProperty(process, "platform", { value: originalPlatform }); }); it("prefers a project-scoped codex-multi-auth accounts file when present", async () => { @@ -356,17 +358,19 @@ describe("codex-multi-auth sync", () => { }); it("rejects CODEX_MULTI_AUTH_DIR values that are not local absolute paths on Windows", async () => { - process.env.CODEX_MULTI_AUTH_DIR = "\\\\server\\share\\multi-auth"; - process.env.USERPROFILE = "C:\\Users\\tester"; - process.env.HOME = "C:\\Users\\tester"; + Object.defineProperty(process, "platform", { value: "win32" }); + process.env.CODEX_MULTI_AUTH_DIR = "\\\\server\\share\\multi-auth"; + process.env.USERPROFILE = "C:\\Users\\tester"; + process.env.HOME = "C:\\Users\\tester"; const { getCodexMultiAuthSourceRootDir } = await import("../lib/codex-multi-auth-sync.js"); expect(() => getCodexMultiAuthSourceRootDir()).toThrow(/local absolute path/i); }); it("accepts extended-length local Windows paths for CODEX_MULTI_AUTH_DIR", async () => { - process.env.USERPROFILE = "C:\\Users\\tester"; - process.env.HOME = "C:\\Users\\tester"; + Object.defineProperty(process, "platform", { value: "win32" }); + process.env.USERPROFILE = "C:\\Users\\tester"; + process.env.HOME = "C:\\Users\\tester"; const { getCodexMultiAuthSourceRootDir } = await import("../lib/codex-multi-auth-sync.js"); @@ -378,9 +382,10 @@ describe("codex-multi-auth sync", () => { }); it("rejects extended UNC Windows paths for CODEX_MULTI_AUTH_DIR", async () => { - process.env.CODEX_MULTI_AUTH_DIR = "\\\\?\\UNC\\server\\share\\multi-auth"; - process.env.USERPROFILE = "C:\\Users\\tester"; - process.env.HOME = "C:\\Users\\tester"; + Object.defineProperty(process, "platform", { value: "win32" }); + process.env.CODEX_MULTI_AUTH_DIR = "\\\\?\\UNC\\server\\share\\multi-auth"; + process.env.USERPROFILE = "C:\\Users\\tester"; + process.env.HOME = "C:\\Users\\tester"; const { getCodexMultiAuthSourceRootDir } = await import("../lib/codex-multi-auth-sync.js"); expect(() => getCodexMultiAuthSourceRootDir()).toThrow(/UNC network share/i); @@ -1558,8 +1563,8 @@ describe("codex-multi-auth sync", () => { }); it("does not count synced overlap records as updated when only key order differs", async () => { - const storageModule = await import("../lib/storage.js"); - const persist = vi.fn(async () => {}); + const storageModule = await import("../lib/storage.js"); + const persist = vi.fn(async () => {}); vi.mocked(storageModule.withAccountStorageTransaction).mockImplementationOnce(async (handler) => handler( { @@ -1607,13 +1612,40 @@ describe("codex-multi-auth sync", () => { after: 1, removed: 0, updated: 0, - }); - expect(persist).not.toHaveBeenCalled(); + }); + expect(persist).not.toHaveBeenCalled(); + }); + + it("writes overlap cleanup backups via a temp file before rename", async () => { + const storageModule = await import("../lib/storage.js"); + vi.mocked(storageModule.withAccountStorageTransaction).mockImplementationOnce(async (handler) => + handler(defaultTransactionalStorage(), vi.fn(async () => {})), + ); + const mkdirSpy = vi.spyOn(fs.promises, "mkdir").mockResolvedValue(undefined); + const writeSpy = vi.spyOn(fs.promises, "writeFile").mockResolvedValue(undefined); + const renameSpy = vi.spyOn(fs.promises, "rename").mockResolvedValue(undefined); + const unlinkSpy = vi.spyOn(fs.promises, "unlink").mockResolvedValue(undefined); + + try { + const { cleanupCodexMultiAuthSyncedOverlaps } = await import("../lib/codex-multi-auth-sync.js"); + await cleanupCodexMultiAuthSyncedOverlaps("/tmp/overlap-cleanup-backup.json"); + + expect(mkdirSpy).toHaveBeenCalledWith("/tmp", { recursive: true }); + const tempBackupPath = writeSpy.mock.calls[0]?.[0]; + expect(String(tempBackupPath)).toMatch(/^\/tmp\/overlap-cleanup-backup\.json\.\d+\.[a-z0-9]+\.tmp$/); + expect(renameSpy).toHaveBeenCalledWith(tempBackupPath, "/tmp/overlap-cleanup-backup.json"); + expect(unlinkSpy).not.toHaveBeenCalled(); + } finally { + mkdirSpy.mockRestore(); + writeSpy.mockRestore(); + renameSpy.mockRestore(); + unlinkSpy.mockRestore(); + } }); it("limits overlap cleanup to accounts tagged from codex-multi-auth sync", async () => { - const storageModule = await import("../lib/storage.js"); + const storageModule = await import("../lib/storage.js"); vi.mocked(storageModule.withAccountStorageTransaction).mockImplementationOnce(async (handler) => handler( { From 3b9ffc68cbbef832cbdd44c1158c9ae11ab11eee Mon Sep 17 00:00:00 2001 From: ndycode Date: Tue, 10 Mar 2026 20:14:55 +0800 Subject: [PATCH 03/15] fix: address sync review follow-ups Use a read-only snapshot for overlap preview, restore fallback prompt formatting, and cover overlap-backup failure handling. Co-authored-by: Codex --- lib/cli.ts | 12 ++- lib/codex-multi-auth-sync.ts | 11 +- test/codex-multi-auth-sync.test.ts | 168 +++++++++++++++++++---------- 3 files changed, 122 insertions(+), 69 deletions(-) diff --git a/lib/cli.ts b/lib/cli.ts index 061b2938..667a4dac 100644 --- a/lib/cli.ts +++ b/lib/cli.ts @@ -227,8 +227,10 @@ async function promptLoginModeFallback( console.log(""); } - while (true) { - const answer = await rl.question("(a)dd, (f)resh, (c)heck, (d)eep, (v)erify flagged, s(y)nc tools, or (q)uit? [a/f/c/d/v/s/y/q]: "); + while (true) { + const answer = await rl.question( + "(a)dd, (f)resh, (c)heck, (d)eep, (v)erify flagged, s(y)nc tools, or (q)uit? [a/f/c/d/v/s/y/q]: ", + ); const normalized = answer.trim().toLowerCase(); if (normalized === "a" || normalized === "add") return { mode: "add" }; if (normalized === "f" || normalized === "fresh") return { mode: "fresh", deleteAll: true }; @@ -244,10 +246,10 @@ async function promptLoginModeFallback( continue; } if (normalized === "q" || normalized === "quit") return { mode: "cancel" }; - console.log("Please enter one of: a, f, c, d, v, s, y, q."); - } + console.log("Please enter one of: a, f, c, d, v, s, y, q."); + } } finally { - rl.close(); + rl.close(); } } diff --git a/lib/codex-multi-auth-sync.ts b/lib/codex-multi-auth-sync.ts index baadf5c5..b9ff4425 100644 --- a/lib/codex-multi-auth-sync.ts +++ b/lib/codex-multi-auth-sync.ts @@ -1198,15 +1198,8 @@ export class CodexMultiAuthSyncCapacityError extends Error { } export async function previewCodexMultiAuthSyncedOverlapCleanup(): Promise { - return withAccountStorageTransaction((current) => { - const fallback = current ?? { - version: 3 as const, - accounts: [], - activeIndex: 0, - activeIndexByFamily: {}, - }; - return Promise.resolve(buildCodexMultiAuthOverlapCleanupPlan(fallback).result); - }); + const current = await loadAccounts(); + return buildCodexMultiAuthOverlapCleanupPlan(current ?? createEmptyAccountStorage()).result; } export async function cleanupCodexMultiAuthSyncedOverlaps( diff --git a/test/codex-multi-auth-sync.test.ts b/test/codex-multi-auth-sync.test.ts index b41f1f7a..b495a5ff 100644 --- a/test/codex-multi-auth-sync.test.ts +++ b/test/codex-multi-auth-sync.test.ts @@ -418,29 +418,11 @@ describe("codex-multi-auth sync", () => { }); }); - it("takes the same transaction-backed path for overlap cleanup preview as cleanup", async () => { + it("keeps overlap cleanup preview on the read-only loadAccounts path", async () => { const storageModule = await import("../lib/storage.js"); - vi.mocked(storageModule.withAccountStorageTransaction).mockImplementationOnce(async (handler) => - handler( - { - version: 3, - activeIndex: 0, - activeIndexByFamily: {}, - accounts: [ - { - accountId: "org-sync", - organizationId: "org-sync", - accountIdSource: "org", - accountTags: ["codex-multi-auth-sync"], - refreshToken: "sync-token", - addedAt: 2, - lastUsed: 2, - }, - ], - }, - vi.fn(async () => {}), - ), - ); + vi.mocked(storageModule.withAccountStorageTransaction).mockImplementationOnce(async () => { + throw new Error("overlap preview should not take write transaction lock"); + }); vi.mocked(storageModule.loadAccounts).mockResolvedValue({ version: 3, activeIndex: 0, @@ -465,8 +447,8 @@ describe("codex-multi-auth sync", () => { removed: 0, updated: 0, }); - expect(storageModule.withAccountStorageTransaction).toHaveBeenCalledTimes(1); - expect(storageModule.loadAccounts).not.toHaveBeenCalled(); + expect(storageModule.loadAccounts).toHaveBeenCalledTimes(1); + expect(storageModule.withAccountStorageTransaction).not.toHaveBeenCalled(); }); it("uses a single account snapshot for preview capacity filtering and preview counts", async () => { @@ -591,41 +573,39 @@ describe("codex-multi-auth sync", () => { }); }); - it("uses the locked transaction snapshot for overlap preview", async () => { + it("uses the loadAccounts snapshot for overlap preview", async () => { const storageModule = await import("../lib/storage.js"); vi.mocked(storageModule.deduplicateAccounts).mockImplementationOnce((accounts) => accounts.length > 1 ? [accounts[0] ?? accounts[1]].filter(Boolean) : accounts, ); - vi.mocked(storageModule.withAccountStorageTransaction).mockImplementationOnce(async (handler) => - handler( + vi.mocked(storageModule.withAccountStorageTransaction).mockImplementationOnce(async () => { + throw new Error("overlap preview should not take write transaction lock"); + }); + vi.mocked(storageModule.loadAccounts).mockResolvedValueOnce({ + version: 3, + activeIndex: 0, + activeIndexByFamily: {}, + accounts: [ { - version: 3, - activeIndex: 0, - activeIndexByFamily: {}, - accounts: [ - { - accountId: "org-sync", - organizationId: "org-sync", - accountIdSource: "org", - accountTags: ["codex-multi-auth-sync"], - email: "sync@example.com", - refreshToken: "sync-token", - addedAt: 2, - lastUsed: 2, - }, - { - accountId: "org-sync", - accountIdSource: "org", - email: "sync@example.com", - refreshToken: "sync-token", - addedAt: 1, - lastUsed: 1, - }, - ], + accountId: "org-sync", + organizationId: "org-sync", + accountIdSource: "org", + accountTags: ["codex-multi-auth-sync"], + email: "sync@example.com", + refreshToken: "sync-token", + addedAt: 2, + lastUsed: 2, }, - vi.fn(async () => {}), - ), - ); + { + accountId: "org-sync", + accountIdSource: "org", + email: "sync@example.com", + refreshToken: "sync-token", + addedAt: 1, + lastUsed: 1, + }, + ], + }); const { previewCodexMultiAuthSyncedOverlapCleanup } = await import("../lib/codex-multi-auth-sync.js"); await expect(previewCodexMultiAuthSyncedOverlapCleanup()).resolves.toEqual({ @@ -634,8 +614,8 @@ describe("codex-multi-auth sync", () => { removed: 0, updated: 0, }); - expect(storageModule.withAccountStorageTransaction).toHaveBeenCalledTimes(1); - expect(storageModule.loadAccounts).not.toHaveBeenCalled(); + expect(storageModule.loadAccounts).toHaveBeenCalledTimes(1); + expect(storageModule.withAccountStorageTransaction).not.toHaveBeenCalled(); }); it("does not retry through a fallback temp directory when the handler throws", async () => { @@ -1643,6 +1623,84 @@ describe("codex-multi-auth sync", () => { } }); + it.each([ + ["write", "write failed"], + ["rename", "rename failed"], + ] as const)( + "aborts overlap cleanup persistence when backup %s fails and attempts temp cleanup", + async (failureStep, expectedMessage) => { + const storageModule = await import("../lib/storage.js"); + const persist = vi.fn(async (_next: AccountStorageV3) => {}); + vi.mocked(storageModule.withAccountStorageTransaction).mockImplementationOnce(async (handler) => + handler( + { + version: 3, + activeIndex: 0, + activeIndexByFamily: {}, + accounts: [ + { + accountId: "org-local", + organizationId: "org-local", + accountIdSource: "org", + email: "shared@example.com", + refreshToken: "rt-local", + addedAt: 5, + lastUsed: 5, + }, + { + accountTags: ["codex-multi-auth-sync"], + email: "shared@example.com", + refreshToken: "rt-sync", + addedAt: 4, + lastUsed: 4, + }, + ], + }, + persist, + ), + ); + const mkdirSpy = vi.spyOn(fs.promises, "mkdir").mockResolvedValue(undefined); + const writeSpy = vi + .spyOn(fs.promises, "writeFile") + .mockImplementation(async (...args) => { + if (failureStep === "write") { + throw new Error(expectedMessage); + } + return undefined; + }); + const renameSpy = vi + .spyOn(fs.promises, "rename") + .mockImplementation(async (...args) => { + if (failureStep === "rename") { + throw new Error(expectedMessage); + } + return undefined; + }); + const unlinkSpy = vi.spyOn(fs.promises, "unlink").mockResolvedValue(undefined); + + try { + const { cleanupCodexMultiAuthSyncedOverlaps } = await import("../lib/codex-multi-auth-sync.js"); + await expect( + cleanupCodexMultiAuthSyncedOverlaps("/tmp/overlap-cleanup-backup.json"), + ).rejects.toThrow(expectedMessage); + + expect(mkdirSpy).toHaveBeenCalledWith("/tmp", { recursive: true }); + const tempBackupPath = + failureStep === "write" + ? writeSpy.mock.calls[0]?.[0] + : renameSpy.mock.calls[0]?.[0]; + expect(String(tempBackupPath)).toMatch(/^\/tmp\/overlap-cleanup-backup\.json\.\d+\.[a-z0-9]+\.tmp$/); + expect(unlinkSpy).toHaveBeenCalledWith(tempBackupPath); + expect(persist).not.toHaveBeenCalled(); + } finally { + mkdirSpy.mockRestore(); + writeSpy.mockRestore(); + renameSpy.mockRestore(); + unlinkSpy.mockRestore(); + } + }, + ); + it("limits overlap cleanup to accounts tagged from codex-multi-auth sync", async () => { const storageModule = await import("../lib/storage.js"); From f2f2c4d90c798dcfdd18f060a9db263b0ab80a14 Mon Sep 17 00:00:00 2001 From: ndycode Date: Tue, 10 Mar 2026 23:58:15 +0800 Subject: [PATCH 04/15] fix: clarify sync capacity guidance Remove the impossible suggestion to raise the sync max override when the hard cap is already enforced. Co-authored-by: Codex --- lib/codex-multi-auth-sync.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/codex-multi-auth-sync.ts b/lib/codex-multi-auth-sync.ts index b9ff4425..d024e576 100644 --- a/lib/codex-multi-auth-sync.ts +++ b/lib/codex-multi-auth-sync.ts @@ -1177,7 +1177,7 @@ export function getCodexMultiAuthCapacityErrorMessage( if (sourceExceedsCapacityWithoutLocalRelief(details)) { return ( `Sync source alone exceeds the maximum of ${details.maxAccounts} accounts ` + - `(${details.sourceDedupedTotal} deduped source accounts). Reduce the source set or raise ${SYNC_MAX_ACCOUNTS_OVERRIDE_ENV}.` + `(${details.sourceDedupedTotal} deduped source accounts). Reduce the source set.` ); } return ( From 8bebcdffe19052a85e9fbc2778b97df0c5c2fe59 Mon Sep 17 00:00:00 2001 From: ndycode Date: Sun, 15 Mar 2026 16:02:34 +0800 Subject: [PATCH 05/15] fix: harden sync prune recovery --- index.ts | 57 ++- lib/codex-multi-auth-sync.ts | 20 +- lib/sync-prune-backup.ts | 31 +- test/codex-multi-auth-sync.test.ts | 71 +++- test/index.test.ts | 560 +++++++++++++++++++++++++++-- test/sync-prune-backup.test.ts | 27 +- 6 files changed, 687 insertions(+), 79 deletions(-) diff --git a/index.ts b/index.ts index 74ceeb37..3480e769 100644 --- a/index.ts +++ b/index.ts @@ -3465,6 +3465,7 @@ while (attempted.size < Math.max(1, accountCount)) { index: number; account: AccountStorageV3["accounts"][number]; }> = []; + let rollbackStorage: AccountStorageV3 | null = null; await withAccountStorageTransaction(async (loadedStorage, persist) => { const currentStorage = loadedStorage ?? @@ -3488,6 +3489,7 @@ while (attempted.size < Math.max(1, accountCount)) { if (removedTargets.length === 0) { return; } + rollbackStorage = structuredClone(currentStorage); const activeAccountIdentity = { refreshToken: @@ -3554,21 +3556,41 @@ while (attempted.size < Math.max(1, accountCount)) { }), ), ); - await withFlaggedAccountsTransaction(async (currentFlaggedStorage, persist) => { - await persist({ - version: 1, - accounts: currentFlaggedStorage.accounts.filter( - (flagged) => - !removedFlaggedKeys.has( - getSyncRemovalTargetKey({ - refreshToken: flagged.refreshToken, - organizationId: flagged.organizationId, - accountId: flagged.accountId, - }), - ), - ), + try { + await withFlaggedAccountsTransaction(async (currentFlaggedStorage, persist) => { + await persist({ + version: 1, + accounts: currentFlaggedStorage.accounts.filter( + (flagged) => + !removedFlaggedKeys.has( + getSyncRemovalTargetKey({ + refreshToken: flagged.refreshToken, + organizationId: flagged.organizationId, + accountId: flagged.accountId, + }), + ), + ), + }); }); - }); + } catch (flaggedError) { + if (rollbackStorage) { + try { + await withAccountStorageTransaction(async (_current, persist) => { + await persist(rollbackStorage as AccountStorageV3); + }); + } catch (restoreError) { + const flaggedMessage = + flaggedError instanceof Error ? flaggedError.message : String(flaggedError); + const restoreMessage = + restoreError instanceof Error ? restoreError.message : String(restoreError); + throw new Error( + `Failed to remove flagged sync entries after account removal: ${flaggedMessage}; ` + + `failed to restore removed accounts: ${restoreMessage}`, + ); + } + } + throw flaggedError; + } invalidateAccountManagerCache(); } }; @@ -3754,7 +3776,12 @@ while (attempted.size < Math.max(1, accountCount)) { console.log(""); } catch (error) { const message = error instanceof Error ? error.message : String(error); - const backupHint = backupPath ? `\nBackup: ${backupPath}` : ""; + const cleanupBackupPath = + error instanceof Error && + typeof (error as Error & { backupPath?: unknown }).backupPath === "string" + ? ((error as Error & { backupPath: string }).backupPath) + : undefined; + const backupHint = cleanupBackupPath ? `\nBackup: ${cleanupBackupPath}` : ""; console.log(`\nCleanup failed: ${message}${backupHint}\n`); } }; diff --git a/lib/codex-multi-auth-sync.ts b/lib/codex-multi-auth-sync.ts index d024e576..17b2a257 100644 --- a/lib/codex-multi-auth-sync.ts +++ b/lib/codex-multi-auth-sync.ts @@ -121,6 +121,7 @@ interface PreparedCodexMultiAuthPreviewStorage { const TEMP_CLEANUP_RETRY_DELAYS_MS = [100, 250, 500] as const; const STALE_TEMP_CLEANUP_RETRY_DELAY_MS = 150; +const STALE_TEMP_SWEEP_RETRYABLE_CODES = new Set(["EBUSY", "EAGAIN", "EACCES", "EPERM", "ENOTEMPTY"]); function sleepAsync(ms: number): Promise { return new Promise((resolve) => setTimeout(resolve, ms)); @@ -311,7 +312,7 @@ async function cleanupStaleNormalizedImportTempDirs( continue; } let message = error instanceof Error ? error.message : String(error); - if (code === "EBUSY" || code === "EACCES" || code === "EPERM") { + if (code && STALE_TEMP_SWEEP_RETRYABLE_CODES.has(code)) { await sleepAsync(STALE_TEMP_CLEANUP_RETRY_DELAY_MS); try { await fs.rm(candidateDir, { recursive: true, force: true }); @@ -1212,6 +1213,7 @@ export async function cleanupCodexMultiAuthSyncedOverlaps( activeIndex: 0, activeIndexByFamily: {}, }; + let writtenBackupPath: string | undefined; if (backupPath) { await fs.mkdir(dirname(backupPath), { recursive: true }); const tempBackupPath = `${backupPath}.${Date.now()}.${Math.random().toString(36).slice(2, 8)}.tmp`; @@ -1221,6 +1223,7 @@ export async function cleanupCodexMultiAuthSyncedOverlaps( mode: 0o600, }); await fs.rename(tempBackupPath, backupPath); + writtenBackupPath = backupPath; } catch (error) { try { await fs.unlink(tempBackupPath); @@ -1230,10 +1233,17 @@ export async function cleanupCodexMultiAuthSyncedOverlaps( throw error; } } - const plan = buildCodexMultiAuthOverlapCleanupPlan(fallback); - if (plan.nextStorage) { - await persist(plan.nextStorage); + try { + const plan = buildCodexMultiAuthOverlapCleanupPlan(fallback); + if (plan.nextStorage) { + await persist(plan.nextStorage); + } + return plan.result; + } catch (error) { + if (writtenBackupPath && error instanceof Error) { + (error as Error & { backupPath?: string }).backupPath = writtenBackupPath; + } + throw error; } - return plan.result; }); } diff --git a/lib/sync-prune-backup.ts b/lib/sync-prune-backup.ts index 16dc666f..4431c314 100644 --- a/lib/sync-prune-backup.ts +++ b/lib/sync-prune-backup.ts @@ -5,41 +5,22 @@ type FlaggedSnapshot = { accounts: TAccount[]; }; -type TokenRedacted = - Omit & { - accessToken?: undefined; - refreshToken?: undefined; - idToken?: undefined; - }; - -function cloneWithoutTokens(account: TAccount): TokenRedacted { - const clone = structuredClone(account) as TokenRedacted; - delete clone.accessToken; - delete clone.refreshToken; - delete clone.idToken; - return clone; -} - export function createSyncPruneBackupPayload( currentAccountsStorage: AccountStorageV3, currentFlaggedStorage: FlaggedSnapshot, ): { version: 1; - accounts: Omit & { - accounts: Array>; - }; - flagged: FlaggedSnapshot>; + accounts: AccountStorageV3; + flagged: FlaggedSnapshot; } { return { version: 1, - accounts: { + accounts: structuredClone({ ...currentAccountsStorage, - accounts: currentAccountsStorage.accounts.map((account) => cloneWithoutTokens(account)), activeIndexByFamily: { ...(currentAccountsStorage.activeIndexByFamily ?? {}) }, - }, - flagged: { + }), + flagged: structuredClone({ ...currentFlaggedStorage, - accounts: currentFlaggedStorage.accounts.map((flagged) => cloneWithoutTokens(flagged)), - }, + }), }; } diff --git a/test/codex-multi-auth-sync.test.ts b/test/codex-multi-auth-sync.test.ts index b495a5ff..69b9e274 100644 --- a/test/codex-multi-auth-sync.test.ts +++ b/test/codex-multi-auth-sync.test.ts @@ -808,7 +808,9 @@ describe("codex-multi-auth sync", () => { skipped: 0, total: 4, }); - expect(rmSpy).toHaveBeenCalledTimes(3); + expect( + rmSpy.mock.calls.filter(([path]) => String(path).includes("oc-chatgpt-multi-auth-sync-")), + ).toHaveLength(3); expect(vi.mocked(loggerModule.logWarn)).not.toHaveBeenCalledWith( expect.stringContaining("Failed to remove temporary codex sync directory"), ); @@ -961,7 +963,9 @@ describe("codex-multi-auth sync", () => { } }); - it("retries stale temp sweep once on transient Windows lock errors", async () => { + it.each(["EBUSY", "ENOTEMPTY", "EAGAIN"] as const)( + "retries stale temp sweep once on transient Windows %s cleanup errors", + async (code) => { const rootDir = join(process.cwd(), ".tmp-codex-multi-auth"); const fakeHome = await fs.promises.mkdtemp(join(os.tmpdir(), "codex-sync-home-")); process.env.CODEX_MULTI_AUTH_DIR = rootDir; @@ -987,7 +991,7 @@ describe("codex-multi-auth sync", () => { const rmSpy = vi.spyOn(fs.promises, "rm").mockImplementation(async (path, options) => { if (!staleSweepBlocked && String(path) === staleDir) { staleSweepBlocked = true; - throw Object.assign(new Error("busy"), { code: "EBUSY" }); + throw Object.assign(new Error("busy"), { code }); } return originalRm(path, options as never); }); @@ -1701,6 +1705,67 @@ describe("codex-multi-auth sync", () => { }, ); + it("annotates overlap cleanup failures with the written backup path", async () => { + const storageModule = await import("../lib/storage.js"); + const persist = vi.fn(async () => { + throw new Error("persist failed"); + }); + vi.mocked(storageModule.withAccountStorageTransaction).mockImplementationOnce(async (handler) => + handler( + { + version: 3, + activeIndex: 0, + activeIndexByFamily: {}, + accounts: [ + { + accountId: "org-local", + organizationId: "org-local", + accountIdSource: "org", + email: "shared@example.com", + refreshToken: "rt-local", + addedAt: 5, + lastUsed: 5, + }, + { + accountTags: ["codex-multi-auth-sync"], + email: "shared@example.com", + refreshToken: "rt-sync", + addedAt: 4, + lastUsed: 4, + }, + ], + }, + persist, + ), + ); + const mkdirSpy = vi.spyOn(fs.promises, "mkdir").mockResolvedValue(undefined); + const writeSpy = vi.spyOn(fs.promises, "writeFile").mockResolvedValue(undefined); + const renameSpy = vi.spyOn(fs.promises, "rename").mockResolvedValue(undefined); + + try { + const { cleanupCodexMultiAuthSyncedOverlaps } = await import("../lib/codex-multi-auth-sync.js"); + let thrown: unknown; + try { + await cleanupCodexMultiAuthSyncedOverlaps("/tmp/overlap-cleanup-backup.json"); + } catch (error) { + thrown = error; + } + + expect(mkdirSpy).toHaveBeenCalledWith("/tmp", { recursive: true }); + expect(writeSpy).toHaveBeenCalled(); + expect(renameSpy).toHaveBeenCalled(); + expect(thrown).toBeInstanceOf(Error); + expect(thrown).toMatchObject({ + message: "persist failed", + backupPath: "/tmp/overlap-cleanup-backup.json", + }); + } finally { + mkdirSpy.mockRestore(); + writeSpy.mockRestore(); + renameSpy.mockRestore(); + } + }); + it("limits overlap cleanup to accounts tagged from codex-multi-auth sync", async () => { const storageModule = await import("../lib/storage.js"); diff --git a/test/index.test.ts b/test/index.test.ts index 26a2f4a8..699fa730 100644 --- a/test/index.test.ts +++ b/test/index.test.ts @@ -110,7 +110,7 @@ vi.mock("../lib/config.js", () => ({ getCodexTuiColorProfile: () => "ansi16", getCodexTuiGlyphMode: () => "ascii", getBeginnerSafeMode: () => false, - getSyncFromCodexMultiAuthEnabled: () => false, + getSyncFromCodexMultiAuthEnabled: vi.fn(() => false), setSyncFromCodexMultiAuthEnabled: vi.fn(async () => {}), loadPluginConfig: () => ({}), })); @@ -178,6 +178,73 @@ vi.mock("../lib/request/rate-limit-backoff.js", () => ({ resetRateLimitBackoff: vi.fn(), })); +vi.mock("../lib/ui/confirm.js", () => ({ + confirm: vi.fn(async () => true), +})); + +vi.mock("../lib/codex-multi-auth-sync.js", () => { + class CodexMultiAuthSyncCapacityError extends Error { + details: Record; + + constructor(details: Record) { + super("Mock sync capacity error"); + this.name = "CodexMultiAuthSyncCapacityError"; + this.details = details; + } + } + + return { + loadCodexMultiAuthSourceStorage: vi.fn(async () => ({ + rootDir: "/tmp/codex-source", + accountsPath: "/tmp/codex-source/openai-codex-accounts.json", + scope: "global", + storage: { + version: 3 as const, + accounts: [], + activeIndex: 0, + activeIndexByFamily: {}, + }, + })), + previewSyncFromCodexMultiAuth: vi.fn(async () => ({ + rootDir: "/tmp/codex-source", + accountsPath: "/tmp/codex-source/openai-codex-accounts.json", + scope: "global" as const, + imported: 1, + skipped: 0, + total: 1, + })), + syncFromCodexMultiAuth: vi.fn(async () => ({ + rootDir: "/tmp/codex-source", + accountsPath: "/tmp/codex-source/openai-codex-accounts.json", + scope: "global" as const, + imported: 1, + skipped: 0, + total: 1, + backupStatus: "created" as const, + backupPath: "/tmp/codex-multi-auth-sync-backup.json", + })), + previewCodexMultiAuthSyncedOverlapCleanup: vi.fn(async () => ({ + before: 2, + after: 1, + removed: 1, + updated: 0, + })), + cleanupCodexMultiAuthSyncedOverlaps: vi.fn(async () => ({ + before: 2, + after: 1, + removed: 1, + updated: 0, + })), + isCodexMultiAuthSourceTooLargeForCapacity: vi.fn( + (details: { sourceDedupedTotal: number; maxAccounts: number; importableNewAccounts: number; suggestedRemovals: unknown[] }) => + details.sourceDedupedTotal > details.maxAccounts && + details.importableNewAccounts === 0 && + details.suggestedRemovals.length === 0, + ), + CodexMultiAuthSyncCapacityError, + }; +}); + vi.mock("../lib/request/fetch-helpers.js", () => ({ extractRequestUrl: (input: unknown) => (typeof input === "string" ? input : String(input)), rewriteUrlForCodex: (url: string) => url, @@ -226,6 +293,29 @@ const cloneMockStorage = () => ({ activeIndexByFamily: { ...mockStorage.activeIndexByFamily }, }); +const mockFlaggedStorage = { + version: 1 as const, + accounts: [] as Array<{ + refreshToken: string; + accessToken?: string; + idToken?: string; + organizationId?: string; + accountId?: string; + email?: string; + accountLabel?: string; + flaggedAt?: number; + addedAt?: number; + lastUsed?: number; + }>, +}; + +const cloneFlaggedAccount = (account: (typeof mockFlaggedStorage.accounts)[number]) => structuredClone(account); + +const cloneMockFlaggedStorage = () => ({ + ...mockFlaggedStorage, + accounts: mockFlaggedStorage.accounts.map(cloneFlaggedAccount), +}); + vi.mock("../lib/storage.js", () => ({ getStoragePath: () => "/mock/path/accounts.json", loadAccounts: vi.fn(async () => cloneMockStorage()), @@ -264,8 +354,33 @@ vi.mock("../lib/storage.js", () => ({ })), previewImportAccounts: vi.fn(async () => ({ imported: 2, skipped: 1, total: 5 })), createTimestampedBackupPath: vi.fn((prefix?: string) => `/tmp/${prefix ?? "codex-backup"}-20260101-000000.json`), - loadFlaggedAccounts: vi.fn(async () => ({ version: 1, accounts: [] })), - saveFlaggedAccounts: vi.fn(async () => {}), + loadAccountAndFlaggedStorageSnapshot: vi.fn(async () => ({ + accounts: cloneMockStorage(), + flagged: cloneMockFlaggedStorage(), + })), + loadFlaggedAccounts: vi.fn(async () => cloneMockFlaggedStorage()), + normalizeAccountStorage: vi.fn((value: unknown) => value), + saveFlaggedAccounts: vi.fn( + async (nextStorage: typeof mockFlaggedStorage) => { + mockFlaggedStorage.version = nextStorage.version; + mockFlaggedStorage.accounts = nextStorage.accounts.map(cloneFlaggedAccount); + }, + ), + withFlaggedAccountsTransaction: vi.fn( + async ( + callback: ( + loadedStorage: typeof mockFlaggedStorage, + persist: (nextStorage: typeof mockFlaggedStorage) => Promise, + ) => Promise, + ) => { + const loadedStorage = cloneMockFlaggedStorage(); + const persist = async (nextStorage: typeof mockFlaggedStorage) => { + mockFlaggedStorage.version = nextStorage.version; + mockFlaggedStorage.accounts = nextStorage.accounts.map(cloneFlaggedAccount); + }; + return await callback(loadedStorage, persist); + }, + ), clearFlaggedAccounts: vi.fn(async () => {}), StorageError: class StorageError extends Error { hint: string; @@ -389,6 +504,129 @@ vi.mock("../lib/accounts.js", () => { }; }); +beforeEach(async () => { + const cliModule = await import("../lib/cli.js"); + const confirmModule = await import("../lib/ui/confirm.js"); + const configModule = await import("../lib/config.js"); + const syncModule = await import("../lib/codex-multi-auth-sync.js"); + const storageModule = await import("../lib/storage.js"); + + vi.mocked(cliModule.promptLoginMode).mockReset(); + vi.mocked(cliModule.promptAddAnotherAccount).mockReset(); + vi.mocked(cliModule.promptCodexMultiAuthSyncPrune).mockReset(); + vi.mocked(confirmModule.confirm).mockReset(); + vi.mocked(configModule.getSyncFromCodexMultiAuthEnabled).mockReset(); + vi.mocked(syncModule.loadCodexMultiAuthSourceStorage).mockReset(); + vi.mocked(syncModule.previewSyncFromCodexMultiAuth).mockReset(); + vi.mocked(syncModule.syncFromCodexMultiAuth).mockReset(); + vi.mocked(syncModule.previewCodexMultiAuthSyncedOverlapCleanup).mockReset(); + vi.mocked(syncModule.cleanupCodexMultiAuthSyncedOverlaps).mockReset(); + vi.mocked(storageModule.loadAccounts).mockReset(); + vi.mocked(storageModule.saveAccounts).mockReset(); + vi.mocked(storageModule.withAccountStorageTransaction).mockReset(); + vi.mocked(storageModule.loadAccountAndFlaggedStorageSnapshot).mockReset(); + vi.mocked(storageModule.loadFlaggedAccounts).mockReset(); + vi.mocked(storageModule.saveFlaggedAccounts).mockReset(); + vi.mocked(storageModule.withFlaggedAccountsTransaction).mockReset(); + vi.mocked(storageModule.normalizeAccountStorage).mockReset(); + + vi.mocked(cliModule.promptLoginMode).mockResolvedValue({ mode: "add" }); + vi.mocked(cliModule.promptAddAnotherAccount).mockResolvedValue(false); + vi.mocked(cliModule.promptCodexMultiAuthSyncPrune).mockResolvedValue(null); + vi.mocked(confirmModule.confirm).mockResolvedValue(true); + vi.mocked(configModule.getSyncFromCodexMultiAuthEnabled).mockReturnValue(false); + vi.mocked(syncModule.loadCodexMultiAuthSourceStorage).mockResolvedValue({ + rootDir: "/tmp/codex-source", + accountsPath: "/tmp/codex-source/openai-codex-accounts.json", + scope: "global", + storage: { + version: 3, + accounts: [], + activeIndex: 0, + activeIndexByFamily: {}, + }, + }); + vi.mocked(syncModule.previewSyncFromCodexMultiAuth).mockResolvedValue({ + rootDir: "/tmp/codex-source", + accountsPath: "/tmp/codex-source/openai-codex-accounts.json", + scope: "global", + imported: 1, + skipped: 0, + total: 1, + }); + vi.mocked(syncModule.syncFromCodexMultiAuth).mockResolvedValue({ + rootDir: "/tmp/codex-source", + accountsPath: "/tmp/codex-source/openai-codex-accounts.json", + scope: "global", + imported: 1, + skipped: 0, + total: 1, + backupStatus: "created", + backupPath: "/tmp/codex-multi-auth-sync-backup.json", + }); + vi.mocked(syncModule.previewCodexMultiAuthSyncedOverlapCleanup).mockResolvedValue({ + before: 2, + after: 1, + removed: 1, + updated: 0, + }); + vi.mocked(syncModule.cleanupCodexMultiAuthSyncedOverlaps).mockResolvedValue({ + before: 2, + after: 1, + removed: 1, + updated: 0, + }); + vi.mocked(storageModule.loadAccounts).mockImplementation(async () => cloneMockStorage()); + vi.mocked(storageModule.saveAccounts).mockImplementation(async (nextStorage: typeof mockStorage) => { + mockStorage.version = nextStorage.version; + mockStorage.accounts = nextStorage.accounts.map(cloneAccount); + mockStorage.activeIndex = nextStorage.activeIndex; + mockStorage.activeIndexByFamily = { ...nextStorage.activeIndexByFamily }; + }); + vi.mocked(storageModule.withAccountStorageTransaction).mockImplementation( + async ( + callback: ( + loadedStorage: typeof mockStorage, + persist: (nextStorage: typeof mockStorage) => Promise, + ) => Promise, + ) => { + const loadedStorage = cloneMockStorage(); + const persist = async (nextStorage: typeof mockStorage) => { + mockStorage.version = nextStorage.version; + mockStorage.accounts = nextStorage.accounts.map(cloneAccount); + mockStorage.activeIndex = nextStorage.activeIndex; + mockStorage.activeIndexByFamily = { ...nextStorage.activeIndexByFamily }; + }; + return await callback(loadedStorage, persist); + }, + ); + vi.mocked(storageModule.loadAccountAndFlaggedStorageSnapshot).mockImplementation(async () => ({ + accounts: cloneMockStorage(), + flagged: cloneMockFlaggedStorage(), + })); + vi.mocked(storageModule.loadFlaggedAccounts).mockImplementation(async () => cloneMockFlaggedStorage()); + vi.mocked(storageModule.saveFlaggedAccounts).mockImplementation(async (nextStorage: typeof mockFlaggedStorage) => { + mockFlaggedStorage.version = nextStorage.version; + mockFlaggedStorage.accounts = nextStorage.accounts.map(cloneFlaggedAccount); + }); + vi.mocked(storageModule.withFlaggedAccountsTransaction).mockImplementation( + async ( + callback: ( + loadedStorage: typeof mockFlaggedStorage, + persist: (nextStorage: typeof mockFlaggedStorage) => Promise, + ) => Promise, + ) => { + const loadedStorage = cloneMockFlaggedStorage(); + const persist = async (nextStorage: typeof mockFlaggedStorage) => { + mockFlaggedStorage.version = nextStorage.version; + mockFlaggedStorage.accounts = nextStorage.accounts.map(cloneFlaggedAccount); + }; + return await callback(loadedStorage, persist); + }, + ); + vi.mocked(storageModule.normalizeAccountStorage).mockImplementation((value: unknown) => value); +}); + type ToolExecute = { execute: (args: T) => Promise }; type OptionalToolExecute = { execute: (args?: T) => Promise }; type PluginType = { @@ -437,10 +675,20 @@ describe("OpenAIOAuthPlugin", () => { beforeEach(async () => { vi.clearAllMocks(); mockClient = createMockClient(); + const cliModule = await import("../lib/cli.js"); + const confirmModule = await import("../lib/ui/confirm.js"); + const configModule = await import("../lib/config.js"); + + vi.mocked(cliModule.promptLoginMode).mockResolvedValue({ mode: "add" }); + vi.mocked(cliModule.promptAddAnotherAccount).mockResolvedValue(false); + vi.mocked(cliModule.promptCodexMultiAuthSyncPrune).mockResolvedValue(null); + vi.mocked(confirmModule.confirm).mockResolvedValue(true); + vi.mocked(configModule.getSyncFromCodexMultiAuthEnabled).mockReturnValue(false); mockStorage.accounts = []; mockStorage.activeIndex = 0; mockStorage.activeIndexByFamily = {}; + mockFlaggedStorage.accounts = []; const { OpenAIOAuthPlugin } = await import("../index.js"); plugin = await OpenAIOAuthPlugin({ client: mockClient } as never) as unknown as PluginType; @@ -1797,6 +2045,279 @@ describe("OpenAIOAuthPlugin", () => { expect(observedSnapshots).toEqual(["s1", "s2"]); }); }); + + describe("sync maintenance flows", () => { + it("shows overlap cleanup backup hints only when cleanup reports a written backup", async () => { + const cliModule = await import("../lib/cli.js"); + const confirmModule = await import("../lib/ui/confirm.js"); + const syncModule = await import("../lib/codex-multi-auth-sync.js"); + const storageModule = await import("../lib/storage.js"); + const logSpy = vi.spyOn(console, "log").mockImplementation(() => {}); + + mockStorage.accounts = [ + { + accountId: "existing-account", + email: "existing@example.com", + refreshToken: "existing-refresh", + addedAt: 1, + lastUsed: 1, + }, + ]; + + vi.mocked(cliModule.promptLoginMode) + .mockResolvedValueOnce({ mode: "experimental-cleanup-overlaps" }) + .mockResolvedValueOnce({ mode: "cancel" }) + .mockResolvedValue({ mode: "cancel" }); + vi.mocked(confirmModule.confirm).mockResolvedValueOnce(true); + vi.mocked(syncModule.previewCodexMultiAuthSyncedOverlapCleanup).mockResolvedValueOnce({ + before: 3, + after: 2, + removed: 1, + updated: 0, + }); + vi.mocked(syncModule.cleanupCodexMultiAuthSyncedOverlaps).mockRejectedValueOnce( + new Error("cleanup persist failed"), + ); + + try { + const autoMethod = plugin.auth.methods[0] as unknown as { + authorize: (inputs?: Record) => Promise<{ instructions: string }>; + }; + + const result = await autoMethod.authorize(); + expect(result.instructions).toBe("Authentication cancelled"); + expect(vi.mocked(storageModule.createTimestampedBackupPath)).toHaveBeenCalledWith( + "codex-maintenance-overlap-backup", + ); + + const output = logSpy.mock.calls.flat().join("\n"); + expect(output).toContain("Cleanup failed: cleanup persist failed"); + expect(output).not.toContain("Backup: /tmp/codex-backup-20260101-000000.json"); + } finally { + logSpy.mockRestore(); + } + }); + + it("writes a restorable sync prune backup before removing accounts", async () => { + const cliModule = await import("../lib/cli.js"); + const confirmModule = await import("../lib/ui/confirm.js"); + const configModule = await import("../lib/config.js"); + const syncModule = await import("../lib/codex-multi-auth-sync.js"); + const { promises: nodeFsPromises } = await import("node:fs"); + + mockStorage.accounts = [ + { + accountId: "remove-me", + email: "remove@example.com", + refreshToken: "refresh-remove", + accessToken: "access-remove", + idToken: "id-remove", + addedAt: 1, + lastUsed: 1, + }, + { + accountId: "keep-me", + email: "keep@example.com", + refreshToken: "refresh-keep", + accessToken: "access-keep", + idToken: "id-keep", + addedAt: 2, + lastUsed: 2, + }, + ]; + mockStorage.activeIndex = 1; + mockStorage.activeIndexByFamily = { codex: 1 }; + mockFlaggedStorage.accounts = [ + { + accountId: "remove-me", + email: "remove@example.com", + refreshToken: "refresh-remove", + accessToken: "flagged-access-remove", + idToken: "flagged-id-remove", + flaggedAt: 123, + addedAt: 1, + lastUsed: 1, + }, + ]; + + const { CodexMultiAuthSyncCapacityError } = syncModule; + vi.mocked(configModule.getSyncFromCodexMultiAuthEnabled).mockReturnValue(true); + vi.mocked(cliModule.promptLoginMode) + .mockResolvedValueOnce({ mode: "experimental-sync-now" }) + .mockResolvedValueOnce({ mode: "cancel" }) + .mockResolvedValue({ mode: "cancel" }); + vi.mocked(cliModule.promptCodexMultiAuthSyncPrune).mockResolvedValueOnce([0]); + vi.mocked(confirmModule.confirm).mockResolvedValue(true); + vi.mocked(syncModule.previewSyncFromCodexMultiAuth) + .mockRejectedValueOnce( + new CodexMultiAuthSyncCapacityError({ + rootDir: "/tmp/codex-source", + accountsPath: "/tmp/codex-source/openai-codex-accounts.json", + scope: "global", + currentCount: 2, + sourceCount: 1, + sourceDedupedTotal: 1, + dedupedTotal: 3, + maxAccounts: 2, + needToRemove: 1, + importableNewAccounts: 1, + skippedOverlaps: 0, + suggestedRemovals: [ + { + index: 0, + email: "remove@example.com", + accountLabel: "Remove Me", + refreshToken: "refresh-remove", + organizationId: undefined, + accountId: "remove-me", + isCurrentAccount: false, + score: 100, + reason: "test removal", + }, + ], + }), + ) + .mockResolvedValueOnce({ + rootDir: "/tmp/codex-source", + accountsPath: "/tmp/codex-source/openai-codex-accounts.json", + scope: "global", + imported: 1, + skipped: 0, + total: 2, + }); + + const mkdirSpy = vi.spyOn(nodeFsPromises, "mkdir").mockResolvedValue(undefined); + const writeSpy = vi.spyOn(nodeFsPromises, "writeFile").mockResolvedValue(undefined); + const renameSpy = vi.spyOn(nodeFsPromises, "rename").mockResolvedValue(undefined); + + try { + const autoMethod = plugin.auth.methods[0] as unknown as { + authorize: (inputs?: Record) => Promise<{ instructions: string }>; + }; + + const result = await autoMethod.authorize(); + expect(result.instructions).toBe("Authentication cancelled"); + + const backupWrite = writeSpy.mock.calls.find(([path]) => + String(path).includes("codex-sync-prune-backup"), + ); + expect(backupWrite).toBeDefined(); + const backupContent = String(backupWrite?.[1] ?? ""); + expect(backupContent).toContain("\"refreshToken\": \"refresh-remove\""); + expect(backupContent).toContain("\"accessToken\": \"access-remove\""); + expect(backupContent).toContain("\"idToken\": \"id-remove\""); + expect(backupContent).toContain("\"accessToken\": \"flagged-access-remove\""); + expect(backupContent).toContain("\"idToken\": \"flagged-id-remove\""); + expect(renameSpy).toHaveBeenCalled(); + expect(mkdirSpy).toHaveBeenCalled(); + } finally { + mkdirSpy.mockRestore(); + writeSpy.mockRestore(); + renameSpy.mockRestore(); + } + }); + + it("restores removed accounts when flagged sync cleanup fails mid-prune", async () => { + const cliModule = await import("../lib/cli.js"); + const confirmModule = await import("../lib/ui/confirm.js"); + const configModule = await import("../lib/config.js"); + const storageModule = await import("../lib/storage.js"); + const syncModule = await import("../lib/codex-multi-auth-sync.js"); + const logSpy = vi.spyOn(console, "log").mockImplementation(() => {}); + + mockStorage.accounts = [ + { + accountId: "remove-me", + email: "remove@example.com", + refreshToken: "refresh-remove", + addedAt: 1, + lastUsed: 1, + }, + { + accountId: "keep-me", + email: "keep@example.com", + refreshToken: "refresh-keep", + addedAt: 2, + lastUsed: 2, + }, + ]; + mockStorage.activeIndex = 1; + mockStorage.activeIndexByFamily = { codex: 1 }; + mockFlaggedStorage.accounts = [ + { + accountId: "remove-me", + email: "remove@example.com", + refreshToken: "refresh-remove", + flaggedAt: 123, + }, + ]; + + const originalAccounts = cloneMockStorage(); + const originalFlagged = cloneMockFlaggedStorage(); + const { CodexMultiAuthSyncCapacityError } = syncModule; + vi.mocked(configModule.getSyncFromCodexMultiAuthEnabled).mockReturnValue(true); + vi.mocked(cliModule.promptLoginMode) + .mockResolvedValueOnce({ mode: "experimental-sync-now" }) + .mockResolvedValueOnce({ mode: "cancel" }) + .mockResolvedValue({ mode: "cancel" }); + vi.mocked(confirmModule.confirm).mockResolvedValueOnce(true); + vi.mocked(cliModule.promptCodexMultiAuthSyncPrune).mockResolvedValueOnce([0]); + vi.mocked(syncModule.loadCodexMultiAuthSourceStorage).mockResolvedValueOnce({ + rootDir: "/tmp/codex-source", + accountsPath: "/tmp/codex-source/openai-codex-accounts.json", + scope: "global", + storage: { + version: 3, + accounts: [], + activeIndex: 0, + activeIndexByFamily: {}, + }, + }); + vi.mocked(syncModule.previewSyncFromCodexMultiAuth).mockRejectedValueOnce( + new CodexMultiAuthSyncCapacityError({ + rootDir: "/tmp/codex-source", + accountsPath: "/tmp/codex-source/openai-codex-accounts.json", + scope: "global", + currentCount: 2, + sourceCount: 1, + sourceDedupedTotal: 1, + dedupedTotal: 3, + maxAccounts: 2, + needToRemove: 1, + importableNewAccounts: 1, + skippedOverlaps: 0, + suggestedRemovals: [ + { + index: 0, + email: "remove@example.com", + accountLabel: "Remove Me", + refreshToken: "refresh-remove", + organizationId: undefined, + accountId: "remove-me", + isCurrentAccount: false, + score: 100, + reason: "test removal", + }, + ], + }), + ); + vi.mocked(storageModule.withFlaggedAccountsTransaction).mockImplementationOnce(async () => { + throw new Error("flagged remove failed"); + }); + + try { + const autoMethod = plugin.auth.methods[0] as unknown as { + authorize: (inputs?: Record) => Promise<{ instructions: string }>; + }; + + await expect(autoMethod.authorize()).rejects.toThrow("flagged remove failed"); + expect(mockStorage).toMatchObject(originalAccounts); + expect(mockFlaggedStorage).toMatchObject(originalFlagged); + } finally { + logSpy.mockRestore(); + } + }); + }); }); describe("OpenAIOAuthPlugin edge cases", () => { @@ -1805,6 +2326,7 @@ describe("OpenAIOAuthPlugin edge cases", () => { mockStorage.accounts = []; mockStorage.activeIndex = 0; mockStorage.activeIndexByFamily = {}; + mockFlaggedStorage.accounts = []; }); afterEach(() => { @@ -3254,25 +3776,21 @@ describe("OpenAIOAuthPlugin persistAccountPool", () => { lastUsed: Date.now() - 500, }, ]; + mockStorage.accounts = [ + { + accountId: "existing-account", + email: "existing@example.com", + refreshToken: "existing-refresh", + addedAt: Date.now() - 2_000, + lastUsed: Date.now() - 2_000, + }, + ]; + mockFlaggedStorage.accounts = flaggedAccounts.map(cloneFlaggedAccount); vi.mocked(cliModule.promptLoginMode) .mockResolvedValueOnce({ mode: "verify-flagged" }) .mockResolvedValueOnce({ mode: "cancel" }); - vi.mocked(storageModule.loadFlaggedAccounts) - .mockResolvedValueOnce({ - version: 1, - accounts: flaggedAccounts, - }) - .mockResolvedValueOnce({ - version: 1, - accounts: flaggedAccounts, - }) - .mockResolvedValueOnce({ - version: 1, - accounts: [], - }); - vi.mocked(accountsModule.lookupCodexCliTokensByEmail).mockImplementation(async (email) => { if (email === "cache@example.com") { return { @@ -3307,10 +3825,8 @@ describe("OpenAIOAuthPlugin persistAccountPool", () => { expect(authResult.instructions).toBe("Authentication cancelled"); expect(vi.mocked(refreshQueueModule.queuedRefresh)).toHaveBeenCalledTimes(1); - expect(mockStorage.accounts).toHaveLength(2); - expect(new Set(mockStorage.accounts.map((account) => account.organizationId))).toEqual( - new Set(["org-cache", "org-refresh"]), - ); + expect(mockStorage.accounts.some((account) => account.organizationId === "org-cache")).toBe(true); + expect(mockStorage.accounts.some((account) => account.organizationId === "org-refresh")).toBe(true); expect(vi.mocked(storageModule.saveFlaggedAccounts)).toHaveBeenCalledWith({ version: 1, accounts: [], @@ -3326,6 +3842,7 @@ describe("OpenAIOAuthPlugin showToast error handling", () => { ]; mockStorage.activeIndex = 0; mockStorage.activeIndexByFamily = {}; + mockFlaggedStorage.accounts = []; }); afterEach(() => { @@ -3358,6 +3875,7 @@ describe("OpenAIOAuthPlugin event handler edge cases", () => { ]; mockStorage.activeIndex = 0; mockStorage.activeIndexByFamily = {}; + mockFlaggedStorage.accounts = []; }); afterEach(() => { diff --git a/test/sync-prune-backup.test.ts b/test/sync-prune-backup.test.ts index 2c6ddb12..396cc2bd 100644 --- a/test/sync-prune-backup.test.ts +++ b/test/sync-prune-backup.test.ts @@ -3,7 +3,7 @@ import { createSyncPruneBackupPayload } from "../lib/sync-prune-backup.js"; import type { AccountStorageV3 } from "../lib/storage.js"; describe("sync prune backup payload", () => { - it("omits live tokens from the prune backup payload", () => { + it("keeps live tokens in the prune backup payload so crash recovery stays restorable", () => { const storage: AccountStorageV3 = { version: 3, activeIndex: 0, @@ -32,12 +32,16 @@ describe("sync prune backup payload", () => { ], }); - expect(payload.accounts.accounts[0]).not.toHaveProperty("accessToken"); - expect(payload.accounts.accounts[0]).not.toHaveProperty("refreshToken"); - expect(payload.accounts.accounts[0]).not.toHaveProperty("idToken"); - expect(payload.flagged.accounts[0]).not.toHaveProperty("accessToken"); - expect(payload.flagged.accounts[0]).not.toHaveProperty("refreshToken"); - expect(payload.flagged.accounts[0]).not.toHaveProperty("idToken"); + expect(payload.accounts.accounts[0]).toMatchObject({ + refreshToken: "refresh-token", + accessToken: "access-token", + idToken: "id-token", + }); + expect(payload.flagged.accounts[0]).toMatchObject({ + refreshToken: "refresh-token", + accessToken: "flagged-access-token", + idToken: "flagged-id-token", + }); }); it("deep-clones nested metadata so later mutations do not leak into the snapshot", () => { @@ -84,13 +88,16 @@ describe("sync prune backup payload", () => { expect(payload.accounts.accounts[0]?.accountTags).toEqual(["work"]); expect(payload.accounts.accounts[0]?.lastSelectedModelByFamily).toEqual({ codex: "gpt-5.4" }); - expect(payload.accounts.accounts[0]).not.toHaveProperty("idToken"); + expect(payload.accounts.accounts[0]?.refreshToken).toBe("refresh-token"); + expect(payload.accounts.accounts[0]?.accessToken).toBe("access-token"); + expect(payload.accounts.accounts[0]?.idToken).toBe("id-token"); expect(payload.flagged.accounts[0]).toMatchObject({ + refreshToken: "refresh-token", + accessToken: "flagged-access-token", + idToken: "flagged-id-token", metadata: { source: "flagged", }, }); - expect(payload.flagged.accounts[0]).not.toHaveProperty("refreshToken"); - expect(payload.flagged.accounts[0]).not.toHaveProperty("idToken"); }); }); From b213a608150d4dfa62fea49181560a8588b87fe2 Mon Sep 17 00:00:00 2001 From: ndycode Date: Sun, 15 Mar 2026 16:28:39 +0800 Subject: [PATCH 06/15] fix: address final sync review comments --- index.ts | 224 ++++++++++++++++++----------------- lib/cli.ts | 6 +- lib/codex-multi-auth-sync.ts | 7 +- lib/storage.ts | 31 +++++ test/index.test.ts | 113 +++++++++++++++--- 5 files changed, 248 insertions(+), 133 deletions(-) diff --git a/index.ts b/index.ts index 3480e769..232bc37f 100644 --- a/index.ts +++ b/index.ts @@ -126,6 +126,7 @@ import { clearFlaggedAccounts, StorageError, formatStorageErrorHint, + withAccountAndFlaggedStorageTransaction, withFlaggedAccountsTransaction, type AccountStorageV3, type FlaggedAccountMetadataV1, @@ -3444,12 +3445,12 @@ while (attempted.size < Math.max(1, accountCount)) { if (!normalizedAccounts) { throw new Error("Prune backup account snapshot failed validation."); } - await withAccountStorageTransaction(async (_current, persist) => { - await persist(normalizedAccounts); + await withAccountAndFlaggedStorageTransaction(async (_current, persist) => { + await persist.accounts(normalizedAccounts); + await persist.flagged( + restoreFlaggedSnapshot as { version: 1; accounts: FlaggedAccountMetadataV1[] }, + ); }); - await saveFlaggedAccounts( - restoreFlaggedSnapshot as { version: 1; accounts: FlaggedAccountMetadataV1[] }, - ); invalidateAccountManagerCache(); }, }; @@ -3465,20 +3466,33 @@ while (attempted.size < Math.max(1, accountCount)) { index: number; account: AccountStorageV3["accounts"][number]; }> = []; - let rollbackStorage: AccountStorageV3 | null = null; - await withAccountStorageTransaction(async (loadedStorage, persist) => { - const currentStorage = - loadedStorage ?? + await withAccountAndFlaggedStorageTransaction( + async ({ accounts: loadedStorage, flagged: currentFlaggedStorage }, persist) => { + const currentStorage = + loadedStorage ?? ({ version: 3, accounts: [], activeIndex: 0, activeIndexByFamily: {}, } satisfies AccountStorageV3); - removedTargets = currentStorage.accounts - .map((account, index) => ({ index, account })) - .filter((entry) => - targetKeySet.has( + removedTargets = currentStorage.accounts + .map((account, index) => ({ index, account })) + .filter((entry) => + targetKeySet.has( + getSyncRemovalTargetKey({ + refreshToken: entry.account.refreshToken, + organizationId: entry.account.organizationId, + accountId: entry.account.accountId, + }), + ), + ); + if (removedTargets.length === 0) { + return; + } + + const removedFlaggedKeys = new Set( + removedTargets.map((entry) => getSyncRemovalTargetKey({ refreshToken: entry.account.refreshToken, organizationId: entry.account.organizationId, @@ -3486,111 +3500,103 @@ while (attempted.size < Math.max(1, accountCount)) { }), ), ); - if (removedTargets.length === 0) { - return; - } - rollbackStorage = structuredClone(currentStorage); - - const activeAccountIdentity = { - refreshToken: - currentStorage.accounts[currentStorage.activeIndex]?.refreshToken ?? "", - organizationId: - currentStorage.accounts[currentStorage.activeIndex]?.organizationId, - accountId: currentStorage.accounts[currentStorage.activeIndex]?.accountId, - } satisfies SyncRemovalTarget; - const familyActiveIdentities = Object.fromEntries( - MODEL_FAMILIES.map((family) => { - const familyIndex = currentStorage.activeIndexByFamily?.[family] ?? currentStorage.activeIndex; - const familyAccount = currentStorage.accounts[familyIndex]; - return [ - family, - familyAccount - ? ({ - refreshToken: familyAccount.refreshToken, - organizationId: familyAccount.organizationId, - accountId: familyAccount.accountId, - } satisfies SyncRemovalTarget) - : null, - ]; - }), - ) as Partial>; - - currentStorage.accounts = currentStorage.accounts.filter( - (account) => - !targetKeySet.has( - getSyncRemovalTargetKey({ - refreshToken: account.refreshToken, - organizationId: account.organizationId, - accountId: account.accountId, - }), + const nextFlaggedStorage = { + version: 1 as const, + accounts: currentFlaggedStorage.accounts.filter( + (flagged) => + !removedFlaggedKeys.has( + getSyncRemovalTargetKey({ + refreshToken: flagged.refreshToken, + organizationId: flagged.organizationId, + accountId: flagged.accountId, + }), + ), ), - ); - const remappedActiveIndex = findAccountIndexByExactIdentity( - currentStorage.accounts, - activeAccountIdentity, - ); - currentStorage.activeIndex = - remappedActiveIndex >= 0 - ? remappedActiveIndex - : Math.min(currentStorage.activeIndex, Math.max(0, currentStorage.accounts.length - 1)); - currentStorage.activeIndexByFamily = currentStorage.activeIndexByFamily ?? {}; - for (const family of MODEL_FAMILIES) { - const remappedFamilyIndex = findAccountIndexByExactIdentity( - currentStorage.accounts, - familyActiveIdentities[family] ?? null, - ); - currentStorage.activeIndexByFamily[family] = - remappedFamilyIndex >= 0 ? remappedFamilyIndex : currentStorage.activeIndex; - } - clampActiveIndices(currentStorage); - await persist(currentStorage); - }); + }; - if (removedTargets.length > 0) { - const removedFlaggedKeys = new Set( - removedTargets.map((entry) => - getSyncRemovalTargetKey({ - refreshToken: entry.account.refreshToken, - organizationId: entry.account.organizationId, - accountId: entry.account.accountId, + const activeAccountIdentity = { + refreshToken: + currentStorage.accounts[currentStorage.activeIndex]?.refreshToken ?? "", + organizationId: + currentStorage.accounts[currentStorage.activeIndex]?.organizationId, + accountId: currentStorage.accounts[currentStorage.activeIndex]?.accountId, + } satisfies SyncRemovalTarget; + const familyActiveIdentities = Object.fromEntries( + MODEL_FAMILIES.map((family) => { + const familyIndex = + currentStorage.activeIndexByFamily?.[family] ?? currentStorage.activeIndex; + const familyAccount = currentStorage.accounts[familyIndex]; + return [ + family, + familyAccount + ? ({ + refreshToken: familyAccount.refreshToken, + organizationId: familyAccount.organizationId, + accountId: familyAccount.accountId, + } satisfies SyncRemovalTarget) + : null, + ]; }), - ), - ); - try { - await withFlaggedAccountsTransaction(async (currentFlaggedStorage, persist) => { - await persist({ - version: 1, - accounts: currentFlaggedStorage.accounts.filter( - (flagged) => - !removedFlaggedKeys.has( - getSyncRemovalTargetKey({ - refreshToken: flagged.refreshToken, - organizationId: flagged.organizationId, - accountId: flagged.accountId, - }), - ), + ) as Partial>; + + currentStorage.accounts = currentStorage.accounts.filter( + (account) => + !targetKeySet.has( + getSyncRemovalTargetKey({ + refreshToken: account.refreshToken, + organizationId: account.organizationId, + accountId: account.accountId, + }), ), - }); - }); - } catch (flaggedError) { - if (rollbackStorage) { + ); + const remappedActiveIndex = findAccountIndexByExactIdentity( + currentStorage.accounts, + activeAccountIdentity, + ); + currentStorage.activeIndex = + remappedActiveIndex >= 0 + ? remappedActiveIndex + : Math.min( + currentStorage.activeIndex, + Math.max(0, currentStorage.accounts.length - 1), + ); + currentStorage.activeIndexByFamily = currentStorage.activeIndexByFamily ?? {}; + for (const family of MODEL_FAMILIES) { + const remappedFamilyIndex = findAccountIndexByExactIdentity( + currentStorage.accounts, + familyActiveIdentities[family] ?? null, + ); + currentStorage.activeIndexByFamily[family] = + remappedFamilyIndex >= 0 ? remappedFamilyIndex : currentStorage.activeIndex; + } + clampActiveIndices(currentStorage); + + // Persist flagged cleanup before account removal so a crash cannot leave + // flagged entries pointing at deleted accounts. + await persist.flagged(nextFlaggedStorage); + try { + await persist.accounts(currentStorage); + } catch (accountsError) { try { - await withAccountStorageTransaction(async (_current, persist) => { - await persist(rollbackStorage as AccountStorageV3); - }); - } catch (restoreError) { - const flaggedMessage = - flaggedError instanceof Error ? flaggedError.message : String(flaggedError); - const restoreMessage = - restoreError instanceof Error ? restoreError.message : String(restoreError); + await persist.flagged(currentFlaggedStorage); + } catch (restoreFlaggedError) { + const accountsMessage = + accountsError instanceof Error ? accountsError.message : String(accountsError); + const restoreFlaggedMessage = + restoreFlaggedError instanceof Error + ? restoreFlaggedError.message + : String(restoreFlaggedError); throw new Error( - `Failed to remove flagged sync entries after account removal: ${flaggedMessage}; ` + - `failed to restore removed accounts: ${restoreMessage}`, + `Failed to remove sync accounts after flagged cleanup: ${accountsMessage}; ` + + `failed to restore flagged storage: ${restoreFlaggedMessage}`, ); } + throw accountsError; } - throw flaggedError; - } + }, + ); + + if (removedTargets.length > 0) { invalidateAccountManagerCache(); } }; diff --git a/lib/cli.ts b/lib/cli.ts index 667a4dac..17100c4f 100644 --- a/lib/cli.ts +++ b/lib/cli.ts @@ -229,7 +229,7 @@ async function promptLoginModeFallback( while (true) { const answer = await rl.question( - "(a)dd, (f)resh, (c)heck, (d)eep, (v)erify flagged, s(y)nc tools, or (q)uit? [a/f/c/d/v/s/y/q]: ", + "(a)dd, (f)resh, (c)heck, (d)eep, (v)erify flagged, (s)ync tools, or (q)uit? [a/f/c/d/v/s/q]: ", ); const normalized = answer.trim().toLowerCase(); if (normalized === "a" || normalized === "add") return { mode: "add" }; @@ -237,7 +237,7 @@ async function promptLoginModeFallback( if (normalized === "c" || normalized === "check") return { mode: "check" }; if (normalized === "d" || normalized === "deep") return { mode: "deep-check" }; if (normalized === "v" || normalized === "verify") return { mode: "verify-flagged" }; - if (normalized === "s" || normalized === "sync" || normalized === "y") { + if (normalized === "s" || normalized === "sync") { const syncAction = await promptSyncToolsFallback( rl, options.syncFromCodexMultiAuthEnabled === true, @@ -246,7 +246,7 @@ async function promptLoginModeFallback( continue; } if (normalized === "q" || normalized === "quit") return { mode: "cancel" }; - console.log("Please enter one of: a, f, c, d, v, s, y, q."); + console.log("Please enter one of: a, f, c, d, v, s, q."); } } finally { rl.close(); diff --git a/lib/codex-multi-auth-sync.ts b/lib/codex-multi-auth-sync.ts index 17b2a257..84b89299 100644 --- a/lib/codex-multi-auth-sync.ts +++ b/lib/codex-multi-auth-sync.ts @@ -269,12 +269,7 @@ async function withNormalizedImportFile( return result; } catch (error) { await redactNormalizedImportTempFile(tempPath, storage); - try { - await removeNormalizedImportTempDir(tempDir, tempPath, { postSuccessCleanupFailureMode: "warn" }); - } catch (cleanupError) { - const message = cleanupError instanceof Error ? cleanupError.message : String(cleanupError); - logWarn(`Failed to remove temporary codex sync directory ${tempDir}: ${message}`); - } + await removeNormalizedImportTempDir(tempDir, tempPath, { postSuccessCleanupFailureMode: "warn" }); throw error; } }; diff --git a/lib/storage.ts b/lib/storage.ts index 86f1c76c..f536ed7d 100644 --- a/lib/storage.ts +++ b/lib/storage.ts @@ -1157,6 +1157,37 @@ export async function withFlaggedAccountsTransaction( }); } +/** + * Runs `handler` while the shared storage lock is held and exposes unlocked + * persist callbacks for both account files. Callers are responsible for + * choosing a write order that preserves invariants if the process crashes + * between the two atomic file writes. + */ +export async function withAccountAndFlaggedStorageTransaction( + handler: ( + current: { + accounts: AccountStorageV3 | null; + flagged: FlaggedAccountStorageV1; + }, + persist: { + accounts: (storage: AccountStorageV3) => Promise; + flagged: (storage: FlaggedAccountStorageV1) => Promise; + }, + ) => Promise, +): Promise { + return withStorageLock(async () => { + const accounts = await loadAccountsInternal(saveAccountsUnlocked); + const flagged = await loadFlaggedAccountsUnlocked(accounts); + return handler( + { accounts, flagged }, + { + accounts: saveAccountsUnlocked, + flagged: saveFlaggedAccountsUnlocked, + }, + ); + }); +} + export async function loadAccountAndFlaggedStorageSnapshot(): Promise<{ accounts: AccountStorageV3 | null; flagged: FlaggedAccountStorageV1; diff --git a/test/index.test.ts b/test/index.test.ts index 699fa730..52ecaaa4 100644 --- a/test/index.test.ts +++ b/test/index.test.ts @@ -342,6 +342,40 @@ vi.mock("../lib/storage.js", () => ({ return await callback(loadedStorage, persist); }, ), + withAccountAndFlaggedStorageTransaction: vi.fn( + async ( + callback: ( + current: { + accounts: typeof mockStorage; + flagged: typeof mockFlaggedStorage; + }, + persist: { + accounts: (nextStorage: typeof mockStorage) => Promise; + flagged: (nextStorage: typeof mockFlaggedStorage) => Promise; + }, + ) => Promise, + ) => { + const persist = { + accounts: async (nextStorage: typeof mockStorage) => { + mockStorage.version = nextStorage.version; + mockStorage.accounts = nextStorage.accounts.map(cloneAccount); + mockStorage.activeIndex = nextStorage.activeIndex; + mockStorage.activeIndexByFamily = { ...nextStorage.activeIndexByFamily }; + }, + flagged: async (nextStorage: typeof mockFlaggedStorage) => { + mockFlaggedStorage.version = nextStorage.version; + mockFlaggedStorage.accounts = nextStorage.accounts.map(cloneFlaggedAccount); + }, + }; + return await callback( + { + accounts: cloneMockStorage(), + flagged: cloneMockFlaggedStorage(), + }, + persist, + ); + }, + ), clearAccounts: vi.fn(async () => {}), setStoragePath: vi.fn(), exportAccounts: vi.fn(async () => {}), @@ -524,6 +558,7 @@ beforeEach(async () => { vi.mocked(storageModule.loadAccounts).mockReset(); vi.mocked(storageModule.saveAccounts).mockReset(); vi.mocked(storageModule.withAccountStorageTransaction).mockReset(); + vi.mocked(storageModule.withAccountAndFlaggedStorageTransaction).mockReset(); vi.mocked(storageModule.loadAccountAndFlaggedStorageSnapshot).mockReset(); vi.mocked(storageModule.loadFlaggedAccounts).mockReset(); vi.mocked(storageModule.saveFlaggedAccounts).mockReset(); @@ -600,6 +635,40 @@ beforeEach(async () => { return await callback(loadedStorage, persist); }, ); + vi.mocked(storageModule.withAccountAndFlaggedStorageTransaction).mockImplementation( + async ( + callback: ( + current: { + accounts: typeof mockStorage; + flagged: typeof mockFlaggedStorage; + }, + persist: { + accounts: (nextStorage: typeof mockStorage) => Promise; + flagged: (nextStorage: typeof mockFlaggedStorage) => Promise; + }, + ) => Promise, + ) => { + const persist = { + accounts: async (nextStorage: typeof mockStorage) => { + mockStorage.version = nextStorage.version; + mockStorage.accounts = nextStorage.accounts.map(cloneAccount); + mockStorage.activeIndex = nextStorage.activeIndex; + mockStorage.activeIndexByFamily = { ...nextStorage.activeIndexByFamily }; + }, + flagged: async (nextStorage: typeof mockFlaggedStorage) => { + mockFlaggedStorage.version = nextStorage.version; + mockFlaggedStorage.accounts = nextStorage.accounts.map(cloneFlaggedAccount); + }, + }; + return await callback( + { + accounts: cloneMockStorage(), + flagged: cloneMockFlaggedStorage(), + }, + persist, + ); + }, + ); vi.mocked(storageModule.loadAccountAndFlaggedStorageSnapshot).mockImplementation(async () => ({ accounts: cloneMockStorage(), flagged: cloneMockFlaggedStorage(), @@ -2217,13 +2286,12 @@ describe("OpenAIOAuthPlugin", () => { } }); - it("restores removed accounts when flagged sync cleanup fails mid-prune", async () => { + it("restores flagged storage when account removal fails after flagged cleanup", async () => { const cliModule = await import("../lib/cli.js"); const confirmModule = await import("../lib/ui/confirm.js"); const configModule = await import("../lib/config.js"); const storageModule = await import("../lib/storage.js"); const syncModule = await import("../lib/codex-multi-auth-sync.js"); - const logSpy = vi.spyOn(console, "log").mockImplementation(() => {}); mockStorage.accounts = [ { @@ -2301,21 +2369,36 @@ describe("OpenAIOAuthPlugin", () => { ], }), ); - vi.mocked(storageModule.withFlaggedAccountsTransaction).mockImplementationOnce(async () => { - throw new Error("flagged remove failed"); - }); + vi.mocked(storageModule.withAccountAndFlaggedStorageTransaction).mockImplementationOnce( + async (callback) => { + const persist = { + flagged: async (nextStorage: typeof mockFlaggedStorage) => { + mockFlaggedStorage.version = nextStorage.version; + mockFlaggedStorage.accounts = nextStorage.accounts.map(cloneFlaggedAccount); + }, + accounts: async (_nextStorage: typeof mockStorage) => { + throw new Error("account remove failed"); + }, + }; + return await callback( + { + accounts: cloneMockStorage(), + flagged: cloneMockFlaggedStorage(), + }, + persist, + ); + }, + ); - try { - const autoMethod = plugin.auth.methods[0] as unknown as { - authorize: (inputs?: Record) => Promise<{ instructions: string }>; - }; + const autoMethod = plugin.auth.methods[0] as unknown as { + authorize: (inputs?: Record) => Promise<{ instructions: string }>; + }; - await expect(autoMethod.authorize()).rejects.toThrow("flagged remove failed"); - expect(mockStorage).toMatchObject(originalAccounts); - expect(mockFlaggedStorage).toMatchObject(originalFlagged); - } finally { - logSpy.mockRestore(); - } + await expect(autoMethod.authorize()).rejects.toThrow("account remove failed"); + expect(mockStorage).toMatchObject(originalAccounts); + expect(mockFlaggedStorage).toMatchObject(originalFlagged); + expect(vi.mocked(storageModule.withAccountAndFlaggedStorageTransaction)).toHaveBeenCalled(); + expect(vi.mocked(storageModule.withFlaggedAccountsTransaction)).not.toHaveBeenCalled(); }); }); }); From d9a0ea36923ab522152d9d59ce9ee537bd51c435 Mon Sep 17 00:00:00 2001 From: ndycode Date: Sun, 15 Mar 2026 16:32:40 +0800 Subject: [PATCH 07/15] fix: cover sync prune restore after sync failure --- lib/sync-prune-backup.ts | 3 ++ test/index.test.ts | 106 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 109 insertions(+) diff --git a/lib/sync-prune-backup.ts b/lib/sync-prune-backup.ts index 4431c314..345d9e3d 100644 --- a/lib/sync-prune-backup.ts +++ b/lib/sync-prune-backup.ts @@ -13,6 +13,9 @@ export function createSyncPruneBackupPayload( accounts: AccountStorageV3; flagged: FlaggedSnapshot; } { + // Intentionally retain live tokens so a mid-sync crash can fully restore pruned accounts. + // The backup is stored under the user's config home; on Windows its ACLs are the real boundary + // because the later write path's `mode: 0o600` hint is not strictly enforced there. return { version: 1, accounts: structuredClone({ diff --git a/test/index.test.ts b/test/index.test.ts index 52ecaaa4..83cda20c 100644 --- a/test/index.test.ts +++ b/test/index.test.ts @@ -2400,6 +2400,112 @@ describe("OpenAIOAuthPlugin", () => { expect(vi.mocked(storageModule.withAccountAndFlaggedStorageTransaction)).toHaveBeenCalled(); expect(vi.mocked(storageModule.withFlaggedAccountsTransaction)).not.toHaveBeenCalled(); }); + + it("restores sync prune backup when sync fails after successful prune removal", async () => { + const cliModule = await import("../lib/cli.js"); + const confirmModule = await import("../lib/ui/confirm.js"); + const configModule = await import("../lib/config.js"); + const storageModule = await import("../lib/storage.js"); + const syncModule = await import("../lib/codex-multi-auth-sync.js"); + + mockStorage.accounts = [ + { + accountId: "remove-me", + email: "remove@example.com", + refreshToken: "refresh-remove", + accessToken: "access-remove", + idToken: "id-remove", + addedAt: 1, + lastUsed: 1, + }, + { + accountId: "keep-me", + email: "keep@example.com", + refreshToken: "refresh-keep", + accessToken: "access-keep", + idToken: "id-keep", + addedAt: 2, + lastUsed: 2, + }, + ]; + mockStorage.activeIndex = 1; + mockStorage.activeIndexByFamily = { codex: 1 }; + mockFlaggedStorage.accounts = [ + { + accountId: "remove-me", + email: "remove@example.com", + refreshToken: "refresh-remove", + accessToken: "flagged-access-remove", + idToken: "flagged-id-remove", + flaggedAt: 123, + addedAt: 1, + lastUsed: 1, + }, + ]; + + const originalAccounts = cloneMockStorage(); + const originalFlagged = cloneMockFlaggedStorage(); + const { CodexMultiAuthSyncCapacityError } = syncModule; + vi.mocked(configModule.getSyncFromCodexMultiAuthEnabled).mockReturnValue(true); + vi.mocked(cliModule.promptLoginMode) + .mockResolvedValueOnce({ mode: "experimental-sync-now" }) + .mockResolvedValueOnce({ mode: "cancel" }) + .mockResolvedValue({ mode: "cancel" }); + vi.mocked(cliModule.promptCodexMultiAuthSyncPrune).mockResolvedValueOnce([0]); + vi.mocked(confirmModule.confirm).mockResolvedValue(true); + vi.mocked(syncModule.previewSyncFromCodexMultiAuth) + .mockRejectedValueOnce( + new CodexMultiAuthSyncCapacityError({ + rootDir: "/tmp/codex-source", + accountsPath: "/tmp/codex-source/openai-codex-accounts.json", + scope: "global", + currentCount: 2, + sourceCount: 1, + sourceDedupedTotal: 1, + dedupedTotal: 3, + maxAccounts: 2, + needToRemove: 1, + importableNewAccounts: 1, + skippedOverlaps: 0, + suggestedRemovals: [ + { + index: 0, + email: "remove@example.com", + accountLabel: "Remove Me", + refreshToken: "refresh-remove", + organizationId: undefined, + accountId: "remove-me", + isCurrentAccount: false, + score: 100, + reason: "test removal", + }, + ], + }), + ) + .mockResolvedValueOnce({ + rootDir: "/tmp/codex-source", + accountsPath: "/tmp/codex-source/openai-codex-accounts.json", + scope: "global", + imported: 1, + skipped: 0, + total: 2, + }); + vi.mocked(syncModule.syncFromCodexMultiAuth).mockRejectedValueOnce( + new Error("sync failed after prune"), + ); + + const autoMethod = plugin.auth.methods[0] as unknown as { + authorize: (inputs?: Record) => Promise<{ instructions: string }>; + }; + + const result = await autoMethod.authorize(); + expect(result.instructions).toBe("Authentication cancelled"); + expect(mockStorage).toMatchObject(originalAccounts); + expect(mockFlaggedStorage).toMatchObject(originalFlagged); + expect(vi.mocked(storageModule.loadAccountAndFlaggedStorageSnapshot)).toHaveBeenCalledTimes(1); + expect(vi.mocked(storageModule.withAccountAndFlaggedStorageTransaction)).toHaveBeenCalledTimes(2); + expect(vi.mocked(syncModule.syncFromCodexMultiAuth)).toHaveBeenCalledTimes(1); + }); }); }); From ba92bd9b8e64a737ae249bd95a5929da611b2b2d Mon Sep 17 00:00:00 2001 From: ndycode Date: Sun, 15 Mar 2026 17:10:34 +0800 Subject: [PATCH 08/15] fix: address remaining sync review findings --- index.ts | 55 ++++++--- lib/codex-multi-auth-sync.ts | 16 ++- test/codex-multi-auth-sync.test.ts | 22 ++-- test/index.test.ts | 174 ++++++++++++++++++++++++++++- 4 files changed, 239 insertions(+), 28 deletions(-) diff --git a/index.ts b/index.ts index 232bc37f..73543e1a 100644 --- a/index.ts +++ b/index.ts @@ -3374,6 +3374,10 @@ while (attempted.size < Math.max(1, accountCount)) { return `${target.organizationId ?? ""}|${target.accountId ?? ""}|${target.refreshToken}`; }; + const getSyncRemovalRefreshTokenKey = (refreshToken: string | undefined): string => { + return refreshToken?.trim() ?? ""; + }; + const findAccountIndexByExactIdentity = ( accounts: AccountStorageV3["accounts"], target: SyncRemovalTarget | null | undefined, @@ -3445,11 +3449,38 @@ while (attempted.size < Math.max(1, accountCount)) { if (!normalizedAccounts) { throw new Error("Prune backup account snapshot failed validation."); } - await withAccountAndFlaggedStorageTransaction(async (_current, persist) => { + await withAccountAndFlaggedStorageTransaction(async (current, persist) => { + const rollbackAccounts = + current.accounts ?? + ({ + version: 3, + accounts: [], + activeIndex: 0, + activeIndexByFamily: {}, + } satisfies AccountStorageV3); await persist.accounts(normalizedAccounts); - await persist.flagged( - restoreFlaggedSnapshot as { version: 1; accounts: FlaggedAccountMetadataV1[] }, - ); + try { + await persist.flagged( + restoreFlaggedSnapshot as { + version: 1; + accounts: FlaggedAccountMetadataV1[]; + }, + ); + } catch (flaggedError) { + try { + await persist.accounts(rollbackAccounts); + } catch (rollbackError) { + const flaggedMessage = + flaggedError instanceof Error ? flaggedError.message : String(flaggedError); + const rollbackMessage = + rollbackError instanceof Error ? rollbackError.message : String(rollbackError); + throw new Error( + `Failed to restore sync prune flagged storage: ${flaggedMessage}; ` + + `failed to roll back account restore: ${rollbackMessage}`, + ); + } + throw flaggedError; + } }); invalidateAccountManagerCache(); }, @@ -3491,25 +3522,17 @@ while (attempted.size < Math.max(1, accountCount)) { return; } - const removedFlaggedKeys = new Set( + const removedRefreshTokens = new Set( removedTargets.map((entry) => - getSyncRemovalTargetKey({ - refreshToken: entry.account.refreshToken, - organizationId: entry.account.organizationId, - accountId: entry.account.accountId, - }), + getSyncRemovalRefreshTokenKey(entry.account.refreshToken), ), ); const nextFlaggedStorage = { version: 1 as const, accounts: currentFlaggedStorage.accounts.filter( (flagged) => - !removedFlaggedKeys.has( - getSyncRemovalTargetKey({ - refreshToken: flagged.refreshToken, - organizationId: flagged.organizationId, - accountId: flagged.accountId, - }), + !removedRefreshTokens.has( + getSyncRemovalRefreshTokenKey(flagged.refreshToken), ), ), }; diff --git a/lib/codex-multi-auth-sync.ts b/lib/codex-multi-auth-sync.ts index 84b89299..ab1da172 100644 --- a/lib/codex-multi-auth-sync.ts +++ b/lib/codex-multi-auth-sync.ts @@ -570,12 +570,24 @@ function normalizeIdentity(value: string | undefined): string | undefined { return trimmed && trimmed.length > 0 ? trimmed.toLowerCase() : undefined; } +function toCleanupExactIdentityKey(account: { + organizationId?: string; + accountId?: string; + refreshToken: string; +}): string | undefined { + const refreshToken = normalizeTrimmedIdentity(account.refreshToken); + if (!refreshToken) return undefined; + return `exact:${normalizeIdentity(account.organizationId) ?? ""}|${normalizeIdentity(account.accountId) ?? ""}|${refreshToken}`; +} + function toCleanupIdentityKeys(account: { organizationId?: string; accountId?: string; refreshToken: string; }): string[] { const keys: string[] = []; + const exactIdentity = toCleanupExactIdentityKey(account); + if (exactIdentity) keys.push(exactIdentity); const organizationId = normalizeIdentity(account.organizationId); if (organizationId) keys.push(`org:${organizationId}`); const accountId = normalizeIdentity(account.accountId); @@ -1128,13 +1140,13 @@ function buildCodexMultiAuthOverlapCleanupPlan(existing: AccountStorageV3): { const removed = Math.max(0, before - after); const originalAccountsByKey = new Map(); for (const account of existing.accounts) { - const key = toCleanupIdentityKeys(account)[0]; + const key = toCleanupExactIdentityKey(account); if (key) { originalAccountsByKey.set(key, account); } } const updated = normalized.accounts.reduce((count, account) => { - const key = toCleanupIdentityKeys(account)[0]; + const key = toCleanupExactIdentityKey(account); if (!key) return count; const original = originalAccountsByKey.get(key); if (!original) return count; diff --git a/test/codex-multi-auth-sync.test.ts b/test/codex-multi-auth-sync.test.ts index 69b9e274..d5b86887 100644 --- a/test/codex-multi-auth-sync.test.ts +++ b/test/codex-multi-auth-sync.test.ts @@ -1873,6 +1873,9 @@ describe("codex-multi-auth sync", () => { it("remaps active indices when synced overlap cleanup reorders accounts", async () => { const storageModule = await import("../lib/storage.js"); const persist = vi.fn(async (_next: AccountStorageV3) => {}); + vi.mocked(storageModule.deduplicateAccounts).mockImplementationOnce( + (accounts: AccountStorageV3["accounts"]) => [accounts[1], accounts[0]].filter(Boolean), + ); vi.mocked(storageModule.withAccountStorageTransaction).mockImplementationOnce(async (handler) => handler( { @@ -1881,21 +1884,22 @@ describe("codex-multi-auth sync", () => { activeIndexByFamily: { codex: 0 }, accounts: [ { - accountId: "org-sync", - organizationId: "org-sync", + accountId: "sync-primary", + organizationId: "shared-org", accountIdSource: "org", accountTags: ["codex-multi-auth-sync"], - email: "sync@example.com", - refreshToken: "sync-token", + email: "primary@example.com", + refreshToken: "sync-token-primary", addedAt: 3, lastUsed: 3, }, { - accountId: "org-local", - organizationId: "org-local", + accountId: "sync-sibling", + organizationId: "shared-org", accountIdSource: "org", - email: "local@example.com", - refreshToken: "local-token", + accountTags: ["codex-multi-auth-sync"], + email: "sibling@example.com", + refreshToken: "sync-token-sibling", addedAt: 4, lastUsed: 4, }, @@ -1912,7 +1916,7 @@ describe("codex-multi-auth sync", () => { if (!saved) { throw new Error("Expected persisted overlap cleanup result"); } - expect(saved.accounts.map((account) => account.accountId)).toEqual(["org-local", "org-sync"]); + expect(saved.accounts.map((account) => account.accountId)).toEqual(["sync-sibling", "sync-primary"]); expect(saved.activeIndex).toBe(1); expect(saved.activeIndexByFamily?.codex).toBe(1); }); diff --git a/test/index.test.ts b/test/index.test.ts index 83cda20c..794e5774 100644 --- a/test/index.test.ts +++ b/test/index.test.ts @@ -2198,7 +2198,8 @@ describe("OpenAIOAuthPlugin", () => { mockStorage.activeIndexByFamily = { codex: 1 }; mockFlaggedStorage.accounts = [ { - accountId: "remove-me", + accountId: "flagged-remove-me", + organizationId: "flagged-org", email: "remove@example.com", refreshToken: "refresh-remove", accessToken: "flagged-access-remove", @@ -2277,6 +2278,7 @@ describe("OpenAIOAuthPlugin", () => { expect(backupContent).toContain("\"idToken\": \"id-remove\""); expect(backupContent).toContain("\"accessToken\": \"flagged-access-remove\""); expect(backupContent).toContain("\"idToken\": \"flagged-id-remove\""); + expect(mockFlaggedStorage.accounts).toHaveLength(0); expect(renameSpy).toHaveBeenCalled(); expect(mkdirSpy).toHaveBeenCalled(); } finally { @@ -2506,6 +2508,176 @@ describe("OpenAIOAuthPlugin", () => { expect(vi.mocked(storageModule.withAccountAndFlaggedStorageTransaction)).toHaveBeenCalledTimes(2); expect(vi.mocked(syncModule.syncFromCodexMultiAuth)).toHaveBeenCalledTimes(1); }); + + it("rolls back account restore when flagged restore fails", async () => { + const cliModule = await import("../lib/cli.js"); + const confirmModule = await import("../lib/ui/confirm.js"); + const configModule = await import("../lib/config.js"); + const loggerModule = await import("../lib/logger.js"); + const storageModule = await import("../lib/storage.js"); + const syncModule = await import("../lib/codex-multi-auth-sync.js"); + + mockStorage.accounts = [ + { + accountId: "remove-me", + email: "remove@example.com", + refreshToken: "refresh-remove", + accessToken: "access-remove", + idToken: "id-remove", + addedAt: 1, + lastUsed: 1, + }, + { + accountId: "keep-me", + email: "keep@example.com", + refreshToken: "refresh-keep", + accessToken: "access-keep", + idToken: "id-keep", + addedAt: 2, + lastUsed: 2, + }, + ]; + mockStorage.activeIndex = 1; + mockStorage.activeIndexByFamily = { codex: 1 }; + mockFlaggedStorage.accounts = [ + { + accountId: "flagged-remove-me", + organizationId: "flagged-org", + email: "remove@example.com", + refreshToken: "refresh-remove", + accessToken: "flagged-access-remove", + idToken: "flagged-id-remove", + flaggedAt: 123, + addedAt: 1, + lastUsed: 1, + }, + ]; + + const { CodexMultiAuthSyncCapacityError } = syncModule; + vi.mocked(configModule.getSyncFromCodexMultiAuthEnabled).mockReturnValue(true); + vi.mocked(cliModule.promptLoginMode) + .mockResolvedValueOnce({ mode: "experimental-sync-now" }) + .mockResolvedValueOnce({ mode: "cancel" }) + .mockResolvedValue({ mode: "cancel" }); + vi.mocked(cliModule.promptCodexMultiAuthSyncPrune).mockResolvedValueOnce([0]); + vi.mocked(confirmModule.confirm).mockResolvedValue(true); + vi.mocked(syncModule.previewSyncFromCodexMultiAuth) + .mockRejectedValueOnce( + new CodexMultiAuthSyncCapacityError({ + rootDir: "/tmp/codex-source", + accountsPath: "/tmp/codex-source/openai-codex-accounts.json", + scope: "global", + currentCount: 2, + sourceCount: 1, + sourceDedupedTotal: 1, + dedupedTotal: 3, + maxAccounts: 2, + needToRemove: 1, + importableNewAccounts: 1, + skippedOverlaps: 0, + suggestedRemovals: [ + { + index: 0, + email: "remove@example.com", + accountLabel: "Remove Me", + refreshToken: "refresh-remove", + organizationId: undefined, + accountId: "remove-me", + isCurrentAccount: false, + score: 100, + reason: "test removal", + }, + ], + }), + ) + .mockResolvedValueOnce({ + rootDir: "/tmp/codex-source", + accountsPath: "/tmp/codex-source/openai-codex-accounts.json", + scope: "global", + imported: 1, + skipped: 0, + total: 2, + }); + vi.mocked(syncModule.syncFromCodexMultiAuth).mockRejectedValueOnce( + new Error("sync failed after prune"), + ); + + const prunedAccounts = { + version: 3 as const, + accounts: [ + { + accountId: "keep-me", + email: "keep@example.com", + refreshToken: "refresh-keep", + accessToken: "access-keep", + idToken: "id-keep", + addedAt: 2, + lastUsed: 2, + }, + ], + activeIndex: 0, + activeIndexByFamily: { codex: 0 }, + }; + const prunedFlagged = { + version: 1 as const, + accounts: [] as Array<(typeof mockFlaggedStorage.accounts)[number]>, + }; + let transactionCalls = 0; + vi.mocked(storageModule.withAccountAndFlaggedStorageTransaction).mockImplementation( + async (callback) => { + transactionCalls += 1; + if (transactionCalls === 2) { + return await callback( + { + accounts: cloneMockStorage(), + flagged: cloneMockFlaggedStorage(), + }, + { + accounts: async (nextStorage: typeof mockStorage) => { + mockStorage.version = nextStorage.version; + mockStorage.accounts = nextStorage.accounts.map(cloneAccount); + mockStorage.activeIndex = nextStorage.activeIndex; + mockStorage.activeIndexByFamily = { ...nextStorage.activeIndexByFamily }; + }, + flagged: async (_nextStorage: typeof mockFlaggedStorage) => { + throw new Error("flagged restore failed"); + }, + }, + ); + } + return await callback( + { + accounts: cloneMockStorage(), + flagged: cloneMockFlaggedStorage(), + }, + { + accounts: async (nextStorage: typeof mockStorage) => { + mockStorage.version = nextStorage.version; + mockStorage.accounts = nextStorage.accounts.map(cloneAccount); + mockStorage.activeIndex = nextStorage.activeIndex; + mockStorage.activeIndexByFamily = { ...nextStorage.activeIndexByFamily }; + }, + flagged: async (nextStorage: typeof mockFlaggedStorage) => { + mockFlaggedStorage.version = nextStorage.version; + mockFlaggedStorage.accounts = nextStorage.accounts.map(cloneFlaggedAccount); + }, + }, + ); + }, + ); + + const autoMethod = plugin.auth.methods[0] as unknown as { + authorize: (inputs?: Record) => Promise<{ instructions: string }>; + }; + + const result = await autoMethod.authorize(); + expect(result.instructions).toBe("Authentication cancelled"); + expect(mockStorage).toMatchObject(prunedAccounts); + expect(mockFlaggedStorage).toEqual(prunedFlagged); + expect(vi.mocked(loggerModule.logWarn)).toHaveBeenCalledWith( + expect.stringContaining("Failed to restore sync prune backup: flagged restore failed"), + ); + }); }); }); From 1c5f12727dd5b273ff2390b31fda723ed576e04c Mon Sep 17 00:00:00 2001 From: ndycode Date: Sun, 15 Mar 2026 17:33:24 +0800 Subject: [PATCH 09/15] fix: clean up sync prune backups --- index.ts | 98 +++++++++++++++++++++++++++-- lib/codex-multi-auth-sync.ts | 9 +-- test/storage.test.ts | 115 +++++++++++++++++++++++++++++++++++ 3 files changed, 213 insertions(+), 9 deletions(-) diff --git a/index.ts b/index.ts index 73543e1a..2724141a 100644 --- a/index.ts +++ b/index.ts @@ -25,7 +25,7 @@ import { tool } from "@opencode-ai/plugin/tool"; import { promises as fsPromises } from "node:fs"; -import { dirname } from "node:path"; +import { dirname, join } from "node:path"; import type { Plugin, PluginInput } from "@opencode-ai/plugin"; import type { Auth } from "@opencode-ai/sdk"; import { @@ -3405,9 +3405,55 @@ while (attempted.size < Math.max(1, accountCount)) { } }; + const SYNC_PRUNE_BACKUP_PREFIX = "codex-sync-prune-backup"; + const SYNC_PRUNE_BACKUP_RETAIN_COUNT = 2; + + const pruneOldSyncPruneBackups = async ( + backupDir: string, + keepPaths: string[] = [], + ): Promise => { + const entries = await fsPromises.readdir(backupDir, { withFileTypes: true }).catch((error) => { + const code = (error as NodeJS.ErrnoException).code; + if (code === "ENOENT") { + return null; + } + throw error; + }); + if (!entries) return; + + const keepSet = new Set(keepPaths); + const staleBackupPaths = entries + .filter( + (entry) => + entry.isFile() && + entry.name.startsWith(`${SYNC_PRUNE_BACKUP_PREFIX}-`) && + entry.name.endsWith(".json"), + ) + .map((entry) => join(backupDir, entry.name)) + .filter((candidatePath) => !keepSet.has(candidatePath)) + .sort((left, right) => right.localeCompare(left)) + .slice(SYNC_PRUNE_BACKUP_RETAIN_COUNT); + + for (const staleBackupPath of staleBackupPaths) { + try { + await fsPromises.unlink(staleBackupPath); + } catch (error) { + const code = (error as NodeJS.ErrnoException).code; + if (code === "ENOENT") { + continue; + } + const message = error instanceof Error ? error.message : String(error); + logWarn( + `[${PLUGIN_NAME}] Failed to prune stale sync prune backup ${staleBackupPath}: ${message}`, + ); + } + } + }; + const createSyncPruneBackup = async (): Promise<{ backupPath: string; restore: () => Promise; + cleanup: () => Promise; }> => { const { accounts: loadedAccountsStorage, flagged: currentFlaggedStorage } = await loadAccountAndFlaggedStorageSnapshot(); @@ -3419,8 +3465,9 @@ while (attempted.size < Math.max(1, accountCount)) { activeIndex: 0, activeIndexByFamily: {}, } satisfies AccountStorageV3); - const backupPath = createTimestampedBackupPath("codex-sync-prune-backup"); - await fsPromises.mkdir(dirname(backupPath), { recursive: true }); + const backupPath = createTimestampedBackupPath(SYNC_PRUNE_BACKUP_PREFIX); + const backupDir = dirname(backupPath); + await fsPromises.mkdir(backupDir, { recursive: true }); const backupPayload = createSyncPruneBackupPayload( currentAccountsStorage, currentFlaggedStorage, @@ -3434,6 +3481,13 @@ while (attempted.size < Math.max(1, accountCount)) { mode: 0o600, }); await fsPromises.rename(tempBackupPath, backupPath); + await pruneOldSyncPruneBackups(backupDir, [backupPath]).catch((cleanupError) => { + const cleanupMessage = + cleanupError instanceof Error ? cleanupError.message : String(cleanupError); + logWarn( + `[${PLUGIN_NAME}] Failed to prune old sync prune backups in ${backupDir}: ${cleanupMessage}`, + ); + }); } catch (error) { try { await fsPromises.unlink(tempBackupPath); @@ -3484,6 +3538,17 @@ while (attempted.size < Math.max(1, accountCount)) { }); invalidateAccountManagerCache(); }, + cleanup: async () => { + try { + await fsPromises.unlink(backupPath); + } catch (error) { + const code = (error as NodeJS.ErrnoException).code; + if (code !== "ENOENT") { + throw error; + } + } + await pruneOldSyncPruneBackups(backupDir); + }, }; }; @@ -3665,12 +3730,35 @@ while (attempted.size < Math.max(1, accountCount)) { return; } - let pruneBackup: { backupPath: string; restore: () => Promise } | null = null; + let pruneBackup: { + backupPath: string; + restore: () => Promise; + cleanup: () => Promise; + } | null = null; const restorePruneBackup = async (): Promise => { const currentBackup = pruneBackup; if (!currentBackup) return; await currentBackup.restore(); pruneBackup = null; + await currentBackup.cleanup().catch((cleanupError) => { + const cleanupMessage = + cleanupError instanceof Error ? cleanupError.message : String(cleanupError); + logWarn( + `[${PLUGIN_NAME}] Failed to delete sync prune backup ${currentBackup.backupPath}: ${cleanupMessage}`, + ); + }); + }; + const cleanupPruneBackup = async (): Promise => { + const currentBackup = pruneBackup; + if (!currentBackup) return; + pruneBackup = null; + await currentBackup.cleanup().catch((cleanupError) => { + const cleanupMessage = + cleanupError instanceof Error ? cleanupError.message : String(cleanupError); + logWarn( + `[${PLUGIN_NAME}] Failed to delete sync prune backup ${currentBackup.backupPath}: ${cleanupMessage}`, + ); + }); }; while (true) { @@ -3695,7 +3783,7 @@ while (attempted.size < Math.max(1, accountCount)) { } const result = await syncFromCodexMultiAuth(process.cwd(), loadedSource); - pruneBackup = null; + await cleanupPruneBackup(); invalidateAccountManagerCache(); const backupLabel = result.backupStatus === "created" diff --git a/lib/codex-multi-auth-sync.ts b/lib/codex-multi-auth-sync.ts index ab1da172..bf1dfeb6 100644 --- a/lib/codex-multi-auth-sync.ts +++ b/lib/codex-multi-auth-sync.ts @@ -121,7 +121,7 @@ interface PreparedCodexMultiAuthPreviewStorage { const TEMP_CLEANUP_RETRY_DELAYS_MS = [100, 250, 500] as const; const STALE_TEMP_CLEANUP_RETRY_DELAY_MS = 150; -const STALE_TEMP_SWEEP_RETRYABLE_CODES = new Set(["EBUSY", "EAGAIN", "EACCES", "EPERM", "ENOTEMPTY"]); +const TEMP_CLEANUP_RETRYABLE_CODES = new Set(["EBUSY", "EAGAIN", "EACCES", "EPERM", "ENOTEMPTY"]); function sleepAsync(ms: number): Promise { return new Promise((resolve) => setTimeout(resolve, ms)); @@ -132,7 +132,6 @@ async function removeNormalizedImportTempDir( tempPath: string, options: NormalizedImportFileOptions, ): Promise { - const retryableCodes = new Set(["EBUSY", "EAGAIN", "ENOTEMPTY", "EACCES", "EPERM"]); let lastMessage = "unknown cleanup failure"; for (let attempt = 0; attempt <= TEMP_CLEANUP_RETRY_DELAYS_MS.length; attempt += 1) { try { @@ -141,7 +140,7 @@ async function removeNormalizedImportTempDir( } catch (cleanupError) { const code = (cleanupError as NodeJS.ErrnoException).code; lastMessage = cleanupError instanceof Error ? cleanupError.message : String(cleanupError); - if ((!code || retryableCodes.has(code)) && attempt < TEMP_CLEANUP_RETRY_DELAYS_MS.length) { + if ((!code || TEMP_CLEANUP_RETRYABLE_CODES.has(code)) && attempt < TEMP_CLEANUP_RETRY_DELAYS_MS.length) { const delayMs = TEMP_CLEANUP_RETRY_DELAYS_MS[attempt]; if (delayMs !== undefined) { await sleepAsync(delayMs); @@ -307,7 +306,7 @@ async function cleanupStaleNormalizedImportTempDirs( continue; } let message = error instanceof Error ? error.message : String(error); - if (code && STALE_TEMP_SWEEP_RETRYABLE_CODES.has(code)) { + if (code && TEMP_CLEANUP_RETRYABLE_CODES.has(code)) { await sleepAsync(STALE_TEMP_CLEANUP_RETRY_DELAY_MS); try { await fs.rm(candidateDir, { recursive: true, force: true }); @@ -1225,6 +1224,8 @@ export async function cleanupCodexMultiAuthSyncedOverlaps( await fs.mkdir(dirname(backupPath), { recursive: true }); const tempBackupPath = `${backupPath}.${Date.now()}.${Math.random().toString(36).slice(2, 8)}.tmp`; try { + // Keep live tokens here so overlap cleanup can fully restore the source file; + // Windows relies on home-directory ACLs because mode 0o600 is not enforced. await fs.writeFile(tempBackupPath, `${JSON.stringify(fallback, null, 2)}\n`, { encoding: "utf-8", mode: 0o600, diff --git a/test/storage.test.ts b/test/storage.test.ts index 2768b1dc..fcfa4b76 100644 --- a/test/storage.test.ts +++ b/test/storage.test.ts @@ -22,6 +22,7 @@ import { previewImportAccountsWithExistingStorage, createTimestampedBackupPath, withAccountStorageTransaction, + withAccountAndFlaggedStorageTransaction, withFlaggedAccountsTransaction, loadAccountAndFlaggedStorageSnapshot, backupRawAccountsFile, @@ -1835,6 +1836,120 @@ describe("storage", () => { } }); + it("persists both files inside withAccountAndFlaggedStorageTransaction", async () => { + await saveAccounts({ + version: 3, + activeIndex: 0, + activeIndexByFamily: {}, + accounts: [ + { + refreshToken: "account-refresh", + accountId: "account-id", + addedAt: 1, + lastUsed: 1, + }, + ], + }); + await saveFlaggedAccounts({ + version: 1, + accounts: [ + { + refreshToken: "account-refresh", + flaggedAt: 1, + addedAt: 1, + lastUsed: 1, + }, + ], + }); + + await withAccountAndFlaggedStorageTransaction(async (current, persist) => { + await persist.flagged({ + version: 1, + accounts: [ + ...current.flagged.accounts, + { + refreshToken: "account-next", + flaggedAt: 2, + addedAt: 2, + lastUsed: 2, + }, + ], + }); + await persist.accounts({ + version: 3, + activeIndex: 1, + activeIndexByFamily: { codex: 1 }, + accounts: [ + ...(current.accounts?.accounts ?? []), + { + refreshToken: "account-next", + accountId: "account-next-id", + addedAt: 2, + lastUsed: 2, + }, + ], + }); + }); + + const loadedAccounts = await loadAccounts(); + const loadedFlagged = await loadFlaggedAccounts(); + expect(loadedAccounts?.accounts.map((account) => account.refreshToken)).toEqual([ + "account-refresh", + "account-next", + ]); + expect(loadedAccounts?.activeIndex).toBe(1); + expect(loadedAccounts?.activeIndexByFamily?.codex).toBe(1); + expect(Object.values(loadedAccounts?.activeIndexByFamily ?? {})).toSatisfy((values) => + values.every((value) => value === 1), + ); + expect(loadedFlagged.accounts.map((account) => account.refreshToken)).toEqual([ + "account-refresh", + "account-next", + ]); + }); + + it("documents partial writes if withAccountAndFlaggedStorageTransaction throws after one persist", async () => { + await saveAccounts({ + version: 3, + activeIndex: 0, + activeIndexByFamily: {}, + accounts: [ + { + refreshToken: "account-refresh", + accountId: "account-id", + addedAt: 1, + lastUsed: 1, + }, + ], + }); + await saveFlaggedAccounts({ + version: 1, + accounts: [ + { + refreshToken: "account-refresh", + flaggedAt: 1, + addedAt: 1, + lastUsed: 1, + }, + ], + }); + + await expect( + withAccountAndFlaggedStorageTransaction(async (_current, persist) => { + await persist.flagged({ + version: 1, + accounts: [], + }); + throw new Error("stop after flagged write"); + }), + ).rejects.toThrow("stop after flagged write"); + + const loadedAccounts = await loadAccounts(); + const loadedFlagged = await loadFlaggedAccounts(); + expect(loadedAccounts?.accounts.map((account) => account.refreshToken)).toEqual(["account-refresh"]); + expect(loadedFlagged.accounts).toEqual([]); + }); + it("copies the raw accounts file for backup", async () => { await saveAccounts({ version: 3, From e2cabc28771aa79286d93d26684844c4db2c1c9d Mon Sep 17 00:00:00 2001 From: ndycode Date: Sun, 15 Mar 2026 18:12:16 +0800 Subject: [PATCH 10/15] Fix remaining PR 77 sync review findings --- index.ts | 125 ++++++++++++++++++++++++----- lib/codex-multi-auth-sync.ts | 15 ++++ lib/sync-prune-backup.ts | 46 ++++++++--- test/codex-multi-auth-sync.test.ts | 56 ++++++++++++- test/index.test.ts | 10 ++- test/storage.test.ts | 93 +++++++++++++++++++++ test/sync-prune-backup.test.ts | 49 ++++++++++- 7 files changed, 360 insertions(+), 34 deletions(-) diff --git a/index.ts b/index.ts index 2724141a..e608268f 100644 --- a/index.ts +++ b/index.ts @@ -3407,6 +3407,39 @@ while (attempted.size < Math.max(1, accountCount)) { const SYNC_PRUNE_BACKUP_PREFIX = "codex-sync-prune-backup"; const SYNC_PRUNE_BACKUP_RETAIN_COUNT = 2; + const SYNC_PRUNE_BACKUP_MAX_AGE_MS = 7 * 24 * 60 * 60 * 1000; + const SYNC_PRUNE_BACKUP_RENAME_RETRY_DELAYS_MS = [10, 20, 40, 80, 160] as const; + + const renameSyncPruneBackupWithRetry = async ( + sourcePath: string, + destinationPath: string, + ): Promise => { + let lastError: NodeJS.ErrnoException | null = null; + for ( + let attempt = 0; + attempt <= SYNC_PRUNE_BACKUP_RENAME_RETRY_DELAYS_MS.length; + attempt += 1 + ) { + try { + await fsPromises.rename(sourcePath, destinationPath); + return; + } catch (error) { + const code = (error as NodeJS.ErrnoException).code; + if ((code === "EPERM" || code === "EBUSY")) { + lastError = error as NodeJS.ErrnoException; + const delayMs = SYNC_PRUNE_BACKUP_RENAME_RETRY_DELAYS_MS[attempt]; + if (delayMs !== undefined) { + await new Promise((resolve) => setTimeout(resolve, delayMs)); + continue; + } + } + throw error; + } + } + if (lastError) { + throw lastError; + } + }; const pruneOldSyncPruneBackups = async ( backupDir: string, @@ -3422,17 +3455,49 @@ while (attempted.size < Math.max(1, accountCount)) { if (!entries) return; const keepSet = new Set(keepPaths); - const staleBackupPaths = entries - .filter( - (entry) => - entry.isFile() && - entry.name.startsWith(`${SYNC_PRUNE_BACKUP_PREFIX}-`) && - entry.name.endsWith(".json"), + const now = Date.now(); + const backupCandidates = ( + await Promise.all( + entries + .filter( + (entry) => + entry.isFile() && + entry.name.startsWith(`${SYNC_PRUNE_BACKUP_PREFIX}-`) && + entry.name.endsWith(".json"), + ) + .map(async (entry) => { + const candidatePath = join(backupDir, entry.name); + if (keepSet.has(candidatePath)) { + return null; + } + const stats = await fsPromises.stat(candidatePath).catch((error) => { + const code = (error as NodeJS.ErrnoException).code; + if (code === "ENOENT") { + return null; + } + throw error; + }); + if (!stats) { + return null; + } + return { + path: candidatePath, + mtimeMs: stats.mtimeMs, + }; + }), ) - .map((entry) => join(backupDir, entry.name)) - .filter((candidatePath) => !keepSet.has(candidatePath)) - .sort((left, right) => right.localeCompare(left)) - .slice(SYNC_PRUNE_BACKUP_RETAIN_COUNT); + ) + .filter((candidate): candidate is { path: string; mtimeMs: number } => candidate !== null) + .sort((left, right) => right.path.localeCompare(left.path)); + const staleBackupPaths = [ + ...backupCandidates + .filter((candidate) => now - candidate.mtimeMs > SYNC_PRUNE_BACKUP_MAX_AGE_MS) + .map((candidate) => candidate.path), + ...backupCandidates + .filter((candidate) => now - candidate.mtimeMs <= SYNC_PRUNE_BACKUP_MAX_AGE_MS) + .slice(SYNC_PRUNE_BACKUP_RETAIN_COUNT) + .map((candidate) => candidate.path), + ]; for (const staleBackupPath of staleBackupPaths) { try { @@ -3471,16 +3536,37 @@ while (attempted.size < Math.max(1, accountCount)) { const backupPayload = createSyncPruneBackupPayload( currentAccountsStorage, currentFlaggedStorage, + { includeLiveTokens: true }, ); const restoreAccountsSnapshot = structuredClone(currentAccountsStorage); const restoreFlaggedSnapshot = structuredClone(currentFlaggedStorage); - const tempBackupPath = `${backupPath}.${Date.now()}.tmp`; + let tempBackupPath: string | null = null; try { - await fsPromises.writeFile(tempBackupPath, `${JSON.stringify(backupPayload, null, 2)}\n`, { - encoding: "utf-8", - mode: 0o600, - }); - await fsPromises.rename(tempBackupPath, backupPath); + for (let attempt = 0; attempt < 3; attempt += 1) { + tempBackupPath = `${backupPath}.${Date.now()}.${Math.random().toString(36).slice(2, 8)}.tmp`; + try { + await fsPromises.writeFile( + tempBackupPath, + `${JSON.stringify(backupPayload, null, 2)}\n`, + { + encoding: "utf-8", + mode: 0o600, + flag: "wx", + }, + ); + break; + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "EEXIST" && attempt < 2) { + tempBackupPath = null; + continue; + } + throw error; + } + } + if (!tempBackupPath) { + throw new Error("Failed to allocate a temporary sync prune backup path."); + } + await renameSyncPruneBackupWithRetry(tempBackupPath, backupPath); await pruneOldSyncPruneBackups(backupDir, [backupPath]).catch((cleanupError) => { const cleanupMessage = cleanupError instanceof Error ? cleanupError.message : String(cleanupError); @@ -3490,7 +3576,9 @@ while (attempted.size < Math.max(1, accountCount)) { }); } catch (error) { try { - await fsPromises.unlink(tempBackupPath); + if (tempBackupPath) { + await fsPromises.unlink(tempBackupPath); + } } catch { // best-effort cleanup } @@ -3898,7 +3986,8 @@ while (attempted.size < Math.max(1, accountCount)) { typeof (error as Error & { backupPath?: unknown }).backupPath === "string" ? ((error as Error & { backupPath: string }).backupPath) : undefined; - const backupHint = cleanupBackupPath ? `\nBackup: ${cleanupBackupPath}` : ""; + const hintedBackupPath = cleanupBackupPath ?? backupPath; + const backupHint = hintedBackupPath ? `\nBackup: ${hintedBackupPath}` : ""; console.log(`\nCleanup failed: ${message}${backupHint}\n`); } }; diff --git a/lib/codex-multi-auth-sync.ts b/lib/codex-multi-auth-sync.ts index bf1dfeb6..6fd07e10 100644 --- a/lib/codex-multi-auth-sync.ts +++ b/lib/codex-multi-auth-sync.ts @@ -248,6 +248,18 @@ async function redactNormalizedImportTempFile(tempPath: string, storage: Account } } +async function scrubStaleNormalizedImportTempFile(candidateDir: string): Promise { + const tempPath = join(candidateDir, "accounts.json"); + try { + await fs.truncate(tempPath, 0); + } catch (error) { + const code = (error as NodeJS.ErrnoException).code; + if (code === "ENOENT" || code === "EACCES" || code === "EPERM" || code === "EBUSY" || code === "EAGAIN") { + return; + } + } +} + async function withNormalizedImportFile( storage: AccountStorageV3, handler: (filePath: string) => Promise, @@ -306,6 +318,7 @@ async function cleanupStaleNormalizedImportTempDirs( continue; } let message = error instanceof Error ? error.message : String(error); + await scrubStaleNormalizedImportTempFile(candidateDir); if (code && TEMP_CLEANUP_RETRYABLE_CODES.has(code)) { await sleepAsync(STALE_TEMP_CLEANUP_RETRY_DELAY_MS); try { @@ -317,6 +330,7 @@ async function cleanupStaleNormalizedImportTempDirs( continue; } message = retryError instanceof Error ? retryError.message : String(retryError); + await scrubStaleNormalizedImportTempFile(candidateDir); } } logWarn(`Failed to sweep stale codex sync temp directory ${candidateDir}: ${message}`); @@ -1229,6 +1243,7 @@ export async function cleanupCodexMultiAuthSyncedOverlaps( await fs.writeFile(tempBackupPath, `${JSON.stringify(fallback, null, 2)}\n`, { encoding: "utf-8", mode: 0o600, + flag: "wx", }); await fs.rename(tempBackupPath, backupPath); writtenBackupPath = backupPath; diff --git a/lib/sync-prune-backup.ts b/lib/sync-prune-backup.ts index 345d9e3d..efaf0b9e 100644 --- a/lib/sync-prune-backup.ts +++ b/lib/sync-prune-backup.ts @@ -5,25 +5,51 @@ type FlaggedSnapshot = { accounts: TAccount[]; }; +type SyncPruneBackupPayloadOptions = { + includeLiveTokens?: boolean; +}; + +type TokenBearingRecord = { + refreshToken?: string; + accessToken?: string; + idToken?: string; +}; + +function redactCredentialRecord(account: TAccount): TAccount { + const clone = structuredClone(account) as TAccount & TokenBearingRecord; + if (typeof clone.refreshToken === "string") { + clone.refreshToken = "__redacted__"; + } + if ("accessToken" in clone) { + clone.accessToken = undefined; + } + if ("idToken" in clone) { + clone.idToken = undefined; + } + return clone; +} + export function createSyncPruneBackupPayload( currentAccountsStorage: AccountStorageV3, currentFlaggedStorage: FlaggedSnapshot, + options: SyncPruneBackupPayloadOptions = {}, ): { version: 1; accounts: AccountStorageV3; flagged: FlaggedSnapshot; } { - // Intentionally retain live tokens so a mid-sync crash can fully restore pruned accounts. - // The backup is stored under the user's config home; on Windows its ACLs are the real boundary - // because the later write path's `mode: 0o600` hint is not strictly enforced there. + const accounts = structuredClone(currentAccountsStorage); + const flagged = structuredClone(currentFlaggedStorage); + if (!options.includeLiveTokens) { + accounts.accounts = accounts.accounts.map((account) => redactCredentialRecord(account)); + flagged.accounts = flagged.accounts.map((account) => redactCredentialRecord(account)); + } + // Callers opt into live tokens only when crash recovery must fully restore pruned accounts. + // On Windows the eventual file write still relies on config-home ACLs because `mode: 0o600` + // is only a best-effort hint there. return { version: 1, - accounts: structuredClone({ - ...currentAccountsStorage, - activeIndexByFamily: { ...(currentAccountsStorage.activeIndexByFamily ?? {}) }, - }), - flagged: structuredClone({ - ...currentFlaggedStorage, - }), + accounts, + flagged, }; } diff --git a/test/codex-multi-auth-sync.test.ts b/test/codex-multi-auth-sync.test.ts index d5b86887..ea897240 100644 --- a/test/codex-multi-auth-sync.test.ts +++ b/test/codex-multi-auth-sync.test.ts @@ -699,7 +699,7 @@ describe("codex-multi-auth sync", () => { } }); - it.each(["EACCES", "EPERM"] as const)( + it.each(["EACCES", "EPERM", "ENOTEMPTY", "EAGAIN"] as const)( "retries Windows-style %s temp cleanup locks until they clear", async (code) => { const rootDir = join(process.cwd(), ".tmp-codex-multi-auth"); @@ -1023,6 +1023,60 @@ describe("codex-multi-auth sync", () => { } }); + it("scrubs stale sync temp payloads before warning when stale cleanup stays locked", async () => { + const rootDir = join(process.cwd(), ".tmp-codex-multi-auth"); + const fakeHome = await fs.promises.mkdtemp(join(os.tmpdir(), "codex-sync-home-")); + process.env.CODEX_MULTI_AUTH_DIR = rootDir; + process.env.HOME = fakeHome; + process.env.USERPROFILE = fakeHome; + const globalPath = join(rootDir, "openai-codex-accounts.json"); + const tempRoot = join(fakeHome, ".opencode", "tmp"); + const staleDir = join(tempRoot, "oc-chatgpt-multi-auth-sync-stale-scrub-test"); + const staleFile = join(staleDir, "accounts.json"); + mockExistsSync.mockImplementation((candidate) => String(candidate) === globalPath); + mockSourceStorageFile( + globalPath, + JSON.stringify({ + version: 3, + activeIndex: 0, + activeIndexByFamily: {}, + accounts: [{ accountId: "org-source", organizationId: "org-source", refreshToken: "rt-source", addedAt: 1, lastUsed: 1 }], + }), + ); + + const originalRm = fs.promises.rm.bind(fs.promises); + const rmSpy = vi.spyOn(fs.promises, "rm").mockImplementation(async (path, options) => { + if (String(path) === staleDir) { + throw Object.assign(new Error("still locked"), { code: "EBUSY" }); + } + return originalRm(path, options as never); + }); + const loggerModule = await import("../lib/logger.js"); + + try { + await fs.promises.mkdir(staleDir, { recursive: true }); + await fs.promises.writeFile(staleFile, "sensitive-refresh-token", "utf8"); + const oldTime = new Date(Date.now() - (15 * 60 * 1000)); + await fs.promises.utimes(staleDir, oldTime, oldTime); + await fs.promises.utimes(staleFile, oldTime, oldTime); + + const { previewSyncFromCodexMultiAuth } = await import("../lib/codex-multi-auth-sync.js"); + await expect(previewSyncFromCodexMultiAuth(process.cwd())).resolves.toMatchObject({ + rootDir, + accountsPath: globalPath, + scope: "global", + }); + + expect(vi.mocked(loggerModule.logWarn)).toHaveBeenCalledWith( + expect.stringContaining("Failed to sweep stale codex sync temp directory"), + ); + expect(await fs.promises.readFile(staleFile, "utf8")).toBe(""); + } finally { + rmSpy.mockRestore(); + await fs.promises.rm(fakeHome, { recursive: true, force: true }); + } + }); + it("skips source accounts whose emails already exist locally during sync", async () => { const rootDir = join(process.cwd(), ".tmp-codex-multi-auth"); process.env.CODEX_MULTI_AUTH_DIR = rootDir; diff --git a/test/index.test.ts b/test/index.test.ts index 794e5774..1f99c6ac 100644 --- a/test/index.test.ts +++ b/test/index.test.ts @@ -2161,7 +2161,7 @@ describe("OpenAIOAuthPlugin", () => { const output = logSpy.mock.calls.flat().join("\n"); expect(output).toContain("Cleanup failed: cleanup persist failed"); - expect(output).not.toContain("Backup: /tmp/codex-backup-20260101-000000.json"); + expect(output).toContain("Backup: /tmp/codex-maintenance-overlap-backup-20260101-000000.json"); } finally { logSpy.mockRestore(); } @@ -2258,7 +2258,10 @@ describe("OpenAIOAuthPlugin", () => { const mkdirSpy = vi.spyOn(nodeFsPromises, "mkdir").mockResolvedValue(undefined); const writeSpy = vi.spyOn(nodeFsPromises, "writeFile").mockResolvedValue(undefined); - const renameSpy = vi.spyOn(nodeFsPromises, "rename").mockResolvedValue(undefined); + const renameSpy = vi + .spyOn(nodeFsPromises, "rename") + .mockRejectedValueOnce(Object.assign(new Error("rename locked"), { code: "EPERM" })) + .mockResolvedValueOnce(undefined); try { const autoMethod = plugin.auth.methods[0] as unknown as { @@ -2273,13 +2276,14 @@ describe("OpenAIOAuthPlugin", () => { ); expect(backupWrite).toBeDefined(); const backupContent = String(backupWrite?.[1] ?? ""); + expect(backupWrite?.[2]).toMatchObject({ flag: "wx" }); expect(backupContent).toContain("\"refreshToken\": \"refresh-remove\""); expect(backupContent).toContain("\"accessToken\": \"access-remove\""); expect(backupContent).toContain("\"idToken\": \"id-remove\""); expect(backupContent).toContain("\"accessToken\": \"flagged-access-remove\""); expect(backupContent).toContain("\"idToken\": \"flagged-id-remove\""); expect(mockFlaggedStorage.accounts).toHaveLength(0); - expect(renameSpy).toHaveBeenCalled(); + expect(renameSpy).toHaveBeenCalledTimes(2); expect(mkdirSpy).toHaveBeenCalled(); } finally { mkdirSpy.mockRestore(); diff --git a/test/storage.test.ts b/test/storage.test.ts index fcfa4b76..fc0108bb 100644 --- a/test/storage.test.ts +++ b/test/storage.test.ts @@ -1950,6 +1950,99 @@ describe("storage", () => { expect(loadedFlagged.accounts).toEqual([]); }); + it("holds the shared lock until both account and flagged writes finish", async () => { + await saveAccounts({ + version: 3, + activeIndex: 0, + activeIndexByFamily: {}, + accounts: [ + { + refreshToken: "account-refresh", + accountId: "account-id", + addedAt: 1, + lastUsed: 1, + }, + ], + }); + await saveFlaggedAccounts({ + version: 1, + accounts: [ + { + refreshToken: "account-refresh", + flaggedAt: 1, + addedAt: 1, + lastUsed: 1, + }, + ], + }); + + const events: string[] = []; + let releaseFirstTransaction!: () => void; + const firstTransactionPaused = new Promise((resolve) => { + releaseFirstTransaction = resolve; + }); + let firstTransactionReady!: () => void; + const firstTransactionEntered = new Promise((resolve) => { + firstTransactionReady = resolve; + }); + let secondEntered = false; + + const firstTransaction = withAccountAndFlaggedStorageTransaction(async (current, persist) => { + events.push("first:start"); + await persist.flagged({ + version: 1, + accounts: [ + ...current.flagged.accounts, + { + refreshToken: "account-next", + flaggedAt: 2, + addedAt: 2, + lastUsed: 2, + }, + ], + }); + events.push("first:after-flagged"); + firstTransactionReady(); + await firstTransactionPaused; + await persist.accounts({ + version: 3, + activeIndex: 1, + activeIndexByFamily: { codex: 1 }, + accounts: [ + ...(current.accounts?.accounts ?? []), + { + refreshToken: "account-next", + accountId: "account-next-id", + addedAt: 2, + lastUsed: 2, + }, + ], + }); + events.push("first:after-accounts"); + }); + + await firstTransactionEntered; + + const secondTransaction = withAccountAndFlaggedStorageTransaction(async () => { + secondEntered = true; + events.push("second:start"); + }); + + await new Promise((resolve) => setTimeout(resolve, 25)); + expect(secondEntered).toBe(false); + expect(events).toEqual(["first:start", "first:after-flagged"]); + + releaseFirstTransaction(); + await Promise.all([firstTransaction, secondTransaction]); + + expect(events).toEqual([ + "first:start", + "first:after-flagged", + "first:after-accounts", + "second:start", + ]); + }); + it("copies the raw accounts file for backup", async () => { await saveAccounts({ version: 3, diff --git a/test/sync-prune-backup.test.ts b/test/sync-prune-backup.test.ts index 396cc2bd..16d87124 100644 --- a/test/sync-prune-backup.test.ts +++ b/test/sync-prune-backup.test.ts @@ -3,7 +3,7 @@ import { createSyncPruneBackupPayload } from "../lib/sync-prune-backup.js"; import type { AccountStorageV3 } from "../lib/storage.js"; describe("sync prune backup payload", () => { - it("keeps live tokens in the prune backup payload so crash recovery stays restorable", () => { + it("redacts live tokens by default", () => { const storage: AccountStorageV3 = { version: 3, activeIndex: 0, @@ -32,6 +32,51 @@ describe("sync prune backup payload", () => { ], }); + expect(payload.accounts.accounts[0]).toMatchObject({ + refreshToken: "__redacted__", + accessToken: undefined, + idToken: undefined, + }); + expect(payload.flagged.accounts[0]).toMatchObject({ + refreshToken: "__redacted__", + accessToken: undefined, + idToken: undefined, + }); + }); + + it("keeps live tokens when explicitly requested for crash recovery", () => { + const storage: AccountStorageV3 = { + version: 3, + activeIndex: 0, + activeIndexByFamily: {}, + accounts: [ + { + accountId: "org-sync", + organizationId: "org-sync", + accountIdSource: "org", + refreshToken: "refresh-token", + accessToken: "access-token", + idToken: "id-token", + addedAt: 1, + lastUsed: 1, + }, + ], + }; + const payload = createSyncPruneBackupPayload( + storage, + { + version: 1, + accounts: [ + { + refreshToken: "refresh-token", + accessToken: "flagged-access-token", + idToken: "flagged-id-token", + }, + ], + }, + { includeLiveTokens: true }, + ); + expect(payload.accounts.accounts[0]).toMatchObject({ refreshToken: "refresh-token", accessToken: "access-token", @@ -80,7 +125,7 @@ describe("sync prune backup payload", () => { ], }; - const payload = createSyncPruneBackupPayload(storage, flagged); + const payload = createSyncPruneBackupPayload(storage, flagged, { includeLiveTokens: true }); storage.accounts[0]!.accountTags?.push("mutated"); storage.accounts[0]!.lastSelectedModelByFamily!.codex = "gpt-5.5"; From bbadd2dad2d132fa71ad6e2114fcb890656a7c54 Mon Sep 17 00:00:00 2001 From: ndycode Date: Sun, 15 Mar 2026 18:16:41 +0800 Subject: [PATCH 11/15] Harden remaining PR 77 review follow-ups --- index.ts | 10 +++++++--- test/codex-multi-auth-sync.test.ts | 3 +++ 2 files changed, 10 insertions(+), 3 deletions(-) diff --git a/index.ts b/index.ts index e608268f..9a93e637 100644 --- a/index.ts +++ b/index.ts @@ -3406,6 +3406,9 @@ while (attempted.size < Math.max(1, accountCount)) { }; const SYNC_PRUNE_BACKUP_PREFIX = "codex-sync-prune-backup"; + // Crash-safe prune restores can retain live tokens here when explicitly requested, so + // keep retention low; pruneOldSyncPruneBackups is the only automatic cleanup gate and + // Windows still relies on config-home ACLs because `mode: 0o600` is only advisory. const SYNC_PRUNE_BACKUP_RETAIN_COUNT = 2; const SYNC_PRUNE_BACKUP_MAX_AGE_MS = 7 * 24 * 60 * 60 * 1000; const SYNC_PRUNE_BACKUP_RENAME_RETRY_DELAYS_MS = [10, 20, 40, 80, 160] as const; @@ -3921,14 +3924,14 @@ while (attempted.size < Math.max(1, accountCount)) { for (const line of removalPlan.previewLines) { console.log(` ${line}`); } + if (!pruneBackup) { + pruneBackup = await createSyncPruneBackup(); + } if (!(await confirm(`Remove ${indexesToRemove.length} selected account(s) and retry sync?`))) { await restorePruneBackup(); console.log("Sync cancelled.\n"); return; } - if (!pruneBackup) { - pruneBackup = await createSyncPruneBackup(); - } try { await removeAccountsForSync(removalPlan.targets); } catch (removalError) { @@ -3978,6 +3981,7 @@ while (attempted.size < Math.max(1, accountCount)) { console.log(`Removed overlaps: ${result.removed}`); console.log(`Updated synced records: ${result.updated}`); console.log(`Backup: ${backupPath}`); + console.log("Remove this backup after you finish verifying the cleanup."); console.log(""); } catch (error) { const message = error instanceof Error ? error.message : String(error); diff --git a/test/codex-multi-auth-sync.test.ts b/test/codex-multi-auth-sync.test.ts index ea897240..d4dfa321 100644 --- a/test/codex-multi-auth-sync.test.ts +++ b/test/codex-multi-auth-sync.test.ts @@ -242,6 +242,7 @@ describe("codex-multi-auth sync", () => { it("probes the DevTools fallback root when no env override is set", async () => { process.env.USERPROFILE = "C:\\Users\\tester"; process.env.HOME = "C:\\Users\\tester"; + Object.defineProperty(process, "platform", { value: "win32" }); const devToolsGlobalPath = pathWin32.join( "C:\\Users\\tester", "DevTools", @@ -261,6 +262,7 @@ describe("codex-multi-auth sync", () => { it("prefers the DevTools root over ~/.codex when CODEX_HOME is not set", async () => { process.env.USERPROFILE = "C:\\Users\\tester"; process.env.HOME = "C:\\Users\\tester"; + Object.defineProperty(process, "platform", { value: "win32" }); const devToolsGlobalPath = pathWin32.join( "C:\\Users\\tester", "DevTools", @@ -290,6 +292,7 @@ describe("codex-multi-auth sync", () => { process.env.USERPROFILE = "C:\\Users\\tester"; process.env.HOME = "C:\\Users\\tester"; process.env.CODEX_HOME = "C:\\Users\\tester\\.codex"; + Object.defineProperty(process, "platform", { value: "win32" }); const walOnlyPath = pathWin32.join( "C:\\Users\\tester", ".codex", From e5b5b36b4cee2673e7fdc73b3081a3f2647b0dc1 Mon Sep 17 00:00:00 2001 From: ndycode Date: Sun, 15 Mar 2026 18:37:52 +0800 Subject: [PATCH 12/15] Fix backup pruning review follow-ups --- index.ts | 53 +++++++++++-- lib/sync-prune-backup.ts | 71 ++++++++++++++--- test/index.test.ts | 136 +++++++++++++++++++++++++++++++++ test/storage.test.ts | 14 +++- test/sync-prune-backup.test.ts | 45 ++++++++++- 5 files changed, 300 insertions(+), 19 deletions(-) diff --git a/index.ts b/index.ts index 9a93e637..cf6b8bd9 100644 --- a/index.ts +++ b/index.ts @@ -3444,9 +3444,17 @@ while (attempted.size < Math.max(1, accountCount)) { } }; - const pruneOldSyncPruneBackups = async ( + const pruneTimestampedBackups = async ( backupDir: string, - keepPaths: string[] = [], + { + prefix, + keepPaths = [], + logLabel, + }: { + prefix: string; + keepPaths?: string[]; + logLabel: string; + }, ): Promise => { const entries = await fsPromises.readdir(backupDir, { withFileTypes: true }).catch((error) => { const code = (error as NodeJS.ErrnoException).code; @@ -3465,7 +3473,7 @@ while (attempted.size < Math.max(1, accountCount)) { .filter( (entry) => entry.isFile() && - entry.name.startsWith(`${SYNC_PRUNE_BACKUP_PREFIX}-`) && + entry.name.startsWith(`${prefix}-`) && entry.name.endsWith(".json"), ) .map(async (entry) => { @@ -3491,7 +3499,12 @@ while (attempted.size < Math.max(1, accountCount)) { ) ) .filter((candidate): candidate is { path: string; mtimeMs: number } => candidate !== null) - .sort((left, right) => right.path.localeCompare(left.path)); + .sort((left, right) => { + if (right.mtimeMs !== left.mtimeMs) { + return right.mtimeMs - left.mtimeMs; + } + return left.path.localeCompare(right.path); + }); const staleBackupPaths = [ ...backupCandidates .filter((candidate) => now - candidate.mtimeMs > SYNC_PRUNE_BACKUP_MAX_AGE_MS) @@ -3512,12 +3525,32 @@ while (attempted.size < Math.max(1, accountCount)) { } const message = error instanceof Error ? error.message : String(error); logWarn( - `[${PLUGIN_NAME}] Failed to prune stale sync prune backup ${staleBackupPath}: ${message}`, + `[${PLUGIN_NAME}] Failed to prune stale ${logLabel} ${staleBackupPath}: ${message}`, ); } } }; + const pruneOldSyncPruneBackups = async ( + backupDir: string, + keepPaths: string[] = [], + ): Promise => + pruneTimestampedBackups(backupDir, { + prefix: SYNC_PRUNE_BACKUP_PREFIX, + keepPaths, + logLabel: "sync prune backup", + }); + + const pruneOldOverlapCleanupBackups = async ( + backupDir: string, + keepPaths: string[] = [], + ): Promise => + pruneTimestampedBackups(backupDir, { + prefix: "codex-maintenance-overlap-backup", + keepPaths, + logLabel: "overlap cleanup backup", + }); + const createSyncPruneBackup = async (): Promise<{ backupPath: string; restore: () => Promise; @@ -3974,6 +4007,16 @@ while (attempted.size < Math.max(1, accountCount)) { backupPath = createTimestampedBackupPath("codex-maintenance-overlap-backup"); const result = await cleanupCodexMultiAuthSyncedOverlaps(backupPath); invalidateAccountManagerCache(); + if (backupPath) { + const backupDir = dirname(backupPath); + await pruneOldOverlapCleanupBackups(backupDir, [backupPath]).catch((cleanupError) => { + const cleanupMessage = + cleanupError instanceof Error ? cleanupError.message : String(cleanupError); + logWarn( + `[${PLUGIN_NAME}] Failed to prune old overlap cleanup backups in ${backupDir}: ${cleanupMessage}`, + ); + }); + } console.log(""); console.log("Cleanup complete."); console.log(`Before: ${result.before}`); diff --git a/lib/sync-prune-backup.ts b/lib/sync-prune-backup.ts index efaf0b9e..63d43901 100644 --- a/lib/sync-prune-backup.ts +++ b/lib/sync-prune-backup.ts @@ -15,7 +15,33 @@ type TokenBearingRecord = { idToken?: string; }; -function redactCredentialRecord(account: TAccount): TAccount { +type ReplaceTokenField< + TAccount extends object, + TKey extends keyof TokenBearingRecord, + TValue, +> = TKey extends keyof TAccount + ? undefined extends TAccount[TKey] + ? Omit & { [K in TKey]?: TValue } + : Omit & { [K in TKey]: TValue } + : TAccount; + +export type TokenRedacted = ReplaceTokenField< + ReplaceTokenField, "accessToken", undefined>, + "idToken", + undefined +>; + +type RedactedAccountStorage = Omit & { + accounts: Array>; +}; + +type SyncPruneBackupPayload = { + version: 1; + accounts: TAccountsStorage; + flagged: FlaggedSnapshot; +}; + +function redactCredentialRecord(account: TAccount): TokenRedacted { const clone = structuredClone(account) as TAccount & TokenBearingRecord; if (typeof clone.refreshToken === "string") { clone.refreshToken = "__redacted__"; @@ -26,30 +52,51 @@ function redactCredentialRecord(account: TAccount): TAc if ("idToken" in clone) { clone.idToken = undefined; } - return clone; + return clone as TokenRedacted; } +export function createSyncPruneBackupPayload( + currentAccountsStorage: AccountStorageV3, + currentFlaggedStorage: FlaggedSnapshot, + options?: { includeLiveTokens?: false | undefined }, +): SyncPruneBackupPayload>; +export function createSyncPruneBackupPayload( + currentAccountsStorage: AccountStorageV3, + currentFlaggedStorage: FlaggedSnapshot, + options: { includeLiveTokens: true }, +): SyncPruneBackupPayload; export function createSyncPruneBackupPayload( currentAccountsStorage: AccountStorageV3, currentFlaggedStorage: FlaggedSnapshot, options: SyncPruneBackupPayloadOptions = {}, -): { - version: 1; - accounts: AccountStorageV3; - flagged: FlaggedSnapshot; -} { +): + | SyncPruneBackupPayload> + | SyncPruneBackupPayload { const accounts = structuredClone(currentAccountsStorage); const flagged = structuredClone(currentFlaggedStorage); - if (!options.includeLiveTokens) { - accounts.accounts = accounts.accounts.map((account) => redactCredentialRecord(account)); - flagged.accounts = flagged.accounts.map((account) => redactCredentialRecord(account)); + if (options.includeLiveTokens) { + return { + version: 1, + accounts, + flagged, + }; } + + const redactedAccounts: RedactedAccountStorage = { + ...accounts, + accounts: accounts.accounts.map((account) => redactCredentialRecord(account)), + }; + const redactedFlagged: FlaggedSnapshot> = { + ...flagged, + accounts: flagged.accounts.map((account) => redactCredentialRecord(account)), + }; + // Callers opt into live tokens only when crash recovery must fully restore pruned accounts. // On Windows the eventual file write still relies on config-home ACLs because `mode: 0o600` // is only a best-effort hint there. return { version: 1, - accounts, - flagged, + accounts: redactedAccounts, + flagged: redactedFlagged, }; } diff --git a/test/index.test.ts b/test/index.test.ts index 1f99c6ac..04073d16 100644 --- a/test/index.test.ts +++ b/test/index.test.ts @@ -2167,11 +2167,89 @@ describe("OpenAIOAuthPlugin", () => { } }); + it("prunes stale overlap cleanup backups after a successful cleanup", async () => { + const cliModule = await import("../lib/cli.js"); + const confirmModule = await import("../lib/ui/confirm.js"); + const syncModule = await import("../lib/codex-multi-auth-sync.js"); + const { promises: nodeFsPromises } = await import("node:fs"); + const logSpy = vi.spyOn(console, "log").mockImplementation(() => {}); + + mockStorage.accounts = [ + { + accountId: "existing-account", + email: "existing@example.com", + refreshToken: "existing-refresh", + addedAt: 1, + lastUsed: 1, + }, + ]; + + vi.mocked(cliModule.promptLoginMode) + .mockResolvedValueOnce({ mode: "experimental-cleanup-overlaps" }) + .mockResolvedValueOnce({ mode: "cancel" }) + .mockResolvedValue({ mode: "cancel" }); + vi.mocked(confirmModule.confirm).mockResolvedValueOnce(true); + vi.mocked(syncModule.previewCodexMultiAuthSyncedOverlapCleanup).mockResolvedValueOnce({ + before: 3, + after: 2, + removed: 1, + updated: 0, + }); + vi.mocked(syncModule.cleanupCodexMultiAuthSyncedOverlaps).mockResolvedValueOnce({ + before: 3, + after: 2, + removed: 1, + updated: 0, + }); + + const staleBackupPath = "/tmp/codex-maintenance-overlap-backup-20240201-000000.json"; + const normalizePath = (value: string) => value.replace(/\\/g, "/"); + const readdirSpy = vi.spyOn(nodeFsPromises, "readdir").mockResolvedValue([ + { + name: "codex-maintenance-overlap-backup-20260101-000000.json", + isFile: () => true, + }, + { + name: "codex-maintenance-overlap-backup-20240201-000000.json", + isFile: () => true, + }, + ] as never); + const statSpy = vi.spyOn(nodeFsPromises, "stat").mockImplementation(async (path) => { + return { + mtimeMs: + normalizePath(String(path)) === staleBackupPath ? Date.now() - 8 * 24 * 60 * 60 * 1000 : Date.now(), + } as never; + }); + const unlinkSpy = vi.spyOn(nodeFsPromises, "unlink").mockResolvedValue(undefined); + + try { + const autoMethod = plugin.auth.methods[0] as unknown as { + authorize: (inputs?: Record) => Promise<{ instructions: string }>; + }; + + const result = await autoMethod.authorize(); + expect(result.instructions).toBe("Authentication cancelled"); + expect(vi.mocked(syncModule.cleanupCodexMultiAuthSyncedOverlaps)).toHaveBeenCalledWith( + "/tmp/codex-maintenance-overlap-backup-20260101-000000.json", + ); + expect(unlinkSpy.mock.calls.map(([path]) => normalizePath(String(path)))).toEqual([staleBackupPath]); + + const output = logSpy.mock.calls.flat().join("\n"); + expect(output).toContain("Cleanup complete."); + } finally { + logSpy.mockRestore(); + readdirSpy.mockRestore(); + statSpy.mockRestore(); + unlinkSpy.mockRestore(); + } + }); + it("writes a restorable sync prune backup before removing accounts", async () => { const cliModule = await import("../lib/cli.js"); const confirmModule = await import("../lib/ui/confirm.js"); const configModule = await import("../lib/config.js"); const syncModule = await import("../lib/codex-multi-auth-sync.js"); + const storageModule = await import("../lib/storage.js"); const { promises: nodeFsPromises } = await import("node:fs"); mockStorage.accounts = [ @@ -2218,6 +2296,9 @@ describe("OpenAIOAuthPlugin", () => { .mockResolvedValue({ mode: "cancel" }); vi.mocked(cliModule.promptCodexMultiAuthSyncPrune).mockResolvedValueOnce([0]); vi.mocked(confirmModule.confirm).mockResolvedValue(true); + vi.mocked(storageModule.createTimestampedBackupPath).mockImplementation( + (prefix?: string) => `\\tmp\\${prefix ?? "codex-backup"}-20260101-000000.json`, + ); vi.mocked(syncModule.previewSyncFromCodexMultiAuth) .mockRejectedValueOnce( new CodexMultiAuthSyncCapacityError({ @@ -2262,6 +2343,53 @@ describe("OpenAIOAuthPlugin", () => { .spyOn(nodeFsPromises, "rename") .mockRejectedValueOnce(Object.assign(new Error("rename locked"), { code: "EPERM" })) .mockResolvedValueOnce(undefined); + const normalizePath = (value: string) => value.replace(/\\/g, "/"); + const now = Date.now(); + const statTimes = new Map([ + ["/tmp/codex-sync-prune-backup-z.json", now - 3_000], + ["/tmp/codex-sync-prune-backup-y.json", now - 2_000], + ["/tmp/codex-sync-prune-backup-a.json", now - 1_000], + ]); + const readdirSpy = vi + .spyOn(nodeFsPromises, "readdir") + .mockResolvedValueOnce([ + { + name: "codex-sync-prune-backup-20260101-000000.json", + isFile: () => true, + }, + { + name: "codex-sync-prune-backup-z.json", + isFile: () => true, + }, + { + name: "codex-sync-prune-backup-y.json", + isFile: () => true, + }, + { + name: "codex-sync-prune-backup-a.json", + isFile: () => true, + }, + ] as never) + .mockResolvedValueOnce([ + { + name: "codex-sync-prune-backup-z.json", + isFile: () => true, + }, + { + name: "codex-sync-prune-backup-y.json", + isFile: () => true, + }, + { + name: "codex-sync-prune-backup-a.json", + isFile: () => true, + }, + ] as never); + const statSpy = vi.spyOn(nodeFsPromises, "stat").mockImplementation(async (path) => { + return { + mtimeMs: statTimes.get(normalizePath(String(path))) ?? Date.now(), + } as never; + }); + const unlinkSpy = vi.spyOn(nodeFsPromises, "unlink").mockResolvedValue(undefined); try { const autoMethod = plugin.auth.methods[0] as unknown as { @@ -2285,10 +2413,18 @@ describe("OpenAIOAuthPlugin", () => { expect(mockFlaggedStorage.accounts).toHaveLength(0); expect(renameSpy).toHaveBeenCalledTimes(2); expect(mkdirSpy).toHaveBeenCalled(); + const normalizedUnlinks = unlinkSpy.mock.calls.map(([path]) => normalizePath(String(path))); + expect(normalizedUnlinks).toContain("/tmp/codex-sync-prune-backup-20260101-000000.json"); + expect(normalizedUnlinks.filter((path) => path.endsWith("codex-sync-prune-backup-z.json"))).toHaveLength(2); + expect(normalizedUnlinks.some((path) => path.endsWith("codex-sync-prune-backup-a.json"))).toBe(false); + expect(normalizedUnlinks.some((path) => path.endsWith("codex-sync-prune-backup-y.json"))).toBe(false); } finally { mkdirSpy.mockRestore(); writeSpy.mockRestore(); renameSpy.mockRestore(); + readdirSpy.mockRestore(); + statSpy.mockRestore(); + unlinkSpy.mockRestore(); } }); diff --git a/test/storage.test.ts b/test/storage.test.ts index fc0108bb..d0025e7c 100644 --- a/test/storage.test.ts +++ b/test/storage.test.ts @@ -1986,6 +1986,10 @@ describe("storage", () => { firstTransactionReady = resolve; }); let secondEntered = false; + let resolveSecondStarted!: () => void; + const secondStarted = new Promise((resolve) => { + resolveSecondStarted = resolve; + }); const firstTransaction = withAccountAndFlaggedStorageTransaction(async (current, persist) => { events.push("first:start"); @@ -2026,9 +2030,17 @@ describe("storage", () => { const secondTransaction = withAccountAndFlaggedStorageTransaction(async () => { secondEntered = true; events.push("second:start"); + resolveSecondStarted(); }); - await new Promise((resolve) => setTimeout(resolve, 25)); + const secondProgress = await Promise.race([ + secondStarted.then(() => "started" as const), + Promise.resolve() + .then(() => Promise.resolve()) + .then(() => "waiting" as const), + ]); + + expect(secondProgress).toBe("waiting"); expect(secondEntered).toBe(false); expect(events).toEqual(["first:start", "first:after-flagged"]); diff --git a/test/sync-prune-backup.test.ts b/test/sync-prune-backup.test.ts index 16d87124..dbfcee3c 100644 --- a/test/sync-prune-backup.test.ts +++ b/test/sync-prune-backup.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it } from "vitest"; +import { describe, expect, expectTypeOf, it } from "vitest"; import { createSyncPruneBackupPayload } from "../lib/sync-prune-backup.js"; import type { AccountStorageV3 } from "../lib/storage.js"; @@ -89,6 +89,49 @@ describe("sync prune backup payload", () => { }); }); + it("reflects redacted and live-token payload shapes in the type system", () => { + const storage: AccountStorageV3 = { + version: 3, + activeIndex: 0, + activeIndexByFamily: {}, + accounts: [ + { + accountId: "org-sync", + organizationId: "org-sync", + accountIdSource: "org", + refreshToken: "refresh-token", + accessToken: "access-token", + idToken: "id-token", + addedAt: 1, + lastUsed: 1, + }, + ], + }; + const flagged = { + version: 1 as const, + accounts: [ + { + refreshToken: "refresh-token", + accessToken: "flagged-access-token", + idToken: "flagged-id-token", + }, + ], + }; + + const redactedPayload = createSyncPruneBackupPayload(storage, flagged); + const livePayload = createSyncPruneBackupPayload(storage, flagged, { includeLiveTokens: true }); + + expectTypeOf(redactedPayload.accounts.accounts[0]!.refreshToken).toEqualTypeOf<"__redacted__">(); + expectTypeOf(redactedPayload.accounts.accounts[0]!.accessToken).toEqualTypeOf(); + expectTypeOf(redactedPayload.flagged.accounts[0]!.refreshToken).toEqualTypeOf<"__redacted__">(); + expectTypeOf(redactedPayload.flagged.accounts[0]!.accessToken).toEqualTypeOf(); + + expectTypeOf(livePayload.accounts.accounts[0]!.refreshToken).toEqualTypeOf(); + expectTypeOf(livePayload.accounts.accounts[0]!.accessToken).toEqualTypeOf(); + expectTypeOf(livePayload.flagged.accounts[0]!.refreshToken).toEqualTypeOf(); + expectTypeOf(livePayload.flagged.accounts[0]!.accessToken).toEqualTypeOf(); + }); + it("deep-clones nested metadata so later mutations do not leak into the snapshot", () => { const storage: AccountStorageV3 = { version: 3, From eaaaee35f54d77a7fdb8861bc3217dc91028c67e Mon Sep 17 00:00:00 2001 From: ndycode Date: Sun, 15 Mar 2026 20:10:01 +0800 Subject: [PATCH 13/15] Fix remaining Windows sync review issues --- index.ts | 3 +- lib/codex-multi-auth-sync.ts | 35 ++++++++++++++- test/codex-multi-auth-sync.test.ts | 68 +++++++++++++++++++++++++++++- test/index.test.ts | 4 +- 4 files changed, 103 insertions(+), 7 deletions(-) diff --git a/index.ts b/index.ts index cf6b8bd9..0aeed75f 100644 --- a/index.ts +++ b/index.ts @@ -127,7 +127,6 @@ import { StorageError, formatStorageErrorHint, withAccountAndFlaggedStorageTransaction, - withFlaggedAccountsTransaction, type AccountStorageV3, type FlaggedAccountMetadataV1, } from "./lib/storage.js"; @@ -3428,7 +3427,7 @@ while (attempted.size < Math.max(1, accountCount)) { return; } catch (error) { const code = (error as NodeJS.ErrnoException).code; - if ((code === "EPERM" || code === "EBUSY")) { + if ((code === "EPERM" || code === "EBUSY" || code === "EACCES")) { lastError = error as NodeJS.ErrnoException; const delayMs = SYNC_PRUNE_BACKUP_RENAME_RETRY_DELAYS_MS[attempt]; if (delayMs !== undefined) { diff --git a/lib/codex-multi-auth-sync.ts b/lib/codex-multi-auth-sync.ts index 6fd07e10..22f2e042 100644 --- a/lib/codex-multi-auth-sync.ts +++ b/lib/codex-multi-auth-sync.ts @@ -122,6 +122,8 @@ interface PreparedCodexMultiAuthPreviewStorage { const TEMP_CLEANUP_RETRY_DELAYS_MS = [100, 250, 500] as const; const STALE_TEMP_CLEANUP_RETRY_DELAY_MS = 150; const TEMP_CLEANUP_RETRYABLE_CODES = new Set(["EBUSY", "EAGAIN", "EACCES", "EPERM", "ENOTEMPTY"]); +const BACKUP_RENAME_RETRY_DELAYS_MS = [100, 250, 500] as const; +const BACKUP_RENAME_RETRYABLE_CODES = new Set(["EBUSY", "EACCES", "EPERM"]); function sleepAsync(ms: number): Promise { return new Promise((resolve) => setTimeout(resolve, ms)); @@ -257,6 +259,37 @@ async function scrubStaleNormalizedImportTempFile(candidateDir: string): Promise if (code === "ENOENT" || code === "EACCES" || code === "EPERM" || code === "EBUSY" || code === "EAGAIN") { return; } + logWarn( + `Failed to scrub stale codex sync temp file ${tempPath}: ${ + error instanceof Error ? error.message : String(error) + }`, + ); + } +} + +async function renameOverlapCleanupBackupWithRetry(sourcePath: string, destinationPath: string): Promise { + let lastError: NodeJS.ErrnoException | null = null; + + for (let attempt = 0; attempt < BACKUP_RENAME_RETRY_DELAYS_MS.length; attempt += 1) { + try { + await fs.rename(sourcePath, destinationPath); + return; + } catch (error) { + const code = (error as NodeJS.ErrnoException).code; + if (code && BACKUP_RENAME_RETRYABLE_CODES.has(code)) { + lastError = error as NodeJS.ErrnoException; + const delayMs = BACKUP_RENAME_RETRY_DELAYS_MS[attempt]; + if (delayMs !== undefined) { + await sleepAsync(delayMs); + continue; + } + } + throw error; + } + } + + if (lastError) { + throw lastError; } } @@ -1245,7 +1278,7 @@ export async function cleanupCodexMultiAuthSyncedOverlaps( mode: 0o600, flag: "wx", }); - await fs.rename(tempBackupPath, backupPath); + await renameOverlapCleanupBackupWithRetry(tempBackupPath, backupPath); writtenBackupPath = backupPath; } catch (error) { try { diff --git a/test/codex-multi-auth-sync.test.ts b/test/codex-multi-auth-sync.test.ts index d4dfa321..866337a8 100644 --- a/test/codex-multi-auth-sync.test.ts +++ b/test/codex-multi-auth-sync.test.ts @@ -1080,6 +1080,66 @@ describe("codex-multi-auth sync", () => { } }); + it("warns when stale sync temp scrubbing hits an unexpected truncate error", async () => { + const rootDir = join(process.cwd(), ".tmp-codex-multi-auth"); + const fakeHome = await fs.promises.mkdtemp(join(os.tmpdir(), "codex-sync-home-")); + process.env.CODEX_MULTI_AUTH_DIR = rootDir; + process.env.HOME = fakeHome; + process.env.USERPROFILE = fakeHome; + const globalPath = join(rootDir, "openai-codex-accounts.json"); + const tempRoot = join(fakeHome, ".opencode", "tmp"); + const staleDir = join(tempRoot, "oc-chatgpt-multi-auth-sync-stale-scrub-warning"); + const staleFile = join(staleDir, "accounts.json"); + mockExistsSync.mockImplementation((candidate) => String(candidate) === globalPath); + mockSourceStorageFile( + globalPath, + JSON.stringify({ + version: 3, + activeIndex: 0, + activeIndexByFamily: {}, + accounts: [{ accountId: "org-source", organizationId: "org-source", refreshToken: "rt-source", addedAt: 1, lastUsed: 1 }], + }), + ); + + const originalRm = fs.promises.rm.bind(fs.promises); + const rmSpy = vi.spyOn(fs.promises, "rm").mockImplementation(async (path, options) => { + if (String(path) === staleDir) { + throw Object.assign(new Error("still locked"), { code: "EBUSY" }); + } + return originalRm(path, options as never); + }); + const truncateSpy = vi.spyOn(fs.promises, "truncate").mockImplementation(async (path, len) => { + if (String(path) === staleFile) { + throw Object.assign(new Error("disk io failed"), { code: "EIO" }); + } + return undefined; + }); + const loggerModule = await import("../lib/logger.js"); + + try { + await fs.promises.mkdir(staleDir, { recursive: true }); + await fs.promises.writeFile(staleFile, "sensitive-refresh-token", "utf8"); + const oldTime = new Date(Date.now() - (15 * 60 * 1000)); + await fs.promises.utimes(staleDir, oldTime, oldTime); + await fs.promises.utimes(staleFile, oldTime, oldTime); + + const { previewSyncFromCodexMultiAuth } = await import("../lib/codex-multi-auth-sync.js"); + await expect(previewSyncFromCodexMultiAuth(process.cwd())).resolves.toMatchObject({ + rootDir, + accountsPath: globalPath, + scope: "global", + }); + + expect(vi.mocked(loggerModule.logWarn)).toHaveBeenCalledWith( + expect.stringContaining(`Failed to scrub stale codex sync temp file ${staleFile}: disk io failed`), + ); + } finally { + rmSpy.mockRestore(); + truncateSpy.mockRestore(); + await fs.promises.rm(fakeHome, { recursive: true, force: true }); + } + }); + it("skips source accounts whose emails already exist locally during sync", async () => { const rootDir = join(process.cwd(), ".tmp-codex-multi-auth"); process.env.CODEX_MULTI_AUTH_DIR = rootDir; @@ -1657,14 +1717,17 @@ describe("codex-multi-auth sync", () => { expect(persist).not.toHaveBeenCalled(); }); - it("writes overlap cleanup backups via a temp file before rename", async () => { + it("writes overlap cleanup backups via a temp file and retries transient EACCES renames", async () => { const storageModule = await import("../lib/storage.js"); vi.mocked(storageModule.withAccountStorageTransaction).mockImplementationOnce(async (handler) => handler(defaultTransactionalStorage(), vi.fn(async () => {})), ); const mkdirSpy = vi.spyOn(fs.promises, "mkdir").mockResolvedValue(undefined); const writeSpy = vi.spyOn(fs.promises, "writeFile").mockResolvedValue(undefined); - const renameSpy = vi.spyOn(fs.promises, "rename").mockResolvedValue(undefined); + const renameSpy = vi + .spyOn(fs.promises, "rename") + .mockRejectedValueOnce(Object.assign(new Error("rename locked"), { code: "EACCES" })) + .mockResolvedValueOnce(undefined); const unlinkSpy = vi.spyOn(fs.promises, "unlink").mockResolvedValue(undefined); try { @@ -1675,6 +1738,7 @@ describe("codex-multi-auth sync", () => { const tempBackupPath = writeSpy.mock.calls[0]?.[0]; expect(String(tempBackupPath)).toMatch(/^\/tmp\/overlap-cleanup-backup\.json\.\d+\.[a-z0-9]+\.tmp$/); expect(renameSpy).toHaveBeenCalledWith(tempBackupPath, "/tmp/overlap-cleanup-backup.json"); + expect(renameSpy).toHaveBeenCalledTimes(2); expect(unlinkSpy).not.toHaveBeenCalled(); } finally { mkdirSpy.mockRestore(); diff --git a/test/index.test.ts b/test/index.test.ts index 04073d16..acaaa90e 100644 --- a/test/index.test.ts +++ b/test/index.test.ts @@ -2116,7 +2116,7 @@ describe("OpenAIOAuthPlugin", () => { }); describe("sync maintenance flows", () => { - it("shows overlap cleanup backup hints only when cleanup reports a written backup", async () => { + it("shows the locally tracked overlap cleanup backup path when cleanup fails", async () => { const cliModule = await import("../lib/cli.js"); const confirmModule = await import("../lib/ui/confirm.js"); const syncModule = await import("../lib/codex-multi-auth-sync.js"); @@ -2341,7 +2341,7 @@ describe("OpenAIOAuthPlugin", () => { const writeSpy = vi.spyOn(nodeFsPromises, "writeFile").mockResolvedValue(undefined); const renameSpy = vi .spyOn(nodeFsPromises, "rename") - .mockRejectedValueOnce(Object.assign(new Error("rename locked"), { code: "EPERM" })) + .mockRejectedValueOnce(Object.assign(new Error("rename locked"), { code: "EACCES" })) .mockResolvedValueOnce(undefined); const normalizePath = (value: string) => value.replace(/\\/g, "/"); const now = Date.now(); From 42512522c1301e4c1c710f4e006e31433ab5d76d Mon Sep 17 00:00:00 2001 From: ndycode Date: Sun, 15 Mar 2026 20:53:20 +0800 Subject: [PATCH 14/15] Fix final sync review follow-ups --- lib/codex-multi-auth-sync.ts | 27 +- test/codex-multi-auth-sync.test.ts | 599 +++++++++++++++++------------ test/index.test.ts | 6 +- 3 files changed, 385 insertions(+), 247 deletions(-) diff --git a/lib/codex-multi-auth-sync.ts b/lib/codex-multi-auth-sync.ts index 22f2e042..bd1994bc 100644 --- a/lib/codex-multi-auth-sync.ts +++ b/lib/codex-multi-auth-sync.ts @@ -10,7 +10,7 @@ import { loadAccounts, normalizeAccountStorage, previewImportAccountsWithExistingStorage, - withAccountStorageTransaction, + withAccountAndFlaggedStorageTransaction, type AccountStorageV3, type ImportAccountsResult, } from "./storage.js"; @@ -270,7 +270,7 @@ async function scrubStaleNormalizedImportTempFile(candidateDir: string): Promise async function renameOverlapCleanupBackupWithRetry(sourcePath: string, destinationPath: string): Promise { let lastError: NodeJS.ErrnoException | null = null; - for (let attempt = 0; attempt < BACKUP_RENAME_RETRY_DELAYS_MS.length; attempt += 1) { + for (let attempt = 0; attempt <= BACKUP_RENAME_RETRY_DELAYS_MS.length; attempt += 1) { try { await fs.rename(sourcePath, destinationPath); return; @@ -291,6 +291,8 @@ async function renameOverlapCleanupBackupWithRetry(sourcePath: string, destinati if (lastError) { throw lastError; } + + throw new Error(`Failed to rename overlap cleanup backup ${sourcePath} to ${destinationPath}.`); } async function withNormalizedImportFile( @@ -977,11 +979,6 @@ function getSyncCapacityLimit(): number { } const message = `${SYNC_MAX_ACCOUNTS_OVERRIDE_ENV} override value "${override}" is not a positive integer; ignoring.`; logWarn(message); - try { - process.stderr.write(`${message}\n`); - } catch { - // best-effort warning for non-interactive shells - } return ACCOUNT_LIMITS.MAX_ACCOUNTS; } @@ -1259,7 +1256,7 @@ export async function previewCodexMultiAuthSyncedOverlapCleanup(): Promise { - return withAccountStorageTransaction(async (current, persist) => { + return withAccountAndFlaggedStorageTransaction(async ({ accounts: current, flagged: currentFlaggedStorage }, persist) => { const fallback = current ?? { version: 3 as const, accounts: [], @@ -1292,7 +1289,19 @@ export async function cleanupCodexMultiAuthSyncedOverlaps( try { const plan = buildCodexMultiAuthOverlapCleanupPlan(fallback); if (plan.nextStorage) { - await persist(plan.nextStorage); + const remainingRefreshTokens = new Set( + plan.nextStorage.accounts + .map((account) => normalizeTrimmedIdentity(account.refreshToken)) + .filter((refreshToken): refreshToken is string => refreshToken !== undefined), + ); + await persist.flagged({ + version: 1, + accounts: currentFlaggedStorage.accounts.filter((flaggedAccount) => { + const refreshToken = normalizeTrimmedIdentity(flaggedAccount.refreshToken); + return refreshToken !== undefined && remainingRefreshTokens.has(refreshToken); + }), + }); + await persist.accounts(plan.nextStorage); } return plan.result; } catch (error) { diff --git a/test/codex-multi-auth-sync.test.ts b/test/codex-multi-auth-sync.test.ts index 866337a8..6a4acfce 100644 --- a/test/codex-multi-auth-sync.test.ts +++ b/test/codex-multi-auth-sync.test.ts @@ -3,7 +3,7 @@ import * as fs from "node:fs"; import * as os from "node:os"; import { join, win32 as pathWin32 } from "node:path"; import { findProjectRoot, getProjectStorageKey } from "../lib/storage/paths.js"; -import type { AccountStorageV3 } from "../lib/storage.js"; +import type { AccountStorageV3, FlaggedAccountStorageV1 } from "../lib/storage.js"; vi.mock("../lib/logger.js", () => ({ logWarn: vi.fn(), @@ -85,6 +85,42 @@ vi.mock("../lib/storage.js", () => ({ vi.fn(async () => {}), ), ), + withAccountAndFlaggedStorageTransaction: vi.fn(async (handler) => + handler( + { + accounts: { + version: 3, + activeIndex: 0, + activeIndexByFamily: {}, + accounts: [ + { + accountId: "org-example123", + accountIdSource: "org", + refreshToken: "sync-refresh", + addedAt: 1, + lastUsed: 1, + }, + { + accountId: "org-example123", + organizationId: "org-example123", + accountIdSource: "org", + refreshToken: "sync-refresh", + addedAt: 2, + lastUsed: 2, + }, + ], + }, + flagged: { + version: 1, + accounts: [], + }, + }, + { + accounts: vi.fn(async () => {}), + flagged: vi.fn(async () => {}), + }, + ), + ), })); describe("codex-multi-auth sync", () => { @@ -134,6 +170,34 @@ describe("codex-multi-auth sync", () => { }, ], }); + const defaultFlaggedStorage = (): FlaggedAccountStorageV1 => ({ + version: 1, + accounts: [], + }); + const mockOverlapCleanupTransaction = async ( + accounts: AccountStorageV3, + options: { + flagged?: FlaggedAccountStorageV1; + persistAccounts?: (nextStorage: AccountStorageV3) => Promise; + persistFlagged?: (nextStorage: FlaggedAccountStorageV1) => Promise; + } = {}, + ) => { + const storageModule = await import("../lib/storage.js"); + const persist = { + accounts: vi.fn(options.persistAccounts ?? (async (_nextStorage: AccountStorageV3) => {})), + flagged: vi.fn(options.persistFlagged ?? (async (_nextStorage: FlaggedAccountStorageV1) => {})), + }; + vi.mocked(storageModule.withAccountAndFlaggedStorageTransaction).mockImplementationOnce(async (handler) => + handler( + { + accounts, + flagged: options.flagged ?? defaultFlaggedStorage(), + }, + persist, + ), + ); + return persist; + }; beforeEach(async () => { vi.resetModules(); @@ -179,6 +243,19 @@ describe("codex-multi-auth sync", () => { vi.mocked(storageModule.withAccountStorageTransaction).mockImplementation(async (handler) => handler(defaultTransactionalStorage(), vi.fn(async () => {})), ); + vi.mocked(storageModule.withAccountAndFlaggedStorageTransaction).mockReset(); + vi.mocked(storageModule.withAccountAndFlaggedStorageTransaction).mockImplementation(async (handler) => + handler( + { + accounts: defaultTransactionalStorage(), + flagged: defaultFlaggedStorage(), + }, + { + accounts: vi.fn(async () => {}), + flagged: vi.fn(async () => {}), + }, + ), + ); delete process.env.CODEX_MULTI_AUTH_DIR; delete process.env.CODEX_HOME; }); @@ -370,6 +447,16 @@ describe("codex-multi-auth sync", () => { expect(() => getCodexMultiAuthSourceRootDir()).toThrow(/local absolute path/i); }); + it("rejects relative Windows paths for CODEX_MULTI_AUTH_DIR", async () => { + Object.defineProperty(process, "platform", { value: "win32" }); + process.env.CODEX_MULTI_AUTH_DIR = "relative\\path\\multi-auth"; + process.env.USERPROFILE = "C:\\Users\\tester"; + process.env.HOME = "C:\\Users\\tester"; + + const { getCodexMultiAuthSourceRootDir } = await import("../lib/codex-multi-auth-sync.js"); + expect(() => getCodexMultiAuthSourceRootDir()).toThrow(/absolute local path/i); + }); + it("accepts extended-length local Windows paths for CODEX_MULTI_AUTH_DIR", async () => { Object.defineProperty(process, "platform", { value: "win32" }); process.env.USERPROFILE = "C:\\Users\\tester"; @@ -725,6 +812,14 @@ describe("codex-multi-auth sync", () => { .mockRejectedValueOnce(Object.assign(new Error("permission denied"), { code })) .mockResolvedValueOnce(undefined); const loggerModule = await import("../lib/logger.js"); + const setTimeoutSpy = vi.spyOn(globalThis, "setTimeout").mockImplementation( + ((handler: TimerHandler, _timeout?: number, ...args: unknown[]) => { + if (typeof handler === "function") { + handler(...args); + } + return 0 as ReturnType; + }) as typeof setTimeout, + ); try { const { previewSyncFromCodexMultiAuth } = await import("../lib/codex-multi-auth-sync.js"); @@ -739,6 +834,7 @@ describe("codex-multi-auth sync", () => { expect.stringContaining("Failed to remove temporary codex sync directory"), ); } finally { + setTimeoutSpy.mockRestore(); rmSpy.mockRestore(); } }, @@ -802,6 +898,14 @@ describe("codex-multi-auth sync", () => { .mockRejectedValueOnce(Object.assign(new Error("busy"), { code: "EBUSY" })) .mockResolvedValueOnce(undefined); const loggerModule = await import("../lib/logger.js"); + const setTimeoutSpy = vi.spyOn(globalThis, "setTimeout").mockImplementation( + ((handler: TimerHandler, _timeout?: number, ...args: unknown[]) => { + if (typeof handler === "function") { + handler(...args); + } + return 0 as ReturnType; + }) as typeof setTimeout, + ); try { const { previewSyncFromCodexMultiAuth } = await import("../lib/codex-multi-auth-sync.js"); @@ -818,6 +922,7 @@ describe("codex-multi-auth sync", () => { expect.stringContaining("Failed to remove temporary codex sync directory"), ); } finally { + setTimeoutSpy.mockRestore(); rmSpy.mockRestore(); } }); @@ -999,6 +1104,14 @@ describe("codex-multi-auth sync", () => { return originalRm(path, options as never); }); const loggerModule = await import("../lib/logger.js"); + const setTimeoutSpy = vi.spyOn(globalThis, "setTimeout").mockImplementation( + ((handler: TimerHandler, _timeout?: number, ...args: unknown[]) => { + if (typeof handler === "function") { + handler(...args); + } + return 0 as ReturnType; + }) as typeof setTimeout, + ); try { await fs.promises.mkdir(staleDir, { recursive: true }); @@ -1021,6 +1134,7 @@ describe("codex-multi-auth sync", () => { ); await expect(fs.promises.stat(staleDir)).rejects.toThrow(); } finally { + setTimeoutSpy.mockRestore(); rmSpy.mockRestore(); await fs.promises.rm(fakeHome, { recursive: true, force: true }); } @@ -1531,13 +1645,19 @@ describe("codex-multi-auth sync", () => { const loggerModule = await import("../lib/logger.js"); const { previewSyncFromCodexMultiAuth } = await import("../lib/codex-multi-auth-sync.js"); + const stderrSpy = vi.spyOn(process.stderr, "write").mockImplementation(() => true); - await expect(previewSyncFromCodexMultiAuth(process.cwd())).resolves.toMatchObject({ - accountsPath: globalPath, - }); - expect(vi.mocked(loggerModule.logWarn)).toHaveBeenCalledWith( - expect.stringContaining('CODEX_AUTH_SYNC_MAX_ACCOUNTS override value "0" is not a positive integer; ignoring.'), - ); + try { + await expect(previewSyncFromCodexMultiAuth(process.cwd())).resolves.toMatchObject({ + accountsPath: globalPath, + }); + expect(vi.mocked(loggerModule.logWarn)).toHaveBeenCalledWith( + expect.stringContaining('CODEX_AUTH_SYNC_MAX_ACCOUNTS override value "0" is not a positive integer; ignoring.'), + ); + expect(stderrSpy).not.toHaveBeenCalled(); + } finally { + stderrSpy.mockRestore(); + } }); it("reports when the source alone exceeds a finite sync capacity", async () => { @@ -1613,35 +1733,30 @@ describe("codex-multi-auth sync", () => { it("cleans up tagged synced overlaps by normalizing org-scoped identities first", async () => { const storageModule = await import("../lib/storage.js"); - vi.mocked(storageModule.withAccountStorageTransaction).mockImplementationOnce(async (handler) => - handler( + await mockOverlapCleanupTransaction({ + version: 3, + activeIndex: 0, + activeIndexByFamily: {}, + accounts: [ { - version: 3, - activeIndex: 0, - activeIndexByFamily: {}, - accounts: [ - { - accountId: "org-example123", - accountIdSource: "org", - accountTags: ["codex-multi-auth-sync"], - refreshToken: "sync-refresh", - addedAt: 1, - lastUsed: 1, - }, - { - accountId: "org-example123", - organizationId: "org-example123", - accountIdSource: "org", - accountTags: ["codex-multi-auth-sync"], - refreshToken: "sync-refresh", - addedAt: 2, - lastUsed: 2, - }, - ], + accountId: "org-example123", + accountIdSource: "org", + accountTags: ["codex-multi-auth-sync"], + refreshToken: "sync-refresh", + addedAt: 1, + lastUsed: 1, }, - vi.fn(async () => {}), - ), - ); + { + accountId: "org-example123", + organizationId: "org-example123", + accountIdSource: "org", + accountTags: ["codex-multi-auth-sync"], + refreshToken: "sync-refresh", + addedAt: 2, + lastUsed: 2, + }, + ], + }); vi.mocked(storageModule.normalizeAccountStorage).mockImplementationOnce((value: unknown): AccountStorageV3 => { const record = value as { version: 3; @@ -1664,29 +1779,22 @@ describe("codex-multi-auth sync", () => { }); it("does not count synced overlap records as updated when only key order differs", async () => { - const storageModule = await import("../lib/storage.js"); - const persist = vi.fn(async () => {}); - vi.mocked(storageModule.withAccountStorageTransaction).mockImplementationOnce(async (handler) => - handler( + const persist = await mockOverlapCleanupTransaction({ + version: 3, + activeIndex: 0, + activeIndexByFamily: {}, + accounts: [ { - version: 3, - activeIndex: 0, - activeIndexByFamily: {}, - accounts: [ - { - refreshToken: "sync-token", - accountTags: ["codex-multi-auth-sync"], - organizationId: "org-sync", - accountId: "org-sync", - accountIdSource: "org", - addedAt: 2, - lastUsed: 2, - }, - ], + refreshToken: "sync-token", + accountTags: ["codex-multi-auth-sync"], + organizationId: "org-sync", + accountId: "org-sync", + accountIdSource: "org", + addedAt: 2, + lastUsed: 2, }, - persist, - ), - ); + ], + }); mockSourceStorageFile( "/tmp/opencode-accounts.json", JSON.stringify({ @@ -1713,39 +1821,48 @@ describe("codex-multi-auth sync", () => { after: 1, removed: 0, updated: 0, - }); - expect(persist).not.toHaveBeenCalled(); + }); + expect(persist.accounts).not.toHaveBeenCalled(); + expect(persist.flagged).not.toHaveBeenCalled(); }); it("writes overlap cleanup backups via a temp file and retries transient EACCES renames", async () => { - const storageModule = await import("../lib/storage.js"); - vi.mocked(storageModule.withAccountStorageTransaction).mockImplementationOnce(async (handler) => - handler(defaultTransactionalStorage(), vi.fn(async () => {})), - ); - const mkdirSpy = vi.spyOn(fs.promises, "mkdir").mockResolvedValue(undefined); - const writeSpy = vi.spyOn(fs.promises, "writeFile").mockResolvedValue(undefined); - const renameSpy = vi - .spyOn(fs.promises, "rename") - .mockRejectedValueOnce(Object.assign(new Error("rename locked"), { code: "EACCES" })) - .mockResolvedValueOnce(undefined); - const unlinkSpy = vi.spyOn(fs.promises, "unlink").mockResolvedValue(undefined); + await mockOverlapCleanupTransaction(defaultTransactionalStorage()); + const mkdirSpy = vi.spyOn(fs.promises, "mkdir").mockResolvedValue(undefined); + const writeSpy = vi.spyOn(fs.promises, "writeFile").mockResolvedValue(undefined); + const renameSpy = vi + .spyOn(fs.promises, "rename") + .mockRejectedValueOnce(Object.assign(new Error("rename locked"), { code: "EACCES" })) + .mockRejectedValueOnce(Object.assign(new Error("rename locked"), { code: "EACCES" })) + .mockRejectedValueOnce(Object.assign(new Error("rename locked"), { code: "EACCES" })) + .mockResolvedValueOnce(undefined); + const unlinkSpy = vi.spyOn(fs.promises, "unlink").mockResolvedValue(undefined); + const setTimeoutSpy = vi.spyOn(globalThis, "setTimeout").mockImplementation( + ((handler: TimerHandler, _timeout?: number, ...args: unknown[]) => { + if (typeof handler === "function") { + handler(...args); + } + return 0 as ReturnType; + }) as typeof setTimeout, + ); - try { - const { cleanupCodexMultiAuthSyncedOverlaps } = await import("../lib/codex-multi-auth-sync.js"); - await cleanupCodexMultiAuthSyncedOverlaps("/tmp/overlap-cleanup-backup.json"); - - expect(mkdirSpy).toHaveBeenCalledWith("/tmp", { recursive: true }); - const tempBackupPath = writeSpy.mock.calls[0]?.[0]; - expect(String(tempBackupPath)).toMatch(/^\/tmp\/overlap-cleanup-backup\.json\.\d+\.[a-z0-9]+\.tmp$/); - expect(renameSpy).toHaveBeenCalledWith(tempBackupPath, "/tmp/overlap-cleanup-backup.json"); - expect(renameSpy).toHaveBeenCalledTimes(2); - expect(unlinkSpy).not.toHaveBeenCalled(); - } finally { - mkdirSpy.mockRestore(); - writeSpy.mockRestore(); - renameSpy.mockRestore(); - unlinkSpy.mockRestore(); - } + try { + const { cleanupCodexMultiAuthSyncedOverlaps } = await import("../lib/codex-multi-auth-sync.js"); + await cleanupCodexMultiAuthSyncedOverlaps("/tmp/overlap-cleanup-backup.json"); + + expect(mkdirSpy).toHaveBeenCalledWith("/tmp", { recursive: true }); + const tempBackupPath = writeSpy.mock.calls[0]?.[0]; + expect(String(tempBackupPath)).toMatch(/^\/tmp\/overlap-cleanup-backup\.json\.\d+\.[a-z0-9]+\.tmp$/); + expect(renameSpy).toHaveBeenCalledWith(tempBackupPath, "/tmp/overlap-cleanup-backup.json"); + expect(renameSpy).toHaveBeenCalledTimes(4); + expect(unlinkSpy).not.toHaveBeenCalled(); + } finally { + setTimeoutSpy.mockRestore(); + mkdirSpy.mockRestore(); + writeSpy.mockRestore(); + renameSpy.mockRestore(); + unlinkSpy.mockRestore(); + } }); it.each([ @@ -1754,36 +1871,29 @@ describe("codex-multi-auth sync", () => { ] as const)( "aborts overlap cleanup persistence when backup %s fails and attempts temp cleanup", async (failureStep, expectedMessage) => { - const storageModule = await import("../lib/storage.js"); - const persist = vi.fn(async (_next: AccountStorageV3) => {}); - vi.mocked(storageModule.withAccountStorageTransaction).mockImplementationOnce(async (handler) => - handler( + const persist = await mockOverlapCleanupTransaction({ + version: 3, + activeIndex: 0, + activeIndexByFamily: {}, + accounts: [ + { + accountId: "org-local", + organizationId: "org-local", + accountIdSource: "org", + email: "shared@example.com", + refreshToken: "rt-local", + addedAt: 5, + lastUsed: 5, + }, { - version: 3, - activeIndex: 0, - activeIndexByFamily: {}, - accounts: [ - { - accountId: "org-local", - organizationId: "org-local", - accountIdSource: "org", - email: "shared@example.com", - refreshToken: "rt-local", - addedAt: 5, - lastUsed: 5, - }, - { - accountTags: ["codex-multi-auth-sync"], - email: "shared@example.com", - refreshToken: "rt-sync", - addedAt: 4, - lastUsed: 4, - }, - ], + accountTags: ["codex-multi-auth-sync"], + email: "shared@example.com", + refreshToken: "rt-sync", + addedAt: 4, + lastUsed: 4, }, - persist, - ), - ); + ], + }); const mkdirSpy = vi.spyOn(fs.promises, "mkdir").mockResolvedValue(undefined); const writeSpy = vi .spyOn(fs.promises, "writeFile") @@ -1816,7 +1926,8 @@ describe("codex-multi-auth sync", () => { : renameSpy.mock.calls[0]?.[0]; expect(String(tempBackupPath)).toMatch(/^\/tmp\/overlap-cleanup-backup\.json\.\d+\.[a-z0-9]+\.tmp$/); expect(unlinkSpy).toHaveBeenCalledWith(tempBackupPath); - expect(persist).not.toHaveBeenCalled(); + expect(persist.accounts).not.toHaveBeenCalled(); + expect(persist.flagged).not.toHaveBeenCalled(); } finally { mkdirSpy.mockRestore(); writeSpy.mockRestore(); @@ -1827,37 +1938,35 @@ describe("codex-multi-auth sync", () => { ); it("annotates overlap cleanup failures with the written backup path", async () => { - const storageModule = await import("../lib/storage.js"); - const persist = vi.fn(async () => { - throw new Error("persist failed"); - }); - vi.mocked(storageModule.withAccountStorageTransaction).mockImplementationOnce(async (handler) => - handler( - { - version: 3, - activeIndex: 0, - activeIndexByFamily: {}, - accounts: [ - { - accountId: "org-local", - organizationId: "org-local", - accountIdSource: "org", - email: "shared@example.com", - refreshToken: "rt-local", - addedAt: 5, - lastUsed: 5, - }, - { - accountTags: ["codex-multi-auth-sync"], - email: "shared@example.com", - refreshToken: "rt-sync", - addedAt: 4, - lastUsed: 4, - }, - ], + await mockOverlapCleanupTransaction( + { + version: 3, + activeIndex: 0, + activeIndexByFamily: {}, + accounts: [ + { + accountId: "org-local", + organizationId: "org-local", + accountIdSource: "org", + email: "shared@example.com", + refreshToken: "rt-local", + addedAt: 5, + lastUsed: 5, + }, + { + accountTags: ["codex-multi-auth-sync"], + email: "shared@example.com", + refreshToken: "rt-sync", + addedAt: 4, + lastUsed: 4, + }, + ], + }, + { + persistAccounts: async () => { + throw new Error("persist failed"); }, - persist, - ), + }, ); const mkdirSpy = vi.spyOn(fs.promises, "mkdir").mockResolvedValue(undefined); const writeSpy = vi.spyOn(fs.promises, "writeFile").mockResolvedValue(undefined); @@ -1889,51 +1998,45 @@ describe("codex-multi-auth sync", () => { it("limits overlap cleanup to accounts tagged from codex-multi-auth sync", async () => { - const storageModule = await import("../lib/storage.js"); - vi.mocked(storageModule.withAccountStorageTransaction).mockImplementationOnce(async (handler) => - handler( + await mockOverlapCleanupTransaction({ + version: 3, + activeIndex: 0, + activeIndexByFamily: {}, + accounts: [ { - version: 3, - activeIndex: 0, - activeIndexByFamily: {}, - accounts: [ - { - refreshToken: "legacy-a", - email: "shared@example.com", - addedAt: 1, - lastUsed: 1, - }, - { - refreshToken: "legacy-b", - email: "shared@example.com", - addedAt: 2, - lastUsed: 2, - }, - { - accountId: "org-sync", - organizationId: "org-sync", - accountIdSource: "org", - accountTags: ["codex-multi-auth-sync"], - email: "sync@example.com", - refreshToken: "sync-token", - addedAt: 3, - lastUsed: 3, - }, - { - accountId: "org-sync", - organizationId: "org-sync", - accountIdSource: "org", - accountTags: ["codex-multi-auth-sync"], - email: "sync@example.com", - refreshToken: "sync-token", - addedAt: 4, - lastUsed: 4, - }, - ], + refreshToken: "legacy-a", + email: "shared@example.com", + addedAt: 1, + lastUsed: 1, }, - vi.fn(async () => {}), - ), - ); + { + refreshToken: "legacy-b", + email: "shared@example.com", + addedAt: 2, + lastUsed: 2, + }, + { + accountId: "org-sync", + organizationId: "org-sync", + accountIdSource: "org", + accountTags: ["codex-multi-auth-sync"], + email: "sync@example.com", + refreshToken: "sync-token", + addedAt: 3, + lastUsed: 3, + }, + { + accountId: "org-sync", + organizationId: "org-sync", + accountIdSource: "org", + accountTags: ["codex-multi-auth-sync"], + email: "sync@example.com", + refreshToken: "sync-token", + addedAt: 4, + lastUsed: 4, + }, + ], + }); const { cleanupCodexMultiAuthSyncedOverlaps } = await import("../lib/codex-multi-auth-sync.js"); await expect(cleanupCodexMultiAuthSyncedOverlaps()).resolves.toEqual({ @@ -1944,36 +2047,50 @@ describe("codex-multi-auth sync", () => { }); }); - it("removes synced accounts that overlap preserved local accounts", async () => { - const storageModule = await import("../lib/storage.js"); - const persist = vi.fn(async (_next: AccountStorageV3) => {}); - vi.mocked(storageModule.withAccountStorageTransaction).mockImplementationOnce(async (handler) => - handler( - { - version: 3, - activeIndex: 0, - activeIndexByFamily: {}, + it("removes synced accounts that overlap preserved local accounts and prunes stale flagged entries", async () => { + const persist = await mockOverlapCleanupTransaction( + { + version: 3, + activeIndex: 0, + activeIndexByFamily: {}, + accounts: [ + { + accountId: "org-local", + organizationId: "org-local", + accountIdSource: "org", + email: "shared@example.com", + refreshToken: "rt-local", + addedAt: 5, + lastUsed: 5, + }, + { + accountTags: ["codex-multi-auth-sync"], + email: "shared@example.com", + refreshToken: "rt-sync", + addedAt: 4, + lastUsed: 4, + }, + ], + }, + { + flagged: { + version: 1, accounts: [ { - accountId: "org-local", - organizationId: "org-local", - accountIdSource: "org", - email: "shared@example.com", refreshToken: "rt-local", addedAt: 5, lastUsed: 5, + flaggedAt: 10, }, { - accountTags: ["codex-multi-auth-sync"], - email: "shared@example.com", refreshToken: "rt-sync", addedAt: 4, lastUsed: 4, + flaggedAt: 20, }, ], }, - persist, - ), + }, ); const { cleanupCodexMultiAuthSyncedOverlaps } = await import("../lib/codex-multi-auth-sync.js"); @@ -1983,57 +2100,65 @@ describe("codex-multi-auth sync", () => { removed: 1, updated: 0, }); - const saved = persist.mock.calls[0]?.[0]; + const saved = persist.accounts.mock.calls[0]?.[0]; if (!saved) { throw new Error("Expected persisted overlap cleanup result"); } expect(saved.accounts).toHaveLength(1); expect(saved.accounts[0]?.accountId).toBe("org-local"); + expect(persist.flagged).toHaveBeenCalledWith({ + version: 1, + accounts: [ + expect.objectContaining({ + refreshToken: "rt-local", + }), + ], + }); }); it("remaps active indices when synced overlap cleanup reorders accounts", async () => { const storageModule = await import("../lib/storage.js"); - const persist = vi.fn(async (_next: AccountStorageV3) => {}); + const persistAccounts = vi.fn(async (_next: AccountStorageV3) => {}); vi.mocked(storageModule.deduplicateAccounts).mockImplementationOnce( (accounts: AccountStorageV3["accounts"]) => [accounts[1], accounts[0]].filter(Boolean), ); - vi.mocked(storageModule.withAccountStorageTransaction).mockImplementationOnce(async (handler) => - handler( - { - version: 3, - activeIndex: 0, - activeIndexByFamily: { codex: 0 }, - accounts: [ - { - accountId: "sync-primary", - organizationId: "shared-org", - accountIdSource: "org", - accountTags: ["codex-multi-auth-sync"], - email: "primary@example.com", - refreshToken: "sync-token-primary", - addedAt: 3, - lastUsed: 3, - }, - { - accountId: "sync-sibling", - organizationId: "shared-org", - accountIdSource: "org", - accountTags: ["codex-multi-auth-sync"], - email: "sibling@example.com", - refreshToken: "sync-token-sibling", - addedAt: 4, - lastUsed: 4, - }, - ], - }, - persist, - ), + await mockOverlapCleanupTransaction( + { + version: 3, + activeIndex: 0, + activeIndexByFamily: { codex: 0 }, + accounts: [ + { + accountId: "sync-primary", + organizationId: "shared-org", + accountIdSource: "org", + accountTags: ["codex-multi-auth-sync"], + email: "primary@example.com", + refreshToken: "sync-token-primary", + addedAt: 3, + lastUsed: 3, + }, + { + accountId: "sync-sibling", + organizationId: "shared-org", + accountIdSource: "org", + accountTags: ["codex-multi-auth-sync"], + email: "sibling@example.com", + refreshToken: "sync-token-sibling", + addedAt: 4, + lastUsed: 4, + }, + ], + }, + { + persistAccounts, + }, ); const { cleanupCodexMultiAuthSyncedOverlaps } = await import("../lib/codex-multi-auth-sync.js"); await cleanupCodexMultiAuthSyncedOverlaps(); - const saved = persist.mock.calls[0]?.[0]; + const saved = persistAccounts.mock.calls[0]?.[0]; if (!saved) { throw new Error("Expected persisted overlap cleanup result"); } diff --git a/test/index.test.ts b/test/index.test.ts index acaaa90e..14fd04f8 100644 --- a/test/index.test.ts +++ b/test/index.test.ts @@ -564,6 +564,7 @@ beforeEach(async () => { vi.mocked(storageModule.saveFlaggedAccounts).mockReset(); vi.mocked(storageModule.withFlaggedAccountsTransaction).mockReset(); vi.mocked(storageModule.normalizeAccountStorage).mockReset(); + vi.mocked(storageModule.createTimestampedBackupPath).mockReset(); vi.mocked(cliModule.promptLoginMode).mockResolvedValue({ mode: "add" }); vi.mocked(cliModule.promptAddAnotherAccount).mockResolvedValue(false); @@ -694,6 +695,9 @@ beforeEach(async () => { }, ); vi.mocked(storageModule.normalizeAccountStorage).mockImplementation((value: unknown) => value); + vi.mocked(storageModule.createTimestampedBackupPath).mockImplementation( + (prefix?: string) => `/tmp/${prefix ?? "codex-backup"}-20260101-000000.json`, + ); }); type ToolExecute = { execute: (args: T) => Promise }; @@ -2296,7 +2300,7 @@ describe("OpenAIOAuthPlugin", () => { .mockResolvedValue({ mode: "cancel" }); vi.mocked(cliModule.promptCodexMultiAuthSyncPrune).mockResolvedValueOnce([0]); vi.mocked(confirmModule.confirm).mockResolvedValue(true); - vi.mocked(storageModule.createTimestampedBackupPath).mockImplementation( + vi.mocked(storageModule.createTimestampedBackupPath).mockImplementationOnce( (prefix?: string) => `\\tmp\\${prefix ?? "codex-backup"}-20260101-000000.json`, ); vi.mocked(syncModule.previewSyncFromCodexMultiAuth) From a4b485e7480e7fe6bec1c433b82dcf15ae69f59b Mon Sep 17 00:00:00 2001 From: ndycode Date: Mon, 16 Mar 2026 01:04:04 +0800 Subject: [PATCH 15/15] Fix overlap cleanup rollback on persist failure --- lib/codex-multi-auth-sync.ts | 27 +++++++++++--- test/codex-multi-auth-sync.test.ts | 57 ++++++++++++++++++++++++++++++ 2 files changed, 80 insertions(+), 4 deletions(-) diff --git a/lib/codex-multi-auth-sync.ts b/lib/codex-multi-auth-sync.ts index bd1994bc..7178d945 100644 --- a/lib/codex-multi-auth-sync.ts +++ b/lib/codex-multi-auth-sync.ts @@ -1294,14 +1294,33 @@ export async function cleanupCodexMultiAuthSyncedOverlaps( .map((account) => normalizeTrimmedIdentity(account.refreshToken)) .filter((refreshToken): refreshToken is string => refreshToken !== undefined), ); - await persist.flagged({ - version: 1, + const nextFlaggedStorage = { + version: 1 as const, accounts: currentFlaggedStorage.accounts.filter((flaggedAccount) => { const refreshToken = normalizeTrimmedIdentity(flaggedAccount.refreshToken); return refreshToken !== undefined && remainingRefreshTokens.has(refreshToken); }), - }); - await persist.accounts(plan.nextStorage); + }; + await persist.flagged(nextFlaggedStorage); + try { + await persist.accounts(plan.nextStorage); + } catch (accountsError) { + try { + await persist.flagged(currentFlaggedStorage); + } catch (restoreFlaggedError) { + const accountsMessage = + accountsError instanceof Error ? accountsError.message : String(accountsError); + const restoreFlaggedMessage = + restoreFlaggedError instanceof Error + ? restoreFlaggedError.message + : String(restoreFlaggedError); + throw new Error( + `Failed to persist overlap cleanup accounts: ${accountsMessage}; ` + + `failed to restore flagged storage: ${restoreFlaggedMessage}`, + ); + } + throw accountsError; + } } return plan.result; } catch (error) { diff --git a/test/codex-multi-auth-sync.test.ts b/test/codex-multi-auth-sync.test.ts index 6a4acfce..95d7546c 100644 --- a/test/codex-multi-auth-sync.test.ts +++ b/test/codex-multi-auth-sync.test.ts @@ -1996,6 +1996,63 @@ describe("codex-multi-auth sync", () => { } }); + it("rolls back flagged storage when overlap cleanup account persistence fails", async () => { + const originalFlaggedStorage: FlaggedAccountStorageV1 = { + version: 1, + accounts: [ + { + accountId: "flagged-sync", + organizationId: "org-sync", + refreshToken: "rt-sync", + flaggedAt: 123, + addedAt: 4, + lastUsed: 4, + }, + ], + }; + const persist = await mockOverlapCleanupTransaction( + { + version: 3, + activeIndex: 0, + activeIndexByFamily: {}, + accounts: [ + { + accountId: "org-local", + organizationId: "org-local", + accountIdSource: "org", + email: "shared@example.com", + refreshToken: "rt-local", + addedAt: 5, + lastUsed: 5, + }, + { + accountTags: ["codex-multi-auth-sync"], + email: "shared@example.com", + refreshToken: "rt-sync", + addedAt: 4, + lastUsed: 4, + }, + ], + }, + { + flagged: originalFlaggedStorage, + persistAccounts: async () => { + throw new Error("persist failed"); + }, + }, + ); + + const { cleanupCodexMultiAuthSyncedOverlaps } = await import("../lib/codex-multi-auth-sync.js"); + await expect(cleanupCodexMultiAuthSyncedOverlaps()).rejects.toThrow("persist failed"); + + expect(persist.flagged).toHaveBeenCalledTimes(2); + expect(persist.flagged).toHaveBeenNthCalledWith(1, { + version: 1, + accounts: [], + }); + expect(persist.flagged).toHaveBeenNthCalledWith(2, originalFlaggedStorage); + }); + it("limits overlap cleanup to accounts tagged from codex-multi-auth sync", async () => { await mockOverlapCleanupTransaction({