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/config.ts b/lib/config.ts index af93ee73..d3241d11 100644 --- a/lib/config.ts +++ b/lib/config.ts @@ -1,5 +1,5 @@ -import { readFileSync, existsSync } from "node:fs"; -import { join } from "node:path"; +import { promises as fs, readFileSync, existsSync } from "node:fs"; +import { dirname, join } from "node:path"; import { homedir } from "node:os"; import type { PluginConfig } from "./types.js"; import { @@ -16,6 +16,7 @@ const TUI_GLYPH_MODES = new Set(["ascii", "unicode", "auto"]); const REQUEST_TRANSFORM_MODES = new Set(["native", "legacy"]); const UNSUPPORTED_CODEX_POLICIES = new Set(["strict", "fallback"]); const RETRY_PROFILES = new Set(["conservative", "balanced", "aggressive"]); +let configMutationMutex: Promise = Promise.resolve(); export type UnsupportedCodexPolicy = "strict" | "fallback"; @@ -111,7 +112,39 @@ function stripUtf8Bom(content: string): string { } function isRecord(value: unknown): value is Record { - return value !== null && typeof value === "object"; + return value !== null && typeof value === "object" && !Array.isArray(value); +} + +type RawPluginConfig = Record; + +function withConfigMutationLock(fn: () => Promise): Promise { + const previous = configMutationMutex; + let release: () => void; + configMutationMutex = new Promise((resolve) => { + release = resolve; + }); + return previous.then(fn).finally(() => release()); +} + +async function renameConfigWithWindowsRetry(sourcePath: string, destinationPath: string): Promise { + let lastError: NodeJS.ErrnoException | null = null; + for (let attempt = 0; attempt < 5; attempt += 1) { + try { + await fs.rename(sourcePath, destinationPath); + return; + } catch (error) { + const code = (error as NodeJS.ErrnoException).code; + if (process.platform === "win32" && (code === "EPERM" || code === "EBUSY")) { + lastError = error as NodeJS.ErrnoException; + await new Promise((resolve) => setTimeout(resolve, 10 * 2 ** attempt)); + continue; + } + throw error; + } + } + if (lastError) { + throw lastError; + } } /** @@ -501,3 +534,62 @@ export function getStreamStallTimeoutMs(pluginConfig: PluginConfig): number { { min: 1_000 }, ); } + +async function savePluginConfigMutation( + mutate: (current: RawPluginConfig) => RawPluginConfig, +): Promise { + await withConfigMutationLock(async () => { + await fs.mkdir(dirname(CONFIG_PATH), { recursive: true }); + const current = existsSync(CONFIG_PATH) + ? await (async () => { + const raw = stripUtf8Bom(await fs.readFile(CONFIG_PATH, "utf-8")); + let parsed: unknown; + try { + parsed = JSON.parse(raw) as unknown; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + throw new Error(`Invalid JSON in config file ${CONFIG_PATH}: ${message}`); + } + if (!isRecord(parsed)) { + throw new Error(`Config file must contain a JSON object: ${CONFIG_PATH}`); + } + return { ...parsed }; + })() + : {}; + const next = mutate(current); + const tempPath = `${CONFIG_PATH}.${process.pid}.${Date.now()}.tmp`; + try { + await fs.writeFile(tempPath, `${JSON.stringify(next, null, 2)}\n`, { + encoding: "utf-8", + mode: 0o600, + }); + await renameConfigWithWindowsRetry(tempPath, CONFIG_PATH); + } catch (error) { + try { + await fs.unlink(tempPath); + } catch { + // Best effort cleanup only. + } + throw error; + } + }); +} + +export function getSyncFromCodexMultiAuthEnabled(pluginConfig: PluginConfig): boolean { + return pluginConfig.experimental?.syncFromCodexMultiAuth?.enabled === true; +} + +export async function setSyncFromCodexMultiAuthEnabled(enabled: boolean): Promise { + await savePluginConfigMutation((current) => { + const experimental = isRecord(current.experimental) ? { ...current.experimental } : {}; + const syncSettings = isRecord(experimental.syncFromCodexMultiAuth) + ? { ...experimental.syncFromCodexMultiAuth } + : {}; + syncSettings.enabled = enabled; + experimental.syncFromCodexMultiAuth = syncSettings; + return { + ...current, + experimental, + }; + }); +} diff --git a/lib/schemas.ts b/lib/schemas.ts index 6028246d..714e9368 100644 --- a/lib/schemas.ts +++ b/lib/schemas.ts @@ -52,6 +52,11 @@ export const PluginConfigSchema = z.object({ pidOffsetEnabled: z.boolean().optional(), fetchTimeoutMs: z.number().min(1_000).optional(), streamStallTimeoutMs: z.number().min(1_000).optional(), + experimental: z.object({ + syncFromCodexMultiAuth: z.object({ + enabled: z.boolean().optional(), + }).optional(), + }).optional(), }); export type PluginConfigFromSchema = z.infer; diff --git a/lib/storage.ts b/lib/storage.ts index 151e2213..bcd90f5d 100644 --- a/lib/storage.ts +++ b/lib/storage.ts @@ -51,6 +51,11 @@ export interface ImportAccountsOptions { backupMode?: ImportBackupMode; } +type PrepareImportStorage = ( + normalized: AccountStorageV3, + existing: AccountStorageV3 | null, +) => AccountStorageV3; + export type ImportBackupStatus = "created" | "skipped" | "failed"; export interface ImportAccountsResult { @@ -1009,14 +1014,53 @@ function normalizeFlaggedStorage(data: unknown): FlaggedAccountStorageV1 { }; } -export async function loadFlaggedAccounts(): Promise { +async function saveFlaggedAccountsUnlocked(storage: FlaggedAccountStorageV1): Promise { + const path = getFlaggedAccountsPath(); + const uniqueSuffix = `${Date.now()}.${Math.random().toString(36).slice(2, 8)}`; + const tempPath = `${path}.${uniqueSuffix}.tmp`; + try { + await fs.mkdir(dirname(path), { recursive: true }); + const content = JSON.stringify(normalizeFlaggedStorage(storage), null, 2); + await fs.writeFile(tempPath, content, { encoding: "utf-8", mode: 0o600 }); + await renameWithWindowsRetry(tempPath, path); + } catch (error) { + try { + await fs.unlink(tempPath); + } catch { + // Ignore cleanup failures. + } + log.error("Failed to save flagged account storage", { path, error: String(error) }); + throw error; + } +} + +async function loadFlaggedAccountsUnlocked(): Promise { const path = getFlaggedAccountsPath(); const empty: FlaggedAccountStorageV1 = { version: 1, accounts: [] }; + const removeOrphanedFlaggedAccounts = async ( + storage: FlaggedAccountStorageV1, + ): Promise => { + const accounts = await loadAccountsInternal(saveAccountsUnlocked); + if (!accounts) { + return storage; + } + const activeRefreshTokens = new Set((accounts?.accounts ?? []).map((account) => account.refreshToken)); + const filteredAccounts = storage.accounts.filter((flagged) => activeRefreshTokens.has(flagged.refreshToken)); + if (filteredAccounts.length === storage.accounts.length) { + return storage; + } + const cleaned = { + version: 1 as const, + accounts: filteredAccounts, + }; + await saveFlaggedAccountsUnlocked(cleaned); + return cleaned; + }; try { const content = await fs.readFile(path, "utf-8"); const data = JSON.parse(content) as unknown; - return normalizeFlaggedStorage(data); + return await removeOrphanedFlaggedAccounts(normalizeFlaggedStorage(data)); } catch (error) { const code = (error as NodeJS.ErrnoException).code; if (code !== "ENOENT") { @@ -1035,7 +1079,7 @@ export async function loadFlaggedAccounts(): Promise { const legacyData = JSON.parse(legacyContent) as unknown; const migrated = normalizeFlaggedStorage(legacyData); if (migrated.accounts.length > 0) { - await saveFlaggedAccounts(migrated); + await saveFlaggedAccountsUnlocked(migrated); } try { await fs.unlink(legacyPath); @@ -1047,7 +1091,7 @@ export async function loadFlaggedAccounts(): Promise { to: path, accounts: migrated.accounts.length, }); - return migrated; + return await removeOrphanedFlaggedAccounts(migrated); } catch (error) { log.error("Failed to migrate legacy flagged account storage", { from: legacyPath, @@ -1058,26 +1102,35 @@ export async function loadFlaggedAccounts(): Promise { } } -export async function saveFlaggedAccounts(storage: FlaggedAccountStorageV1): Promise { +export async function loadFlaggedAccounts(): Promise { + return withStorageLock(async () => loadFlaggedAccountsUnlocked()); +} + +export async function withFlaggedAccountsTransaction( + handler: ( + current: FlaggedAccountStorageV1, + persist: (storage: FlaggedAccountStorageV1) => Promise, + ) => Promise, +): Promise { return withStorageLock(async () => { - const path = getFlaggedAccountsPath(); - const uniqueSuffix = `${Date.now()}.${Math.random().toString(36).slice(2, 8)}`; - const tempPath = `${path}.${uniqueSuffix}.tmp`; + const current = await loadFlaggedAccountsUnlocked(); + return handler(current, saveFlaggedAccountsUnlocked); + }); +} - try { - await fs.mkdir(dirname(path), { recursive: true }); - const content = JSON.stringify(normalizeFlaggedStorage(storage), null, 2); - await fs.writeFile(tempPath, content, { encoding: "utf-8", mode: 0o600 }); - await renameWithWindowsRetry(tempPath, path); - } catch (error) { - try { - await fs.unlink(tempPath); - } catch { - // Ignore cleanup failures. - } - log.error("Failed to save flagged account storage", { path, error: String(error) }); - throw error; - } +export async function loadAccountAndFlaggedStorageSnapshot(): Promise<{ + accounts: AccountStorageV3 | null; + flagged: FlaggedAccountStorageV1; +}> { + return withStorageLock(async () => ({ + accounts: await loadAccountsInternal(saveAccountsUnlocked), + flagged: await loadFlaggedAccountsUnlocked(), + })); +} + +export async function saveFlaggedAccounts(storage: FlaggedAccountStorageV1): Promise { + return withStorageLock(async () => { + await saveFlaggedAccountsUnlocked(storage); }); } @@ -1155,26 +1208,62 @@ export async function previewImportAccounts( const { normalized } = await readAndNormalizeImportFile(filePath); return withAccountStorageTransaction((existing) => { - const existingAccounts = existing?.accounts ?? []; - const merged = [...existingAccounts, ...normalized.accounts]; - - if (merged.length > ACCOUNT_LIMITS.MAX_ACCOUNTS) { - const deduped = deduplicateAccountsForStorage(merged); - if (deduped.length > ACCOUNT_LIMITS.MAX_ACCOUNTS) { - throw new Error( - `Import would exceed maximum of ${ACCOUNT_LIMITS.MAX_ACCOUNTS} accounts (would have ${deduped.length})`, - ); - } + return Promise.resolve(previewImportAccountsAgainstExistingNormalized(normalized, existing)); + }); +} + +export async function previewImportAccountsWithExistingStorage( + filePath: string, + existing: AccountStorageV3 | null | undefined, +): Promise<{ imported: number; total: number; skipped: number }> { + const { normalized } = await readAndNormalizeImportFile(filePath); + return previewImportAccountsAgainstExistingNormalized(normalized, existing); +} + +function previewImportAccountsAgainstExistingNormalized( + normalized: AccountStorageV3, + existing: AccountStorageV3 | null | undefined, +): { imported: number; total: number; skipped: number } { + const existingAccounts = existing?.accounts ?? []; + const merged = [...existingAccounts, ...normalized.accounts]; + + if (merged.length > ACCOUNT_LIMITS.MAX_ACCOUNTS) { + const deduped = deduplicateAccountsForStorage(merged); + if (deduped.length > ACCOUNT_LIMITS.MAX_ACCOUNTS) { + throw new Error( + `Import would exceed maximum of ${ACCOUNT_LIMITS.MAX_ACCOUNTS} accounts (would have ${deduped.length})`, + ); } + } - const deduplicatedAccounts = deduplicateAccountsForStorage(merged); - const imported = deduplicatedAccounts.length - existingAccounts.length; - const skipped = normalized.accounts.length - imported; - return Promise.resolve({ - imported, - total: deduplicatedAccounts.length, - skipped, - }); + const deduplicatedAccounts = deduplicateAccountsForStorage(merged); + const imported = Math.max(0, deduplicatedAccounts.length - existingAccounts.length); + const skipped = normalized.accounts.length - imported; + return { + imported, + total: deduplicatedAccounts.length, + skipped, + }; +} + +export async function backupRawAccountsFile(filePath: string, force = true): Promise { + await withStorageLock(async () => { + const resolvedPath = resolvePath(filePath); + + if (!force && existsSync(resolvedPath)) { + throw new Error(`File already exists: ${resolvedPath}`); + } + + await migrateLegacyProjectStorageIfNeeded(saveAccountsUnlocked); + const storagePath = getStoragePath(); + if (!existsSync(storagePath)) { + throw new Error("No accounts to back up"); + } + + await fs.mkdir(dirname(resolvedPath), { recursive: true }); + await fs.copyFile(storagePath, resolvedPath); + await fs.chmod(resolvedPath, 0o600).catch(() => undefined); + log.info("Backed up raw accounts storage", { path: resolvedPath, source: storagePath }); }); } @@ -1213,6 +1302,7 @@ export async function exportAccounts(filePath: string, force = true): Promise { const { resolvedPath, normalized } = await readAndNormalizeImportFile(filePath); const backupMode = options.backupMode ?? "none"; @@ -1227,6 +1317,12 @@ export async function importAccounts( backupError, } = await withAccountStorageTransaction(async (existing, persist) => { + const prepared = prepare ? prepare(normalized, existing) : normalized; + const preparedNormalized = normalizeAccountStorage(prepared); + if (!preparedNormalized) { + throw new Error("prepare() returned invalid account storage"); + } + const skippedByPrepare = Math.max(0, normalized.accounts.length - preparedNormalized.accounts.length); const existingStorage: AccountStorageV3 = existing ?? ({ @@ -1262,7 +1358,7 @@ export async function importAccounts( } } - const merged = [...existingAccounts, ...normalized.accounts]; + const merged = [...existingAccounts, ...preparedNormalized.accounts]; if (merged.length > ACCOUNT_LIMITS.MAX_ACCOUNTS) { const deduped = deduplicateAccountsForStorage(merged); @@ -1309,8 +1405,8 @@ export async function importAccounts( await persist(newStorage); - const imported = deduplicatedAccounts.length - existingAccounts.length; - const skipped = normalized.accounts.length - imported; + const imported = Math.max(0, deduplicatedAccounts.length - existingAccounts.length); + const skipped = skippedByPrepare + Math.max(0, preparedNormalized.accounts.length - imported); return { imported, total: deduplicatedAccounts.length, diff --git a/lib/sync-prune-backup.ts b/lib/sync-prune-backup.ts new file mode 100644 index 00000000..16dc666f --- /dev/null +++ b/lib/sync-prune-backup.ts @@ -0,0 +1,45 @@ +import type { AccountStorageV3 } from "./storage.js"; + +type FlaggedSnapshot = { + version: 1; + 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>; +} { + return { + version: 1, + accounts: { + ...currentAccountsStorage, + accounts: currentAccountsStorage.accounts.map((account) => cloneWithoutTokens(account)), + activeIndexByFamily: { ...(currentAccountsStorage.activeIndexByFamily ?? {}) }, + }, + flagged: { + ...currentFlaggedStorage, + accounts: currentFlaggedStorage.accounts.map((flagged) => cloneWithoutTokens(flagged)), + }, + }; +} 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: () => ({}), })); diff --git a/test/sync-prune-backup.test.ts b/test/sync-prune-backup.test.ts new file mode 100644 index 00000000..a7102dd2 --- /dev/null +++ b/test/sync-prune-backup.test.ts @@ -0,0 +1,96 @@ +import { describe, expect, it } from "vitest"; +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", () => { + 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", + }, + ], + }); + + 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"); + }); + + it("deep-clones nested metadata so later mutations do not leak into the snapshot", () => { + 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", + accountTags: ["work"], + addedAt: 1, + lastUsed: 1, + lastSelectedModelByFamily: { + codex: "gpt-5.4", + }, + }, + ], + }; + const flagged = { + version: 1 as const, + accounts: [ + { + refreshToken: "refresh-token", + accessToken: "flagged-access-token", + idToken: "flagged-id-token", + metadata: { + source: "flagged", + }, + }, + ], + }; + + const payload = createSyncPruneBackupPayload(storage, flagged); + + storage.accounts[0]!.accountTags?.push("mutated"); + storage.accounts[0]!.lastSelectedModelByFamily = { codex: "gpt-5.5" }; + flagged.accounts[0]!.metadata.source = "mutated"; + + 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.flagged.accounts[0]).toMatchObject({ + metadata: { + source: "flagged", + }, + }); + expect(payload.flagged.accounts[0]).not.toHaveProperty("refreshToken"); + expect(payload.flagged.accounts[0]).not.toHaveProperty("idToken"); + }); +});