From 0ac50f71ecf0511e3c1e4027dd5ac75575787467 Mon Sep 17 00:00:00 2001 From: ndycode Date: Tue, 10 Mar 2026 12:53:45 +0800 Subject: [PATCH 1/6] feat: add codex-multi-auth sync flow Rebuilds the codex-multi-auth sync feature on top of current main with only the sync-specific runtime, storage, and dashboard wiring. Keeps sync preview/import/prune/overlap cleanup plus the in-scope token-safety fixes for idToken backup redaction and case-sensitive refresh-token matching. Co-authored-by: Codex --- index.ts | 425 +++++- lib/cli.ts | 134 +- lib/codex-multi-auth-sync.ts | 1299 ++++++++++++++++++ lib/config.ts | 49 +- lib/schemas.ts | 5 + lib/storage.ts | 147 +- lib/sync-prune-backup.ts | 40 + lib/ui/auth-menu.ts | 32 + test/codex-multi-auth-sync.test.ts | 2039 ++++++++++++++++++++++++++++ test/index.test.ts | 3 + test/sync-prune-backup.test.ts | 88 ++ 11 files changed, 4212 insertions(+), 49 deletions(-) create mode 100644 lib/codex-multi-auth-sync.ts create mode 100644 lib/sync-prune-backup.ts create mode 100644 test/codex-multi-auth-sync.test.ts create mode 100644 test/sync-prune-backup.test.ts diff --git a/index.ts b/index.ts index 5ff94c0c..a09e9c92 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, @@ -111,15 +115,19 @@ import { withAccountStorageTransaction, clearAccounts, setStoragePath, + backupRawAccountsFile, exportAccounts, importAccounts, previewImportAccounts, createTimestampedBackupPath, + loadAccountAndFlaggedStorageSnapshot, loadFlaggedAccounts, + normalizeAccountStorage, saveFlaggedAccounts, clearFlaggedAccounts, StorageError, formatStorageErrorHint, + withFlaggedAccountsTransaction, type AccountStorageV3, type FlaggedAccountMetadataV1, } from "./lib/storage.js"; @@ -151,6 +159,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 +191,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 +3356,397 @@ while (attempted.size < Math.max(1, accountCount)) { console.log(""); }; + type SyncRemovalTarget = { + refreshToken: string; + organizationId?: string; + accountId?: 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 createMaintenanceAccountsBackup = async (prefix: string): Promise => { + const backupPath = createTimestampedBackupPath(prefix); + await backupRawAccountsFile(backupPath, true); + return backupPath; + }; + + 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); + await fsPromises.writeFile(backupPath, `${JSON.stringify(backupPayload, null, 2)}\n`, { + encoding: "utf-8", + mode: 0o600, + flag: "wx", + }); + 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 = async (indexes: number[]): Promise<{ + previewLines: string[]; + targets: SyncRemovalTarget[]; + }> => { + const currentStorage = + (await loadAccounts()) ?? + ({ + version: 3, + accounts: [], + activeIndex: 0, + activeIndexByFamily: {}, + } satisfies AccountStorageV3); + const candidates = [...indexes] + .sort((left, right) => left - right) + .map((index) => { + const account = currentStorage.accounts[index]; + if (!account) { + throw new Error( + `Selected account ${index + 1} changed before confirmation. Re-run sync and confirm again.`, + ); + } + const label = account.email ?? account.accountLabel ?? `Account ${index + 1}`; + const currentSuffix = index === currentStorage.activeIndex ? " | current" : ""; + return { + previewLine: `${index + 1}. ${label}${currentSuffix}`, + target: { + refreshToken: account.refreshToken, + organizationId: account.organizationId, + accountId: account.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 = await buildSyncRemovalPlan(indexesToRemove); + 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 => { + 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; + } + const backupPath = await createMaintenanceAccountsBackup("codex-maintenance-overlap-backup"); + const result = await cleanupCodexMultiAuthSyncedOverlaps(); + 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); + console.log(`\nCleanup failed: ${message}\n`); + } + }; + if (!explicitLoginMode) { while (true) { const loadedStorage = await hydrateEmails(await loadAccounts()); @@ -3388,6 +3798,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 +3825,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..48459119 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,112 @@ 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 parsed = normalized + .split(",") + .map((value) => Number.parseInt(value.trim(), 10)) + .filter((value) => Number.isFinite(value)) + .map((value) => value - 1); + const unique = Array.from(new Set(parsed)); + 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 +223,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 +255,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 +279,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..cc564752 --- /dev/null +++ b/lib/codex-multi-auth-sync.ts @@ -0,0 +1,1299 @@ +import { existsSync, readdirSync, promises as fs } from "node:fs"; +import { homedir } from "node:os"; +import { join, win32 } from "node:path"; +import { ACCOUNT_LIMITS } from "./constants.js"; +import { logWarn } from "./logger.js"; +import { + deduplicateAccounts, + deduplicateAccountsByEmail, + getStoragePath, + importAccounts, + loadAccounts, + normalizeAccountStorage, + previewImportAccountsWithExistingStorage, + withAccountStorageTransaction, + type AccountStorageV3, + type ImportAccountsResult, +} from "./storage.js"; +import { migrateV1ToV3, type AccountStorageV1 } from "./storage/migrations.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; + 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 normalizedEmail = normalizeIdentity(account.email); + if (normalizedEmail && existingState.emails.has(normalizedEmail)) { + return false; + } + 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; + } + 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, + 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, isCurrentAccount, score, reason }) => ({ + index, + email, + accountLabel, + 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; + } + const parsed = Number(override); + if (Number.isFinite(parsed) && parsed > 0) { + return parsed; + } + const message = `${SYNC_MAX_ACCOUNTS_OVERRIDE_ENV} override value "${override}" is not a positive finite number; 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 = account.organizationId ?? account.accountId ?? account.refreshToken; + if (key) { + originalAccountsByKey.set(key, account); + } + } + const updated = normalized.accounts.reduce((count, account) => { + const key = account.organizationId ?? account.accountId ?? account.refreshToken; + 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 normalizeOverlapCleanupSourceStorage(data: unknown): AccountStorageV3 | null { + if ( + !data || + typeof data !== "object" || + !("version" in data) || + !((data as { version?: unknown }).version === 1 || (data as { version?: unknown }).version === 3) || + !("accounts" in data) || + !Array.isArray((data as { accounts?: unknown }).accounts) + ) { + return null; + } + + const baseRecord = + (data as { version?: unknown }).version === 1 + ? migrateV1ToV3(data as AccountStorageV1) + : (data as AccountStorageV3); + const originalToFilteredIndex = new Map(); + const accounts = baseRecord.accounts.flatMap((account, index) => { + if (typeof account.refreshToken !== "string" || account.refreshToken.trim().length === 0) { + return []; + } + originalToFilteredIndex.set(index, originalToFilteredIndex.size); + return [account]; + }); + const activeIndexValue = + typeof baseRecord.activeIndex === "number" && Number.isFinite(baseRecord.activeIndex) + ? baseRecord.activeIndex + : 0; + const remappedActiveIndex = originalToFilteredIndex.get(activeIndexValue); + const activeIndex = Math.max( + 0, + Math.min(accounts.length - 1, remappedActiveIndex ?? activeIndexValue), + ); + const rawActiveIndexByFamily = + baseRecord.activeIndexByFamily && typeof baseRecord.activeIndexByFamily === "object" + ? baseRecord.activeIndexByFamily + : {}; + const activeIndexByFamily = Object.fromEntries( + Object.entries(rawActiveIndexByFamily).flatMap(([family, value]) => { + if (typeof value !== "number" || !Number.isFinite(value)) { + return []; + } + const remappedValue = originalToFilteredIndex.get(value) ?? value; + return [[family, Math.max(0, Math.min(accounts.length - 1, remappedValue))]]; + }), + ) as AccountStorageV3["activeIndexByFamily"]; + + return { + version: 3, + accounts, + activeIndex: accounts.length === 0 ? 0 : activeIndex, + activeIndexByFamily, + }; +} + +async function loadRawCodexMultiAuthOverlapCleanupStorage( + fallback: AccountStorageV3, +): Promise { + try { + const raw = await fs.readFile(getStoragePath(), "utf-8"); + const parsed = JSON.parse(raw) as unknown; + const normalized = normalizeOverlapCleanupSourceStorage(parsed); + if (normalized) { + return normalized; + } + throw new Error("Invalid raw storage snapshot for synced overlap cleanup."); + } catch (error) { + const code = (error as NodeJS.ErrnoException).code; + if (code === "ENOENT") { + return fallback; + } + if (code === "EBUSY" || code === "EACCES" || code === "EPERM") { + logWarn( + `Failed reading raw storage snapshot for synced overlap cleanup (${code}); using transaction snapshot fallback.`, + ); + return fallback; + } + const message = error instanceof Error ? error.message : String(error); + throw new Error(`Failed to read raw storage snapshot for synced overlap cleanup: ${message}`); + } +} + +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(async (current) => { + const fallback = current ?? { + version: 3 as const, + accounts: [], + activeIndex: 0, + activeIndexByFamily: {}, + }; + const existing = await loadRawCodexMultiAuthOverlapCleanupStorage(fallback); + return buildCodexMultiAuthOverlapCleanupPlan(existing).result; + }); +} + +export async function cleanupCodexMultiAuthSyncedOverlaps(): Promise { + return withAccountStorageTransaction(async (current, persist) => { + const fallback = current ?? { + version: 3 as const, + accounts: [], + activeIndex: 0, + activeIndexByFamily: {}, + }; + const existing = await loadRawCodexMultiAuthOverlapCleanupStorage(fallback); + const plan = buildCodexMultiAuthOverlapCleanupPlan(existing); + if (plan.nextStorage) { + await persist(plan.nextStorage); + } + return plan.result; + }); +} diff --git a/lib/config.ts b/lib/config.ts index af93ee73..bc57fc78 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 { @@ -111,9 +111,11 @@ 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; + /** * Get the effective CODEX_MODE setting * Priority: environment variable > config file > default (true) @@ -501,3 +503,44 @@ export function getStreamStallTimeoutMs(pluginConfig: PluginConfig): number { { min: 1_000 }, ); } + +async function savePluginConfigMutation( + mutate: (current: RawPluginConfig) => RawPluginConfig, +): Promise { + await fs.mkdir(dirname(CONFIG_PATH), { recursive: true }); + const current = existsSync(CONFIG_PATH) + ? (() => { + try { + const raw = stripUtf8Bom(readFileSync(CONFIG_PATH, "utf-8")); + const parsed = JSON.parse(raw) as unknown; + return isRecord(parsed) ? { ...parsed } : {}; + } catch { + return {}; + } + })() + : {}; + const next = mutate(current); + await fs.writeFile(CONFIG_PATH, `${JSON.stringify(next, null, 2)}\n`, { + encoding: "utf-8", + mode: 0o600, + }); +} + +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..6f154c30 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,7 +1014,19 @@ function normalizeFlaggedStorage(data: unknown): FlaggedAccountStorageV1 { }; } -export async function loadFlaggedAccounts(): Promise { +async function saveFlaggedAccountsUnlocked(storage: FlaggedAccountStorageV1): Promise { + const path = getFlaggedAccountsPath(); + try { + await fs.mkdir(dirname(path), { recursive: true }); + const content = JSON.stringify(normalizeFlaggedStorage(storage), null, 2); + await fs.writeFile(path, content, { encoding: "utf-8", mode: 0o600 }); + } catch (error) { + 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: [] }; @@ -1035,7 +1052,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); @@ -1058,26 +1075,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 +1181,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 +1275,7 @@ export async function exportAccounts(filePath: string, force = true): Promise { const { resolvedPath, normalized } = await readAndNormalizeImportFile(filePath); const backupMode = options.backupMode ?? "none"; @@ -1227,6 +1290,8 @@ export async function importAccounts( backupError, } = await withAccountStorageTransaction(async (existing, persist) => { + const preparedNormalized = prepare ? prepare(normalized, existing) : normalized; + const skippedByPrepare = Math.max(0, normalized.accounts.length - preparedNormalized.accounts.length); const existingStorage: AccountStorageV3 = existing ?? ({ @@ -1262,7 +1327,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 +1374,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..8bcc34bf --- /dev/null +++ b/lib/sync-prune-backup.ts @@ -0,0 +1,40 @@ +import type { AccountStorageV3 } from "./storage.js"; + +type FlaggedSnapshot = { + version: 1; + accounts: TAccount[]; +}; + +function cloneWithoutTokens(account: TAccount): TAccount { + const clone = structuredClone(account) as TAccount & { + accessToken?: unknown; + refreshToken?: unknown; + idToken?: unknown; + }; + delete clone.accessToken; + delete clone.refreshToken; + delete clone.idToken; + return clone as TAccount; +} + +export function createSyncPruneBackupPayload( + currentAccountsStorage: AccountStorageV3, + currentFlaggedStorage: FlaggedSnapshot, +): { + version: 1; + accounts: AccountStorageV3; + 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..2b12d983 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: ui.v2Enabled ? "Sync tools" : "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..1d9c1120 --- /dev/null +++ b/test/codex-multi-auth-sync.test.ts @@ -0,0 +1,2039 @@ +import { beforeEach, afterEach, describe, expect, it, vi } from "vitest"; +import * as fs from "node:fs"; +import * as os from "node:os"; +import { join } 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 projectRoot = findProjectRoot(process.cwd()) ?? process.cwd(); + const projectKey = getProjectStorageKey(projectRoot); + 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 = 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( + 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 = join( + "C:\\Users\\tester", + "DevTools", + "config", + "codex", + "multi-auth", + "openai-codex-accounts.json", + ); + const dotCodexGlobalPath = 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( + 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 = join( + "C:\\Users\\tester", + ".codex", + "multi-auth", + "openai-codex-accounts.json.wal", + ); + const laterRealJson = 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( + 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 same locked raw storage snapshot for overlap preview as cleanup", async () => { + const storageModule = await import("../lib/storage.js"); + vi.mocked(storageModule.loadAccounts).mockResolvedValueOnce({ + version: 3, + activeIndex: 0, + activeIndexByFamily: {}, + accounts: [], + }); + vi.mocked(storageModule.withAccountStorageTransaction).mockImplementationOnce(async (handler) => + handler( + { + version: 3, + activeIndex: 0, + activeIndexByFamily: {}, + accounts: [], + }, + vi.fn(async () => {}), + ), + ); + 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"], + 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, + }, + ], + }), + ); + + const { previewCodexMultiAuthSyncedOverlapCleanup } = await import("../lib/codex-multi-auth-sync.js"); + await expect(previewCodexMultiAuthSyncedOverlapCleanup()).resolves.toEqual({ + before: 2, + after: 1, + removed: 1, + 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: [ + { + accountId: "org-shared-a", + organizationId: "org-shared-a", + accountIdSource: "org", + email: "shared@example.com", + refreshToken: "rt-shared-a", + addedAt: 1, + lastUsed: 1, + }, + { + accountId: "org-shared-b", + organizationId: "org-shared-b", + accountIdSource: "org", + 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: [ + { + accountId: "org-existing", + organizationId: "org-existing", + accountIdSource: "org", + 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 finite number; 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("reads the raw storage file so duplicate tagged rows are removed from disk", 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-sync", + organizationId: "org-sync", + accountIdSource: "org", + accountTags: ["codex-multi-auth-sync"], + email: "sync@example.com", + refreshToken: "sync-token", + addedAt: 2, + lastUsed: 2, + }, + ], + }, + persist, + ), + ); + mockSourceStorageFile( + "/tmp/opencode-accounts.json", + JSON.stringify({ + version: 3, + activeIndex: 0, + activeIndexByFamily: {}, + accounts: [ + { + accountId: "org-sync", + accountIdSource: "org", + accountTags: ["codex-multi-auth-sync"], + email: "sync@example.com", + refreshToken: "sync-token", + addedAt: 1, + lastUsed: 1, + }, + { + accountId: "org-sync", + organizationId: "org-sync", + accountIdSource: "org", + accountTags: ["codex-multi-auth-sync"], + email: "sync@example.com", + refreshToken: "sync-token", + addedAt: 2, + lastUsed: 2, + }, + ], + }), + ); + vi.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, + }); + 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]?.organizationId).toBe("org-sync"); + }); + + it("does not count synced overlap records as updated when only key order differs", async () => { + const storageModule = await import("../lib/storage.js"); + const persist = vi.fn(async () => {}); + vi.mocked(storageModule.withAccountStorageTransaction).mockImplementationOnce(async (handler) => + handler( + { + 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("migrates v1 raw overlap snapshots without collapsing duplicate tagged rows before cleanup", 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", + accountIdSource: "org", + accountTags: ["codex-multi-auth-sync"], + refreshToken: "sync-token", + addedAt: 1, + lastUsed: 1, + }, + ], + }, + persist, + ), + ); + mockSourceStorageFile( + "/tmp/opencode-accounts.json", + JSON.stringify({ + version: 1, + activeIndex: 1, + accounts: [ + { + accountId: "org-sync", + accountIdSource: "org", + accountTags: ["codex-multi-auth-sync"], + refreshToken: "sync-token", + addedAt: 1, + lastUsed: 1, + }, + { + accountId: "org-sync", + organizationId: "org-sync", + accountIdSource: "org", + accountTags: ["codex-multi-auth-sync"], + refreshToken: "sync-token", + addedAt: 2, + lastUsed: 2, + }, + ], + }), + ); + 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, + }); + 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]?.organizationId).toBe("org-sync"); + expect(saved.activeIndexByFamily?.codex).toBe(0); + }); + + it("falls back to in-memory overlap cleanup state on transient Windows lock errors", async () => { + const storageModule = await import("../lib/storage.js"); + const loggerModule = await import("../lib/logger.js"); + const persist = vi.fn(async (_next: AccountStorageV3) => {}); + vi.mocked(storageModule.deduplicateAccounts).mockImplementationOnce((accounts) => { + return accounts.length > 1 ? [accounts[1] ?? accounts[0]].filter(Boolean) : accounts; + }); + vi.mocked(storageModule.withAccountStorageTransaction).mockImplementationOnce(async (handler) => + handler( + { + version: 3, + activeIndex: 0, + activeIndexByFamily: {}, + accounts: [ + { + accountId: "org-sync", + accountIdSource: "org", + accountTags: ["codex-multi-auth-sync"], + refreshToken: "sync-token", + addedAt: 1, + lastUsed: 1, + }, + { + accountId: "org-sync", + organizationId: "org-sync", + accountIdSource: "org", + accountTags: ["codex-multi-auth-sync"], + refreshToken: "sync-token", + addedAt: 2, + lastUsed: 2, + }, + ], + }, + persist, + ), + ); + mockReadFile.mockRejectedValueOnce(Object.assign(new Error("busy"), { code: "EBUSY" })); + const storagePath = await import("../lib/storage.js"); + vi.mocked(storagePath.getStoragePath).mockReturnValueOnce("/tmp/opencode-accounts.json"); + + 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]?.organizationId).toBe("org-sync"); + expect(vi.mocked(loggerModule.logWarn)).toHaveBeenCalledWith( + expect.stringContaining("raw storage snapshot for synced overlap cleanup (EBUSY)"), + ); + }); + + 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, + }, + { + accountId: "org-sync", + organizationId: "org-sync", + accountIdSource: "org", + 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..017b1835 --- /dev/null +++ b/test/sync-prune-backup.test.ts @@ -0,0 +1,88 @@ +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", + addedAt: 1, + lastUsed: 1, + }, + ], + }; + const payload = createSyncPruneBackupPayload(storage, { + version: 1, + accounts: [ + { + refreshToken: "refresh-token", + accessToken: "flagged-access-token", + }, + ], + }); + + expect(payload.accounts.accounts[0]).not.toHaveProperty("accessToken"); + expect(payload.accounts.accounts[0]).not.toHaveProperty("refreshToken"); + expect(payload.flagged.accounts[0]).not.toHaveProperty("accessToken"); + expect(payload.flagged.accounts[0]).not.toHaveProperty("refreshToken"); + }); + + 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", + 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", + 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.flagged.accounts[0]).toMatchObject({ + metadata: { + source: "flagged", + }, + }); + expect(payload.flagged.accounts[0]).not.toHaveProperty("refreshToken"); + }); +}); From 50fef7c73a7268b54ab26a5924ba56a684446d31 Mon Sep 17 00:00:00 2001 From: ndycode Date: Tue, 10 Mar 2026 13:11:29 +0800 Subject: [PATCH 2/6] fix: harden sync persistence and input guards Restore atomic flagged-account writes, serialize sync config mutations with temp-file replacement, validate prune selections strictly, key flagged cleanup by refresh token, tighten sync capacity override parsing, and remove unsafe token-redaction casts. Co-authored-by: Codex --- index.ts | 19 ++------ lib/cli.ts | 17 +++++--- lib/codex-multi-auth-sync.ts | 12 +++-- lib/config.ts | 70 ++++++++++++++++++++++++------ lib/storage.ts | 10 ++++- lib/sync-prune-backup.ts | 21 +++++---- test/codex-multi-auth-sync.test.ts | 2 +- 7 files changed, 102 insertions(+), 49 deletions(-) diff --git a/index.ts b/index.ts index a09e9c92..21c044e3 100644 --- a/index.ts +++ b/index.ts @@ -3534,27 +3534,14 @@ while (attempted.size < Math.max(1, accountCount)) { }); if (removedTargets.length > 0) { - const removedFlaggedKeys = new Set( - removedTargets.map((entry) => - getSyncRemovalTargetKey({ - refreshToken: entry.account.refreshToken, - organizationId: entry.account.organizationId, - accountId: entry.account.accountId, - }), - ), + const removedRefreshTokens = new Set( + removedTargets.map((entry) => entry.account.refreshToken), ); 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, - }), - ), + (flagged) => !removedRefreshTokens.has(flagged.refreshToken), ), }); }); diff --git a/lib/cli.ts b/lib/cli.ts index 48459119..fe567907 100644 --- a/lib/cli.ts +++ b/lib/cli.ts @@ -191,12 +191,17 @@ export async function promptCodexMultiAuthSyncPrune( return null; } - const parsed = normalized - .split(",") - .map((value) => Number.parseInt(value.trim(), 10)) - .filter((value) => Number.isFinite(value)) - .map((value) => value - 1); - const unique = Array.from(new Set(parsed)); + 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; diff --git a/lib/codex-multi-auth-sync.ts b/lib/codex-multi-auth-sync.ts index cc564752..e4a66b3d 100644 --- a/lib/codex-multi-auth-sync.ts +++ b/lib/codex-multi-auth-sync.ts @@ -906,11 +906,15 @@ function getSyncCapacityLimit(): number { if (override.length === 0) { return ACCOUNT_LIMITS.MAX_ACCOUNTS; } - const parsed = Number(override); - if (Number.isFinite(parsed) && parsed > 0) { - return parsed; + 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 finite number; ignoring.`; + const message = `${SYNC_MAX_ACCOUNTS_OVERRIDE_ENV} override value "${override}" is not a positive integer; ignoring.`; logWarn(message); try { process.stderr.write(`${message}\n`); diff --git a/lib/config.ts b/lib/config.ts index bc57fc78..f0ff1872 100644 --- a/lib/config.ts +++ b/lib/config.ts @@ -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"; @@ -116,6 +117,36 @@ function isRecord(value: unknown): value is Record { 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; + } +} + /** * Get the effective CODEX_MODE setting * Priority: environment variable > config file > default (true) @@ -507,22 +538,35 @@ export function getStreamStallTimeoutMs(pluginConfig: PluginConfig): number { async function savePluginConfigMutation( mutate: (current: RawPluginConfig) => RawPluginConfig, ): Promise { - await fs.mkdir(dirname(CONFIG_PATH), { recursive: true }); - const current = existsSync(CONFIG_PATH) - ? (() => { + await withConfigMutationLock(async () => { + await fs.mkdir(dirname(CONFIG_PATH), { recursive: true }); + const current = existsSync(CONFIG_PATH) + ? await (async () => { + try { + const raw = stripUtf8Bom(await fs.readFile(CONFIG_PATH, "utf-8")); + const parsed = JSON.parse(raw) as unknown; + return isRecord(parsed) ? { ...parsed } : {}; + } catch { + return {}; + } + })() + : {}; + 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 { - const raw = stripUtf8Bom(readFileSync(CONFIG_PATH, "utf-8")); - const parsed = JSON.parse(raw) as unknown; - return isRecord(parsed) ? { ...parsed } : {}; + await fs.unlink(tempPath); } catch { - return {}; + // Best effort cleanup only. } - })() - : {}; - const next = mutate(current); - await fs.writeFile(CONFIG_PATH, `${JSON.stringify(next, null, 2)}\n`, { - encoding: "utf-8", - mode: 0o600, + throw error; + } }); } diff --git a/lib/storage.ts b/lib/storage.ts index 6f154c30..2588205f 100644 --- a/lib/storage.ts +++ b/lib/storage.ts @@ -1016,11 +1016,19 @@ function normalizeFlaggedStorage(data: unknown): FlaggedAccountStorageV1 { 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(path, content, { encoding: "utf-8", mode: 0o600 }); + 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; } diff --git a/lib/sync-prune-backup.ts b/lib/sync-prune-backup.ts index 8bcc34bf..16dc666f 100644 --- a/lib/sync-prune-backup.ts +++ b/lib/sync-prune-backup.ts @@ -5,16 +5,19 @@ type FlaggedSnapshot = { accounts: TAccount[]; }; -function cloneWithoutTokens(account: TAccount): TAccount { - const clone = structuredClone(account) as TAccount & { - accessToken?: unknown; - refreshToken?: unknown; - idToken?: unknown; +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 as TAccount; + return clone; } export function createSyncPruneBackupPayload( @@ -22,8 +25,10 @@ export function createSyncPruneBackupPayload( currentFlaggedStorage: FlaggedSnapshot, ): { version: 1; - accounts: AccountStorageV3; - flagged: FlaggedSnapshot; + accounts: Omit & { + accounts: Array>; + }; + flagged: FlaggedSnapshot>; } { return { version: 1, diff --git a/test/codex-multi-auth-sync.test.ts b/test/codex-multi-auth-sync.test.ts index 1d9c1120..28192f8c 100644 --- a/test/codex-multi-auth-sync.test.ts +++ b/test/codex-multi-auth-sync.test.ts @@ -1451,7 +1451,7 @@ describe("codex-multi-auth sync", () => { accountsPath: globalPath, }); expect(vi.mocked(loggerModule.logWarn)).toHaveBeenCalledWith( - expect.stringContaining('CODEX_AUTH_SYNC_MAX_ACCOUNTS override value "0" is not a positive finite number; ignoring.'), + expect.stringContaining('CODEX_AUTH_SYNC_MAX_ACCOUNTS override value "0" is not a positive integer; ignoring.'), ); }); From f0d4f38f67d7eb0b37c95832b02b29f62fe23e9b Mon Sep 17 00:00:00 2001 From: ndycode Date: Tue, 10 Mar 2026 13:24:47 +0800 Subject: [PATCH 3/6] fix: tighten sync cleanup consistency Use the locked transaction snapshot for overlap cleanup, fail fast on malformed config mutations, and normalize prepared import storage before limit and result accounting. Co-authored-by: Codex --- lib/codex-multi-auth-sync.ts | 92 +--------- lib/config.ts | 15 +- lib/storage.ts | 6 +- test/codex-multi-auth-sync.test.ts | 274 +++-------------------------- 4 files changed, 39 insertions(+), 348 deletions(-) diff --git a/lib/codex-multi-auth-sync.ts b/lib/codex-multi-auth-sync.ts index e4a66b3d..1ba2f1c2 100644 --- a/lib/codex-multi-auth-sync.ts +++ b/lib/codex-multi-auth-sync.ts @@ -6,7 +6,6 @@ import { logWarn } from "./logger.js"; import { deduplicateAccounts, deduplicateAccountsByEmail, - getStoragePath, importAccounts, loadAccounts, normalizeAccountStorage, @@ -15,7 +14,6 @@ import { type AccountStorageV3, type ImportAccountsResult, } from "./storage.js"; -import { migrateV1ToV3, type AccountStorageV1 } from "./storage/migrations.js"; import { findProjectRoot, getProjectStorageKey } from "./storage/paths.js"; const EXTERNAL_ROOT_SUFFIX = "multi-auth"; @@ -1150,88 +1148,6 @@ function buildCodexMultiAuthOverlapCleanupPlan(existing: AccountStorageV3): { }; } -function normalizeOverlapCleanupSourceStorage(data: unknown): AccountStorageV3 | null { - if ( - !data || - typeof data !== "object" || - !("version" in data) || - !((data as { version?: unknown }).version === 1 || (data as { version?: unknown }).version === 3) || - !("accounts" in data) || - !Array.isArray((data as { accounts?: unknown }).accounts) - ) { - return null; - } - - const baseRecord = - (data as { version?: unknown }).version === 1 - ? migrateV1ToV3(data as AccountStorageV1) - : (data as AccountStorageV3); - const originalToFilteredIndex = new Map(); - const accounts = baseRecord.accounts.flatMap((account, index) => { - if (typeof account.refreshToken !== "string" || account.refreshToken.trim().length === 0) { - return []; - } - originalToFilteredIndex.set(index, originalToFilteredIndex.size); - return [account]; - }); - const activeIndexValue = - typeof baseRecord.activeIndex === "number" && Number.isFinite(baseRecord.activeIndex) - ? baseRecord.activeIndex - : 0; - const remappedActiveIndex = originalToFilteredIndex.get(activeIndexValue); - const activeIndex = Math.max( - 0, - Math.min(accounts.length - 1, remappedActiveIndex ?? activeIndexValue), - ); - const rawActiveIndexByFamily = - baseRecord.activeIndexByFamily && typeof baseRecord.activeIndexByFamily === "object" - ? baseRecord.activeIndexByFamily - : {}; - const activeIndexByFamily = Object.fromEntries( - Object.entries(rawActiveIndexByFamily).flatMap(([family, value]) => { - if (typeof value !== "number" || !Number.isFinite(value)) { - return []; - } - const remappedValue = originalToFilteredIndex.get(value) ?? value; - return [[family, Math.max(0, Math.min(accounts.length - 1, remappedValue))]]; - }), - ) as AccountStorageV3["activeIndexByFamily"]; - - return { - version: 3, - accounts, - activeIndex: accounts.length === 0 ? 0 : activeIndex, - activeIndexByFamily, - }; -} - -async function loadRawCodexMultiAuthOverlapCleanupStorage( - fallback: AccountStorageV3, -): Promise { - try { - const raw = await fs.readFile(getStoragePath(), "utf-8"); - const parsed = JSON.parse(raw) as unknown; - const normalized = normalizeOverlapCleanupSourceStorage(parsed); - if (normalized) { - return normalized; - } - throw new Error("Invalid raw storage snapshot for synced overlap cleanup."); - } catch (error) { - const code = (error as NodeJS.ErrnoException).code; - if (code === "ENOENT") { - return fallback; - } - if (code === "EBUSY" || code === "EACCES" || code === "EPERM") { - logWarn( - `Failed reading raw storage snapshot for synced overlap cleanup (${code}); using transaction snapshot fallback.`, - ); - return fallback; - } - const message = error instanceof Error ? error.message : String(error); - throw new Error(`Failed to read raw storage snapshot for synced overlap cleanup: ${message}`); - } -} - function sourceExceedsCapacityWithoutLocalRelief(details: CodexMultiAuthSyncCapacityDetails): boolean { return ( details.sourceDedupedTotal > details.maxAccounts && @@ -1273,15 +1189,14 @@ export class CodexMultiAuthSyncCapacityError extends Error { } export async function previewCodexMultiAuthSyncedOverlapCleanup(): Promise { - return withAccountStorageTransaction(async (current) => { + return withAccountStorageTransaction((current) => { const fallback = current ?? { version: 3 as const, accounts: [], activeIndex: 0, activeIndexByFamily: {}, }; - const existing = await loadRawCodexMultiAuthOverlapCleanupStorage(fallback); - return buildCodexMultiAuthOverlapCleanupPlan(existing).result; + return Promise.resolve(buildCodexMultiAuthOverlapCleanupPlan(fallback).result); }); } @@ -1293,8 +1208,7 @@ export async function cleanupCodexMultiAuthSyncedOverlaps(): Promise { + const raw = stripUtf8Bom(await fs.readFile(CONFIG_PATH, "utf-8")); + let parsed: unknown; try { - const raw = stripUtf8Bom(await fs.readFile(CONFIG_PATH, "utf-8")); - const parsed = JSON.parse(raw) as unknown; - return isRecord(parsed) ? { ...parsed } : {}; - } catch { - return {}; + 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); diff --git a/lib/storage.ts b/lib/storage.ts index 2588205f..cc817566 100644 --- a/lib/storage.ts +++ b/lib/storage.ts @@ -1298,7 +1298,11 @@ export async function importAccounts( backupError, } = await withAccountStorageTransaction(async (existing, persist) => { - const preparedNormalized = prepare ? prepare(normalized, existing) : normalized; + 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 ?? diff --git a/test/codex-multi-auth-sync.test.ts b/test/codex-multi-auth-sync.test.ts index 28192f8c..a7ff7246 100644 --- a/test/codex-multi-auth-sync.test.ts +++ b/test/codex-multi-auth-sync.test.ts @@ -586,53 +586,38 @@ describe("codex-multi-auth sync", () => { }); }); - it("uses the same locked raw storage snapshot for overlap preview as cleanup", async () => { + it("uses the locked transaction snapshot for overlap preview", async () => { const storageModule = await import("../lib/storage.js"); - vi.mocked(storageModule.loadAccounts).mockResolvedValueOnce({ - version: 3, - activeIndex: 0, - activeIndexByFamily: {}, - accounts: [], - }); vi.mocked(storageModule.withAccountStorageTransaction).mockImplementationOnce(async (handler) => handler( { version: 3, activeIndex: 0, activeIndexByFamily: {}, - accounts: [], + 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 () => {}), ), ); - 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"], - 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, - }, - ], - }), - ); const { previewCodexMultiAuthSyncedOverlapCleanup } = await import("../lib/codex-multi-auth-sync.js"); await expect(previewCodexMultiAuthSyncedOverlapCleanup()).resolves.toEqual({ @@ -1578,88 +1563,6 @@ describe("codex-multi-auth sync", () => { }); }); - it("reads the raw storage file so duplicate tagged rows are removed from disk", 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-sync", - organizationId: "org-sync", - accountIdSource: "org", - accountTags: ["codex-multi-auth-sync"], - email: "sync@example.com", - refreshToken: "sync-token", - addedAt: 2, - lastUsed: 2, - }, - ], - }, - persist, - ), - ); - mockSourceStorageFile( - "/tmp/opencode-accounts.json", - JSON.stringify({ - version: 3, - activeIndex: 0, - activeIndexByFamily: {}, - accounts: [ - { - accountId: "org-sync", - accountIdSource: "org", - accountTags: ["codex-multi-auth-sync"], - email: "sync@example.com", - refreshToken: "sync-token", - addedAt: 1, - lastUsed: 1, - }, - { - accountId: "org-sync", - organizationId: "org-sync", - accountIdSource: "org", - accountTags: ["codex-multi-auth-sync"], - email: "sync@example.com", - refreshToken: "sync-token", - addedAt: 2, - lastUsed: 2, - }, - ], - }), - ); - vi.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, - }); - 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]?.organizationId).toBe("org-sync"); - }); - it("does not count synced overlap records as updated when only key order differs", async () => { const storageModule = await import("../lib/storage.js"); const persist = vi.fn(async () => {}); @@ -1714,141 +1617,6 @@ describe("codex-multi-auth sync", () => { expect(persist).not.toHaveBeenCalled(); }); - it("migrates v1 raw overlap snapshots without collapsing duplicate tagged rows before cleanup", 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", - accountIdSource: "org", - accountTags: ["codex-multi-auth-sync"], - refreshToken: "sync-token", - addedAt: 1, - lastUsed: 1, - }, - ], - }, - persist, - ), - ); - mockSourceStorageFile( - "/tmp/opencode-accounts.json", - JSON.stringify({ - version: 1, - activeIndex: 1, - accounts: [ - { - accountId: "org-sync", - accountIdSource: "org", - accountTags: ["codex-multi-auth-sync"], - refreshToken: "sync-token", - addedAt: 1, - lastUsed: 1, - }, - { - accountId: "org-sync", - organizationId: "org-sync", - accountIdSource: "org", - accountTags: ["codex-multi-auth-sync"], - refreshToken: "sync-token", - addedAt: 2, - lastUsed: 2, - }, - ], - }), - ); - 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, - }); - 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]?.organizationId).toBe("org-sync"); - expect(saved.activeIndexByFamily?.codex).toBe(0); - }); - - it("falls back to in-memory overlap cleanup state on transient Windows lock errors", async () => { - const storageModule = await import("../lib/storage.js"); - const loggerModule = await import("../lib/logger.js"); - const persist = vi.fn(async (_next: AccountStorageV3) => {}); - vi.mocked(storageModule.deduplicateAccounts).mockImplementationOnce((accounts) => { - return accounts.length > 1 ? [accounts[1] ?? accounts[0]].filter(Boolean) : accounts; - }); - vi.mocked(storageModule.withAccountStorageTransaction).mockImplementationOnce(async (handler) => - handler( - { - version: 3, - activeIndex: 0, - activeIndexByFamily: {}, - accounts: [ - { - accountId: "org-sync", - accountIdSource: "org", - accountTags: ["codex-multi-auth-sync"], - refreshToken: "sync-token", - addedAt: 1, - lastUsed: 1, - }, - { - accountId: "org-sync", - organizationId: "org-sync", - accountIdSource: "org", - accountTags: ["codex-multi-auth-sync"], - refreshToken: "sync-token", - addedAt: 2, - lastUsed: 2, - }, - ], - }, - persist, - ), - ); - mockReadFile.mockRejectedValueOnce(Object.assign(new Error("busy"), { code: "EBUSY" })); - const storagePath = await import("../lib/storage.js"); - vi.mocked(storagePath.getStoragePath).mockReturnValueOnce("/tmp/opencode-accounts.json"); - - 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]?.organizationId).toBe("org-sync"); - expect(vi.mocked(loggerModule.logWarn)).toHaveBeenCalledWith( - expect.stringContaining("raw storage snapshot for synced overlap cleanup (EBUSY)"), - ); - }); it("limits overlap cleanup to accounts tagged from codex-multi-auth sync", async () => { const storageModule = await import("../lib/storage.js"); From cec7b9dc868156e1a0adc9343f0353f2433be316 Mon Sep 17 00:00:00 2001 From: ndycode Date: Tue, 10 Mar 2026 15:14:24 +0800 Subject: [PATCH 4/6] fix: cover sync backup redaction and flagged orphans Add explicit idToken regression coverage for sync-prune backups and prune orphaned flagged entries only when an accounts snapshot exists, keeping flagged-only recovery flows intact. Co-authored-by: Codex --- lib/storage.ts | 23 +++++++++++++++++++++-- lib/ui/auth-menu.ts | 2 +- test/sync-prune-backup.test.ts | 8 ++++++++ 3 files changed, 30 insertions(+), 3 deletions(-) diff --git a/lib/storage.ts b/lib/storage.ts index cc817566..bcd90f5d 100644 --- a/lib/storage.ts +++ b/lib/storage.ts @@ -1037,11 +1037,30 @@ async function saveFlaggedAccountsUnlocked(storage: FlaggedAccountStorageV1): Pr 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") { @@ -1072,7 +1091,7 @@ async function loadFlaggedAccountsUnlocked(): 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, diff --git a/lib/ui/auth-menu.ts b/lib/ui/auth-menu.ts index 2b12d983..6af6f164 100644 --- a/lib/ui/auth-menu.ts +++ b/lib/ui/auth-menu.ts @@ -207,7 +207,7 @@ export async function showSyncToolsMenu(syncEnabled: boolean): Promise { accountIdSource: "org", refreshToken: "refresh-token", accessToken: "access-token", + idToken: "id-token", addedAt: 1, lastUsed: 1, }, @@ -26,14 +27,17 @@ describe("sync prune backup payload", () => { { 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", () => { @@ -48,6 +52,7 @@ describe("sync prune backup payload", () => { accountIdSource: "org", refreshToken: "refresh-token", accessToken: "access-token", + idToken: "id-token", accountTags: ["work"], addedAt: 1, lastUsed: 1, @@ -63,6 +68,7 @@ describe("sync prune backup payload", () => { { refreshToken: "refresh-token", accessToken: "flagged-access-token", + idToken: "flagged-id-token", metadata: { source: "flagged", }, @@ -78,11 +84,13 @@ describe("sync prune backup payload", () => { expect(payload.accounts.accounts[0]?.accountTags).toEqual(["work"]); expect(payload.accounts.accounts[0]?.lastSelectedModelByFamily).toEqual({ codex: "gpt-5.4" }); + expect(payload.accounts.accounts[0]).not.toHaveProperty("idToken"); expect(payload.flagged.accounts[0]).toMatchObject({ metadata: { source: "flagged", }, }); expect(payload.flagged.accounts[0]).not.toHaveProperty("refreshToken"); + expect(payload.flagged.accounts[0]).not.toHaveProperty("idToken"); }); }); From 080ca634b2bd83065965e8757a7ea104a38cd27e Mon Sep 17 00:00:00 2001 From: ndycode Date: Tue, 10 Mar 2026 18:52:36 +0800 Subject: [PATCH 5/6] fix: close remaining sync review gaps Move overlap-cleanup backup creation under the same storage lock as apply, preserve exact removal identities through sync-prune confirmation, restore composite flagged filtering there, and align tests with the stronger identity semantics and Windows fixture paths. Co-authored-by: Codex --- index.ts | 77 ++++++++++++++++++------------ lib/codex-multi-auth-sync.ts | 37 ++++++++++---- test/codex-multi-auth-sync.test.ts | 58 +++++++++++----------- 3 files changed, 104 insertions(+), 68 deletions(-) diff --git a/index.ts b/index.ts index 21c044e3..2291e4db 100644 --- a/index.ts +++ b/index.ts @@ -115,7 +115,6 @@ import { withAccountStorageTransaction, clearAccounts, setStoragePath, - backupRawAccountsFile, exportAccounts, importAccounts, previewImportAccounts, @@ -3361,6 +3360,14 @@ while (attempted.size < Math.max(1, accountCount)) { 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}`; @@ -3393,12 +3400,6 @@ while (attempted.size < Math.max(1, accountCount)) { } }; - const createMaintenanceAccountsBackup = async (prefix: string): Promise => { - const backupPath = createTimestampedBackupPath(prefix); - await backupRawAccountsFile(backupPath, true); - return backupPath; - }; - const createSyncPruneBackup = async (): Promise<{ backupPath: string; restore: () => Promise; @@ -3534,14 +3535,27 @@ while (attempted.size < Math.max(1, accountCount)) { }); if (removedTargets.length > 0) { - const removedRefreshTokens = new Set( - removedTargets.map((entry) => entry.account.refreshToken), + 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) => !removedRefreshTokens.has(flagged.refreshToken), + (flagged) => + !removedFlaggedKeys.has( + getSyncRemovalTargetKey({ + refreshToken: flagged.refreshToken, + organizationId: flagged.organizationId, + accountId: flagged.accountId, + }), + ), ), }); }); @@ -3549,35 +3563,31 @@ while (attempted.size < Math.max(1, accountCount)) { } }; - const buildSyncRemovalPlan = async (indexes: number[]): Promise<{ + const buildSyncRemovalPlan = ( + indexes: number[], + suggestions: SyncRemovalSuggestion[], + ): { previewLines: string[]; targets: SyncRemovalTarget[]; - }> => { - const currentStorage = - (await loadAccounts()) ?? - ({ - version: 3, - accounts: [], - activeIndex: 0, - activeIndexByFamily: {}, - } satisfies AccountStorageV3); + } => { + const byIndex = new Map(suggestions.map((suggestion) => [suggestion.index, suggestion])); const candidates = [...indexes] .sort((left, right) => left - right) .map((index) => { - const account = currentStorage.accounts[index]; - if (!account) { + 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 = account.email ?? account.accountLabel ?? `Account ${index + 1}`; - const currentSuffix = index === currentStorage.activeIndex ? " | current" : ""; + const label = suggestion.email ?? suggestion.accountLabel ?? `Account ${index + 1}`; + const currentSuffix = suggestion.isCurrentAccount ? " | current" : ""; return { previewLine: `${index + 1}. ${label}${currentSuffix}`, target: { - refreshToken: account.refreshToken, - organizationId: account.organizationId, - accountId: account.accountId, + refreshToken: suggestion.refreshToken, + organizationId: suggestion.organizationId, + accountId: suggestion.accountId, } satisfies SyncRemovalTarget, }; }); @@ -3666,7 +3676,10 @@ while (attempted.size < Math.max(1, accountCount)) { console.log("Sync cancelled.\n"); return; } - const removalPlan = await buildSyncRemovalPlan(indexesToRemove); + const removalPlan = buildSyncRemovalPlan( + indexesToRemove, + details.suggestedRemovals as SyncRemovalSuggestion[], + ); console.log("Dry run removal:"); for (const line of removalPlan.previewLines) { console.log(` ${line}`); @@ -3701,6 +3714,7 @@ while (attempted.size < Math.max(1, accountCount)) { }; const runCodexMultiAuthOverlapCleanup = async (): Promise => { + let backupPath: string | undefined; try { const preview = await previewCodexMultiAuthSyncedOverlapCleanup(); if (preview.removed <= 0 && preview.updated <= 0) { @@ -3717,8 +3731,8 @@ while (attempted.size < Math.max(1, accountCount)) { console.log("\nCleanup cancelled.\n"); return; } - const backupPath = await createMaintenanceAccountsBackup("codex-maintenance-overlap-backup"); - const result = await cleanupCodexMultiAuthSyncedOverlaps(); + backupPath = createTimestampedBackupPath("codex-maintenance-overlap-backup"); + const result = await cleanupCodexMultiAuthSyncedOverlaps(backupPath); invalidateAccountManagerCache(); console.log(""); console.log("Cleanup complete."); @@ -3730,7 +3744,8 @@ while (attempted.size < Math.max(1, accountCount)) { console.log(""); } catch (error) { const message = error instanceof Error ? error.message : String(error); - console.log(`\nCleanup failed: ${message}\n`); + const backupHint = backupPath ? `\nBackup: ${backupPath}` : ""; + console.log(`\nCleanup failed: ${message}${backupHint}\n`); } }; diff --git a/lib/codex-multi-auth-sync.ts b/lib/codex-multi-auth-sync.ts index 1ba2f1c2..f5a1f123 100644 --- a/lib/codex-multi-auth-sync.ts +++ b/lib/codex-multi-auth-sync.ts @@ -1,6 +1,6 @@ import { existsSync, readdirSync, promises as fs } from "node:fs"; import { homedir } from "node:os"; -import { join, win32 } from "node:path"; +import { dirname, join, win32 } from "node:path"; import { ACCOUNT_LIMITS } from "./constants.js"; import { logWarn } from "./logger.js"; import { @@ -68,6 +68,9 @@ export interface CodexMultiAuthSyncCapacityDetails extends CodexMultiAuthResolve index: number; email?: string; accountLabel?: string; + refreshToken: string; + organizationId?: string; + accountId?: string; isCurrentAccount: boolean; score: number; reason: string; @@ -437,10 +440,6 @@ function filterSourceAccountsAgainstExistingEmails( return { ...sourceStorage, accounts: deduplicateSourceAccountsByEmail(sourceStorage.accounts).filter((account) => { - const normalizedEmail = normalizeIdentity(account.email); - if (normalizedEmail && existingState.emails.has(normalizedEmail)) { - return false; - } const organizationId = normalizeIdentity(account.organizationId); if (organizationId) { return !existingState.organizationIds.has(organizationId); @@ -453,6 +452,10 @@ function filterSourceAccountsAgainstExistingEmails( if (refreshToken && existingState.refreshTokens.has(refreshToken)) { return false; } + const normalizedEmail = normalizeIdentity(account.email); + if (normalizedEmail) { + return !existingState.emails.has(normalizedEmail); + } return true; }), }; @@ -516,6 +519,9 @@ function computeSyncCapacityDetails( index, email: account.email, accountLabel: account.accountLabel, + refreshToken: account.refreshToken, + organizationId: account.organizationId, + accountId: account.accountId, isCurrentAccount, enabled: account.enabled !== false, matchesSource, @@ -535,10 +541,13 @@ function computeSyncCapacityDetails( return left.index - right.index; }) .slice(0, Math.max(5, dedupedTotal - maxAccounts)) - .map(({ index, email, accountLabel, isCurrentAccount, score, reason }) => ({ + .map(({ index, email, accountLabel, refreshToken, organizationId, accountId, isCurrentAccount, score, reason }) => ({ index, email, accountLabel, + refreshToken, + organizationId, + accountId, isCurrentAccount, score, reason, @@ -1123,13 +1132,13 @@ function buildCodexMultiAuthOverlapCleanupPlan(existing: AccountStorageV3): { const removed = Math.max(0, before - after); const originalAccountsByKey = new Map(); for (const account of existing.accounts) { - const key = account.organizationId ?? account.accountId ?? account.refreshToken; + const key = toCleanupIdentityKeys(account)[0]; if (key) { originalAccountsByKey.set(key, account); } } const updated = normalized.accounts.reduce((count, account) => { - const key = account.organizationId ?? account.accountId ?? account.refreshToken; + const key = toCleanupIdentityKeys(account)[0]; if (!key) return count; const original = originalAccountsByKey.get(key); if (!original) return count; @@ -1200,7 +1209,9 @@ export async function previewCodexMultiAuthSyncedOverlapCleanup(): Promise { +export async function cleanupCodexMultiAuthSyncedOverlaps( + backupPath?: string, +): Promise { return withAccountStorageTransaction(async (current, persist) => { const fallback = current ?? { version: 3 as const, @@ -1208,6 +1219,14 @@ export async function cleanupCodexMultiAuthSyncedOverlaps(): Promise { 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 projectRoot = findProjectRoot(process.cwd()) ?? process.cwd(); - const projectKey = getProjectStorageKey(projectRoot); + 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"); @@ -240,7 +240,7 @@ describe("codex-multi-auth sync", () => { it("probes the DevTools fallback root when no env override is set", async () => { process.env.USERPROFILE = "C:\\Users\\tester"; process.env.HOME = "C:\\Users\\tester"; - const devToolsGlobalPath = join( + const devToolsGlobalPath = pathWin32.join( "C:\\Users\\tester", "DevTools", "config", @@ -252,14 +252,14 @@ describe("codex-multi-auth sync", () => { const { getCodexMultiAuthSourceRootDir } = await import("../lib/codex-multi-auth-sync.js"); expect(getCodexMultiAuthSourceRootDir()).toBe( - join("C:\\Users\\tester", "DevTools", "config", "codex", "multi-auth"), + 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 = join( + const devToolsGlobalPath = pathWin32.join( "C:\\Users\\tester", "DevTools", "config", @@ -267,7 +267,7 @@ describe("codex-multi-auth sync", () => { "multi-auth", "openai-codex-accounts.json", ); - const dotCodexGlobalPath = join( + const dotCodexGlobalPath = pathWin32.join( "C:\\Users\\tester", ".codex", "multi-auth", @@ -280,7 +280,7 @@ describe("codex-multi-auth sync", () => { const { getCodexMultiAuthSourceRootDir } = await import("../lib/codex-multi-auth-sync.js"); expect(getCodexMultiAuthSourceRootDir()).toBe( - join("C:\\Users\\tester", "DevTools", "config", "codex", "multi-auth"), + pathWin32.join("C:\\Users\\tester", "DevTools", "config", "codex", "multi-auth"), ); }); @@ -288,13 +288,13 @@ describe("codex-multi-auth sync", () => { process.env.USERPROFILE = "C:\\Users\\tester"; process.env.HOME = "C:\\Users\\tester"; process.env.CODEX_HOME = "C:\\Users\\tester\\.codex"; - const walOnlyPath = join( + const walOnlyPath = pathWin32.join( "C:\\Users\\tester", ".codex", "multi-auth", "openai-codex-accounts.json.wal", ); - const laterRealJson = join( + const laterRealJson = pathWin32.join( "C:\\Users\\tester", "DevTools", "config", @@ -309,7 +309,7 @@ describe("codex-multi-auth sync", () => { const { getCodexMultiAuthSourceRootDir } = await import("../lib/codex-multi-auth-sync.js"); expect(getCodexMultiAuthSourceRootDir()).toBe( - join("C:\\Users\\tester", "DevTools", "config", "codex", "multi-auth"), + pathWin32.join("C:\\Users\\tester", "DevTools", "config", "codex", "multi-auth"), ); }); @@ -588,6 +588,9 @@ describe("codex-multi-auth sync", () => { 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( { @@ -622,8 +625,8 @@ describe("codex-multi-auth sync", () => { const { previewCodexMultiAuthSyncedOverlapCleanup } = await import("../lib/codex-multi-auth-sync.js"); await expect(previewCodexMultiAuthSyncedOverlapCleanup()).resolves.toEqual({ before: 2, - after: 1, - removed: 1, + after: 2, + removed: 0, updated: 0, }); expect(storageModule.withAccountStorageTransaction).toHaveBeenCalledTimes(1); @@ -1096,21 +1099,25 @@ describe("codex-multi-auth sync", () => { 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([ + "shared@example.com", + "shared@example.com", "new@example.com", ]); - return { imported: 1, skipped: 0, total: 1 }; + return { imported: 3, skipped: 0, total: 3 }; }); 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([ + "shared@example.com", + "shared@example.com", "new@example.com", ]); return { - imported: 1, + imported: 3, skipped: 0, - total: 1, + total: 3, backupStatus: "created", backupPath: "/tmp/filtered-sync-backup.json", }; @@ -1120,14 +1127,14 @@ describe("codex-multi-auth sync", () => { await expect(previewSyncFromCodexMultiAuth(process.cwd())).resolves.toMatchObject({ accountsPath: globalPath, - imported: 1, - total: 1, + imported: 3, + total: 3, skipped: 0, }); await expect(syncFromCodexMultiAuth(process.cwd())).resolves.toMatchObject({ accountsPath: globalPath, - imported: 1, - total: 1, + imported: 3, + total: 3, skipped: 0, }); }); @@ -1712,16 +1719,11 @@ describe("codex-multi-auth sync", () => { const { cleanupCodexMultiAuthSyncedOverlaps } = await import("../lib/codex-multi-auth-sync.js"); await expect(cleanupCodexMultiAuthSyncedOverlaps()).resolves.toEqual({ before: 2, - after: 1, - removed: 1, + after: 2, + removed: 0, 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"); + expect(persist).not.toHaveBeenCalled(); }); it("remaps active indices when synced overlap cleanup reorders accounts", async () => { From ddb1a0f56fabc89f55b623f27e11eb8ddea00049 Mon Sep 17 00:00:00 2001 From: ndycode Date: Tue, 10 Mar 2026 19:07:43 +0800 Subject: [PATCH 6/6] fix: finish sync review follow-ups Use atomic temp-rename for prune backups and restore the two sync tests to verify the behaviors their names describe after the identity-handling changes. Co-authored-by: Codex --- index.ts | 20 +++++++++++---- test/codex-multi-auth-sync.test.ts | 41 +++++++++++------------------- 2 files changed, 30 insertions(+), 31 deletions(-) diff --git a/index.ts b/index.ts index 2291e4db..74ceeb37 100644 --- a/index.ts +++ b/index.ts @@ -3422,11 +3422,21 @@ while (attempted.size < Math.max(1, accountCount)) { ); const restoreAccountsSnapshot = structuredClone(currentAccountsStorage); const restoreFlaggedSnapshot = structuredClone(currentFlaggedStorage); - await fsPromises.writeFile(backupPath, `${JSON.stringify(backupPayload, null, 2)}\n`, { - encoding: "utf-8", - mode: 0o600, - flag: "wx", - }); + 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 () => { diff --git a/test/codex-multi-auth-sync.test.ts b/test/codex-multi-auth-sync.test.ts index 015182d2..87b0a139 100644 --- a/test/codex-multi-auth-sync.test.ts +++ b/test/codex-multi-auth-sync.test.ts @@ -1046,18 +1046,12 @@ describe("codex-multi-auth sync", () => { activeIndexByFamily: {}, accounts: [ { - accountId: "org-shared-a", - organizationId: "org-shared-a", - accountIdSource: "org", email: "shared@example.com", refreshToken: "rt-shared-a", addedAt: 1, lastUsed: 1, }, { - accountId: "org-shared-b", - organizationId: "org-shared-b", - accountIdSource: "org", email: "shared@example.com", refreshToken: "rt-shared-b", addedAt: 2, @@ -1083,9 +1077,6 @@ describe("codex-multi-auth sync", () => { activeIndexByFamily: {}, accounts: [ { - accountId: "org-existing", - organizationId: "org-existing", - accountIdSource: "org", email: "shared@example.com", refreshToken: "rt-existing", addedAt: 10, @@ -1099,25 +1090,21 @@ describe("codex-multi-auth sync", () => { 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([ - "shared@example.com", - "shared@example.com", "new@example.com", ]); - return { imported: 3, skipped: 0, total: 3 }; + 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([ - "shared@example.com", - "shared@example.com", "new@example.com", ]); return { - imported: 3, + imported: 1, skipped: 0, - total: 3, + total: 1, backupStatus: "created", backupPath: "/tmp/filtered-sync-backup.json", }; @@ -1127,14 +1114,14 @@ describe("codex-multi-auth sync", () => { await expect(previewSyncFromCodexMultiAuth(process.cwd())).resolves.toMatchObject({ accountsPath: globalPath, - imported: 3, - total: 3, + imported: 1, + total: 1, skipped: 0, }); await expect(syncFromCodexMultiAuth(process.cwd())).resolves.toMatchObject({ accountsPath: globalPath, - imported: 3, - total: 3, + imported: 1, + total: 1, skipped: 0, }); }); @@ -1701,9 +1688,6 @@ describe("codex-multi-auth sync", () => { lastUsed: 5, }, { - accountId: "org-sync", - organizationId: "org-sync", - accountIdSource: "org", accountTags: ["codex-multi-auth-sync"], email: "shared@example.com", refreshToken: "rt-sync", @@ -1719,11 +1703,16 @@ describe("codex-multi-auth sync", () => { const { cleanupCodexMultiAuthSyncedOverlaps } = await import("../lib/codex-multi-auth-sync.js"); await expect(cleanupCodexMultiAuthSyncedOverlaps()).resolves.toEqual({ before: 2, - after: 2, - removed: 0, + after: 1, + removed: 1, updated: 0, }); - expect(persist).not.toHaveBeenCalled(); + 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 () => {