From 11e9298e879ebf3b56ef50f4b956c073260ff97d Mon Sep 17 00:00:00 2001 From: ndycode Date: Sat, 7 Mar 2026 15:04:20 +0800 Subject: [PATCH 01/81] feat: integrate codex-multi-auth sync flow - add codex-multi-auth sync, overlap cleanup, and capacity-aware pruning\n- redesign the auth dashboard flow and add standalone TUI input diagnostics\n- validate the branch with live-session sync and parser harness coverage\n\nCo-authored-by: Codex --- README.md | 24 + docs/configuration.md | 20 + index.ts | 704 +++++++++++++++++++++++++++-- lib/cli.ts | 173 ++++++- lib/codex-multi-auth-sync.ts | 546 ++++++++++++++++++++++ lib/config.ts | 52 ++- lib/constants.ts | 4 +- lib/schemas.ts | 5 + lib/ui/ansi.ts | 36 +- lib/ui/auth-menu.ts | 534 +++++++++++++++++----- lib/ui/copy.ts | 61 +++ lib/ui/runtime.ts | 15 +- lib/ui/select.ts | 626 ++++++++++++++++++------- lib/ui/theme.ts | 95 +++- package.json | 1 + scripts/capture-tui-input.js | 127 ++++++ test/auth-menu.test.ts | 48 +- test/cli.test.ts | 47 +- test/codex-multi-auth-sync.test.ts | 416 +++++++++++++++++ test/index.test.ts | 28 +- test/plugin-config.test.ts | 88 +++- test/schemas.test.ts | 5 + test/ui-select.test.ts | 50 ++ 23 files changed, 3350 insertions(+), 355 deletions(-) create mode 100644 lib/codex-multi-auth-sync.ts create mode 100644 lib/ui/copy.ts create mode 100644 scripts/capture-tui-input.js create mode 100644 test/codex-multi-auth-sync.test.ts create mode 100644 test/ui-select.test.ts diff --git a/README.md b/README.md index 1db6279e..035827d0 100644 --- a/README.md +++ b/README.md @@ -836,6 +836,30 @@ Create `~/.opencode/openai-codex-auth-config.json` for optional settings: | `toastDurationMs` | `5000` | How long toast notifications stay visible (ms) | | `beginnerSafeMode` | `false` | Beginner-safe retry profile: conservative retry budget, disables all-accounts wait/retry, and caps all-accounts retries | +### Experimental Settings + +The auth dashboard now includes `Experimental settings` with a manual sync option for `codex-multi-auth`. + +Persist the toggle in `~/.opencode/openai-codex-auth-config.json`: + +```json +{ + "experimental": { + "syncFromCodexMultiAuth": { + "enabled": true + } + } +} +``` + +When enabled, `Sync now` will auto-discover a `codex-multi-auth` account store from: +- `CODEX_MULTI_AUTH_DIR` +- `CODEX_HOME/multi-auth` +- `~/DevTools/config/codex/multi-auth` +- `~/.codex/multi-auth` + +It previews import impact first and skips duplicate overlaps using the existing dedupe-aware import flow. + ### Retry Behavior | Option | Default | What It Does | diff --git a/docs/configuration.md b/docs/configuration.md index 57b76e9e..fbcb2b04 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -165,6 +165,26 @@ The sample above intentionally sets `"retryAllAccountsMaxRetries": 3` as a bound | `autoResume` | `true` | auto-resume after thinking block recovery | | `tokenRefreshSkewMs` | `60000` | refresh tokens this many ms before expiry | | `rateLimitToastDebounceMs` | `60000` | debounce rate limit toasts | + +### Experimental Settings + +Enable manual sync from `codex-multi-auth`: + +```json +{ + "experimental": { + "syncFromCodexMultiAuth": { + "enabled": true + } + } +} +``` + +When enabled, the auth dashboard can discover `codex-multi-auth` storage from: +- `CODEX_MULTI_AUTH_DIR` +- `CODEX_HOME/multi-auth` +- `~/DevTools/config/codex/multi-auth` +- `~/.codex/multi-auth` | `fetchTimeoutMs` | `60000` | upstream fetch timeout in ms | | `streamStallTimeoutMs` | `45000` | max time to wait for next SSE chunk before aborting | diff --git a/index.ts b/index.ts index 23dff3cf..35fce855 100644 --- a/index.ts +++ b/index.ts @@ -35,7 +35,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 +65,9 @@ import { getCodexTuiColorProfile, getCodexTuiGlyphMode, getBeginnerSafeMode, + getSyncFromCodexMultiAuthEnabled, loadPluginConfig, + setSyncFromCodexMultiAuthEnabled, } from "./lib/config.js"; import { AUTH_LABELS, @@ -152,6 +154,8 @@ 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 { paintUiText, formatUiBadge, formatUiHeader, formatUiItem, formatUiKeyValue, formatUiSection } from "./lib/ui/format.js"; +import { confirm } from "./lib/ui/confirm.js"; +import { ANSI, isTTY as isInteractiveTTY } from "./lib/ui/ansi.js"; import { buildBeginnerChecklist, buildBeginnerDoctorFindings, @@ -182,6 +186,12 @@ import { detectErrorType, getRecoveryToastContent, } from "./lib/recovery.js"; +import { + CodexMultiAuthSyncCapacityError, + cleanupCodexMultiAuthSyncedOverlaps, + previewSyncFromCodexMultiAuth, + syncFromCodexMultiAuth, +} from "./lib/codex-multi-auth-sync.js"; /** * OpenAI Codex OAuth authentication plugin for opencode @@ -1216,6 +1226,149 @@ export const OpenAIOAuthPlugin: Plugin = async ({ client }: PluginInput) => { return applyUiRuntimeFromConfig(loadPluginConfig()); }; + const createOperationScreen = ( + ui: UiRuntimeOptions, + title: string, + subtitle?: string, + ): { + push: (line: string, tone?: "normal" | "muted" | "success" | "warning" | "danger" | "accent") => void; + finish: (summaryLines?: Array<{ line: string; tone?: "normal" | "muted" | "success" | "warning" | "danger" | "accent" }>) => Promise; + } | null => { + if (!ui.v2Enabled || !isInteractiveTTY()) { + return null; + } + + const lines: Array<{ line: string; tone: "normal" | "muted" | "success" | "warning" | "danger" | "accent" }> = []; + let footer = "Running..."; + const stripAnsi = (value: string): string => value.replace(/\x1b\[[0-9;]*m/g, ""); + const truncateAnsi = (value: string, maxVisibleChars: number): string => { + if (maxVisibleChars <= 0) return ""; + const visible = stripAnsi(value); + if (visible.length <= maxVisibleChars) return value; + const suffix = maxVisibleChars >= 3 ? "..." : ".".repeat(maxVisibleChars); + const keep = Math.max(0, maxVisibleChars - suffix.length); + let kept = 0; + let index = 0; + let output = ""; + while (index < value.length && kept < keep) { + if (value[index] === "\x1b") { + const match = value.slice(index).match(/^\x1b\[[0-9;]*m/); + if (match) { + output += match[0]; + index += match[0].length; + continue; + } + } + output += value[index]; + index += 1; + kept += 1; + } + return output + suffix; + }; + const render = () => { + const screenLines: string[] = []; + const columns = process.stdout.columns ?? 120; + screenLines.push(...formatUiHeader(ui, title)); + if (subtitle) { + screenLines.push(paintUiText(ui, subtitle, "muted")); + } + screenLines.push(""); + screenLines.push( + ...lines.map((entry) => + truncateAnsi(paintUiText(ui, entry.line, entry.tone), Math.max(20, columns - 2)), + ), + ); + screenLines.push(""); + screenLines.push(paintUiText(ui, footer, "muted")); + process.stdout.write(ANSI.clearScreen + ANSI.moveTo(1, 1) + screenLines.join("\n")); + }; + + render(); + return { + push: (line: string, tone = "normal") => { + lines.push({ line, tone }); + render(); + }, + finish: async (summaryLines?: Array<{ line: string; tone?: "normal" | "muted" | "success" | "warning" | "danger" | "accent" }>) => { + if (summaryLines && summaryLines.length > 0) { + lines.push({ line: "", tone: "normal" }); + for (const entry of summaryLines) { + lines.push({ line: entry.line, tone: entry.tone ?? "normal" }); + } + } + footer = "Done."; + render(); + const autoReturnMs = 2_000; + const { stdin } = process; + const wasRaw = stdin.isRaw ?? false; + const waitForAnyKey = async (message: string): Promise => { + footer = message; + render(); + await new Promise((resolve) => { + const onData = () => { + stdin.off("data", onData); + resolve(); + }; + try { + stdin.setRawMode(true); + } catch { + resolve(); + return; + } + stdin.resume(); + stdin.on("data", onData); + }); + }; + let paused = false; + await new Promise((resolve) => { + let finished = false; + let timer: ReturnType | null = null; + const endAt = Date.now() + autoReturnMs; + const onData = () => { + if (finished) return; + paused = true; + finished = true; + if (timer) clearInterval(timer); + stdin.off("data", onData); + resolve(); + }; + try { + stdin.setRawMode(true); + stdin.resume(); + stdin.on("data", onData); + } catch { + resolve(); + return; + } + timer = setInterval(() => { + const remaining = Math.max(0, endAt - Date.now()); + if (remaining <= 0) { + if (finished) return; + finished = true; + if (timer) { + clearInterval(timer); + } + stdin.off("data", onData); + resolve(); + return; + } + footer = `Returning in ${Math.ceil(remaining / 1000)}s... Press any key to pause.`; + render(); + }, 150); + }); + if (paused) { + await waitForAnyKey("Paused. Press any key to continue."); + } + try { + stdin.setRawMode(wasRaw); + stdin.pause(); + } catch { + // best effort restore + } + }, + }; + }; + const getStatusMarker = ( ui: UiRuntimeOptions, status: "ok" | "warning" | "error", @@ -1270,6 +1423,88 @@ export const OpenAIOAuthPlugin: Plugin = async ({ client }: PluginInput) => { return `Account ${index + 1} (${details.join(", ")})`; }; + type CheckDashboardRow = { + index: number; + email?: string; + status: "current" | "ok" | "warning" | "danger" | "disabled"; + detail?: string; + }; + + const createInlineCheckDashboardScreen = ( + ui: UiRuntimeOptions, + progressLabel: string, + rows: CheckDashboardRow[], + selectedIndex: number, + ): { + render: (progressText: string, footer?: string) => void; + finish: (progressText: string, footer?: string) => Promise; + } | null => { + if (!ui.v2Enabled || !isInteractiveTTY()) return null; + + const stripAnsi = (value: string): string => value.replace(/\x1b\[[0-9;]*m/g, ""); + const truncate = (value: string, maxVisibleChars: number): string => { + const visible = stripAnsi(value); + if (visible.length <= maxVisibleChars) return value; + return `${visible.slice(0, Math.max(0, maxVisibleChars - 3))}...`; + }; + + const statusBadgeForRow = (row: CheckDashboardRow): string => { + switch (row.status) { + case "current": + return `${formatUiBadge(ui, "current", "accent")} ${formatUiBadge(ui, "active", "success")}`; + case "ok": + return formatUiBadge(ui, "ok", "success"); + case "warning": + return formatUiBadge(ui, "warning", "warning"); + case "danger": + return formatUiBadge(ui, "rate-limited", "warning"); + case "disabled": + return formatUiBadge(ui, "disabled", "danger"); + } + }; + + const spinnerFrames = ["-", "\\", "|", "/"]; + const render = (progressText: string, footer = "Running...") => { + const cols = process.stdout.columns ?? 120; + const spinner = spinnerFrames[Math.floor(Date.now() / 150) % spinnerFrames.length] ?? "-"; + const lines: string[] = []; + lines.push(...formatUiHeader(ui, "Accounts Dashboard")); + lines.push(paintUiText(ui, `${spinner} ${progressText}`, "muted")); + lines.push(""); + lines.push(paintUiText(ui, "Quick Actions", "muted")); + lines.push(` ${paintUiText(ui, "o", "muted")} ${paintUiText(ui, "Add New Account", "success")}`); + lines.push(` ${paintUiText(ui, "o", "muted")} ${paintUiText(ui, "Run Health Check", "success")}`); + lines.push(` ${paintUiText(ui, "o", "muted")} ${paintUiText(ui, "Pick Best Account", "success")}`); + lines.push(` ${paintUiText(ui, "o", "muted")} ${paintUiText(ui, "Auto-Repair Issues", "success")}`); + lines.push(` ${paintUiText(ui, "o", "muted")} ${paintUiText(ui, "Settings", "success")}`); + lines.push(""); + lines.push(paintUiText(ui, "Advanced Checks", "muted")); + lines.push(` ${paintUiText(ui, "o", "muted")} ${paintUiText(ui, "Refresh All Accounts", "success")}`); + lines.push(` ${paintUiText(ui, "o", "muted")} ${paintUiText(ui, "Check Problem Accounts", "warning")}`); + lines.push(""); + lines.push(paintUiText(ui, "Saved Accounts", "muted")); + for (const row of rows) { + const marker = row.index === selectedIndex ? ">" : "o"; + const primary = `${row.index + 1}. ${row.email ?? `Account ${row.index + 1}`}`; + lines.push(` ${paintUiText(ui, marker, row.index === selectedIndex ? "accent" : "muted")} ${truncate(`${paintUiText(ui, primary, row.index === selectedIndex ? "accent" : "heading")} ${statusBadgeForRow(row)}`, Math.max(20, cols - 4))}`); + if (row.index === selectedIndex && row.detail) { + lines.push(` ${truncate(paintUiText(ui, `Limits: ${row.detail}`, "muted"), Math.max(20, cols - 6))}`); + } + } + lines.push(""); + lines.push(paintUiText(ui, footer, "muted")); + process.stdout.write(ANSI.clearScreen + ANSI.moveTo(1, 1) + lines.join("\n")); + }; + + return { + render, + finish: async (progressText: string, footer = "Done.") => { + render(progressText, footer); + await new Promise((resolve) => setTimeout(resolve, 1200)); + }, + }; + }; + const normalizeAccountTags = (raw: string): string[] => { return Array.from( new Set( @@ -2857,6 +3092,14 @@ while (attempted.size < Math.max(1, accountCount)) { }; const formatCodexQuotaLine = (snapshot: CodexQuotaSnapshot): string => { + const quotaBar = (usedPercent: number | undefined): string => { + if (typeof usedPercent !== "number" || !Number.isFinite(usedPercent)) { + return "▒▒▒▒▒▒▒▒▒▒"; + } + const left = Math.max(0, Math.min(100, Math.round(100 - usedPercent))); + const filled = Math.max(0, Math.min(10, Math.round(left / 10))); + return `${"█".repeat(filled)}${"▒".repeat(10 - filled)}`; + }; const summarizeWindow = (label: string, window: CodexQuotaWindow): string => { const used = window.usedPercent; const left = @@ -2865,7 +3108,7 @@ while (attempted.size < Math.max(1, accountCount)) { : undefined; const reset = formatResetAt(window.resetAtMs); let summary = label; - if (left !== undefined) summary = `${summary} ${left}% left`; + if (left !== undefined) summary = `${summary} ${quotaBar(used)} ${left}% left`; if (reset) summary = `${summary} (resets ${reset})`; return summary; }; @@ -2977,6 +3220,7 @@ while (attempted.size < Math.max(1, accountCount)) { }; const runAccountCheck = async (deepProbe: boolean): Promise => { + const ui = resolveUiRuntime(); const loadedStorage = await hydrateEmails(await loadAccounts()); const workingStorage = loadedStorage ? { @@ -2987,9 +3231,49 @@ while (attempted.size < Math.max(1, accountCount)) { : {}, } : { version: 3 as const, accounts: [], activeIndex: 0, activeIndexByFamily: {} }; + const activeIndex = resolveActiveIndex(workingStorage, "codex"); + const dashboardRows: CheckDashboardRow[] = workingStorage.accounts.map((account, index) => ({ + index, + email: account.email, + status: index === activeIndex ? "current" : account.enabled === false ? "disabled" : "ok", + detail: undefined, + })); + const dashboardScreen = createInlineCheckDashboardScreen( + ui, + deepProbe ? "Refreshing account limits..." : "Fetching account limits...", + dashboardRows, + activeIndex, + ); + const emit = ( + index: number, + detail: string, + tone: "normal" | "muted" | "success" | "warning" | "danger" | "accent" = "normal", + ) => { + const row = dashboardRows[index]; + if (row) { + row.detail = detail; + row.status = + tone === "danger" + ? "danger" + : tone === "warning" + ? "warning" + : row.status === "disabled" + ? "disabled" + : index === activeIndex + ? "current" + : "ok"; + } + if (dashboardScreen) { + dashboardScreen.render( + `${deepProbe ? "Refreshing" : "Fetching"} account limits... [${Math.min(index + 1, workingStorage.accounts.length)}/${workingStorage.accounts.length}]`, + ); + return; + } + console.log(detail); + }; if (workingStorage.accounts.length === 0) { - console.log("\nNo accounts to check.\n"); + console.log("No accounts to check."); return; } @@ -3001,18 +3285,16 @@ while (attempted.size < Math.max(1, accountCount)) { let ok = 0; let disabled = 0; let errors = 0; - - console.log( - `\nChecking ${deepProbe ? "full account health" : "quotas"} for all accounts...\n`, + dashboardScreen?.render( + `${deepProbe ? "Refreshing" : "Fetching"} account limits... [0/${workingStorage.accounts.length}]`, ); for (let i = 0; i < total; i += 1) { const account = workingStorage.accounts[i]; if (!account) continue; - const label = account.email ?? account.accountLabel ?? `Account ${i + 1}`; if (account.enabled === false) { disabled += 1; - console.log(`[${i + 1}/${total}] ${label}: DISABLED`); + emit(i, "disabled", "warning"); continue; } @@ -3099,7 +3381,7 @@ while (attempted.size < Math.max(1, accountCount)) { errors += 1; const message = refreshResult.message ?? refreshResult.reason ?? "refresh failed"; - console.log(`[${i + 1}/${total}] ${label}: ERROR (${message})`); + emit(i, `error: ${message}`, "danger"); if (deepProbe && isFlaggableFailure(refreshResult)) { const existingIndex = flaggedStorage.accounts.findIndex( (flagged) => flagged.refreshToken === account.refreshToken, @@ -3167,7 +3449,7 @@ while (attempted.size < Math.max(1, accountCount)) { tokenAccountId ? `${authDetail} (id:${tokenAccountId.slice(-6)})` : authDetail; - console.log(`[${i + 1}/${total}] ${label}: ${detail}`); + emit(i, detail, "success"); continue; } @@ -3191,20 +3473,16 @@ while (attempted.size < Math.max(1, accountCount)) { organizationId: account.organizationId, }); ok += 1; - console.log( - `[${i + 1}/${total}] ${label}: ${formatCodexQuotaLine(snapshot)}`, - ); + emit(i, formatCodexQuotaLine(snapshot), snapshot.status === 429 ? "warning" : "success"); } catch (error) { errors += 1; const message = error instanceof Error ? error.message : String(error); - console.log( - `[${i + 1}/${total}] ${label}: ERROR (${message.slice(0, 160)})`, - ); + emit(i, `error: ${message.slice(0, 160)}`, "danger"); } } catch (error) { errors += 1; const message = error instanceof Error ? error.message : String(error); - console.log(`[${i + 1}/${total}] ${label}: ERROR (${message.slice(0, 120)})`); + emit(i, `error: ${message.slice(0, 120)}`, "danger"); } } @@ -3224,31 +3502,70 @@ while (attempted.size < Math.max(1, accountCount)) { await saveFlaggedAccounts(flaggedStorage); } - console.log(""); - console.log(`Results: ${ok} ok, ${errors} error, ${disabled} disabled`); + const summaryLines: Array<{ + line: string; + tone?: "normal" | "muted" | "success" | "warning" | "danger" | "accent"; + }> = [{ line: `Results: ${ok} ok, ${errors} error, ${disabled} disabled`, tone: errors > 0 ? "warning" : "success" }]; if (removeFromActive.size > 0) { - console.log( - `Moved ${removeFromActive.size} account(s) to flagged pool (invalid refresh token).`, + summaryLines.push({ line: `Moved ${removeFromActive.size} account(s) to flagged pool (invalid refresh token).`, tone: "warning" as const }); + } + if (dashboardScreen) { + await dashboardScreen.finish( + `${deepProbe ? "Refreshing" : "Fetching"} account limits... [${workingStorage.accounts.length}/${workingStorage.accounts.length}]`, + summaryLines.map((entry) => entry.line).join(" | "), ); + return; + } + console.log(""); + for (const line of summaryLines) { + console.log(line.line); } console.log(""); }; const verifyFlaggedAccounts = async (): Promise => { + const ui = resolveUiRuntime(); + const screen = createOperationScreen( + ui, + "Check Problem Accounts", + "Checking flagged accounts and attempting restore", + ); + const emit = ( + line: string, + tone: "normal" | "muted" | "success" | "warning" | "danger" | "accent" = "normal", + ) => { + if (screen) { + screen.push(line, tone); + return; + } + console.log(line); + }; const flaggedStorage = await loadFlaggedAccounts(); if (flaggedStorage.accounts.length === 0) { - console.log("\nNo flagged accounts to verify.\n"); + emit("No flagged accounts to verify."); + await screen?.finish(); return; } - console.log("\nVerifying flagged accounts...\n"); + emit(`Checking ${flaggedStorage.accounts.length} problem account(s)...`, "muted"); const remaining: FlaggedAccountMetadataV1[] = []; const restored: TokenSuccessWithAccount[] = []; + const flaggedLabelWidth = Math.min( + 72, + Math.max( + 18, + ...flaggedStorage.accounts.map((flagged, index) => + (flagged.email ?? flagged.accountLabel ?? `Flagged ${index + 1}`).length, + ), + ), + ); + const padFlaggedLabel = (value: string): string => + value.length >= flaggedLabelWidth ? value : `${value}${" ".repeat(flaggedLabelWidth - value.length)}`; for (let i = 0; i < flaggedStorage.accounts.length; i += 1) { const flagged = flaggedStorage.accounts[i]; if (!flagged) continue; - const label = flagged.email ?? flagged.accountLabel ?? `Flagged ${i + 1}`; + const label = padFlaggedLabel(flagged.email ?? flagged.accountLabel ?? `Flagged ${i + 1}`); try { const cached = await lookupCodexCliTokensByEmail(flagged.email); const now = Date.now(); @@ -3281,17 +3598,13 @@ while (attempted.size < Math.max(1, accountCount)) { resolved.primary.accountLabel = flagged.accountLabel; } restored.push(...resolved.variantsForPersistence); - console.log( - `[${i + 1}/${flaggedStorage.accounts.length}] ${label}: RESTORED (Codex CLI cache)`, - ); + emit(`${getStatusMarker(ui, "ok")} ${label} | restored (cache)`, "success"); continue; } const refreshResult = await queuedRefresh(flagged.refreshToken); if (refreshResult.type !== "success") { - console.log( - `[${i + 1}/${flaggedStorage.accounts.length}] ${label}: STILL FLAGGED (${refreshResult.message ?? refreshResult.reason ?? "refresh failed"})`, - ); + emit(`${getStatusMarker(ui, "warning")} ${label} | still flagged: ${refreshResult.message ?? refreshResult.reason ?? "refresh failed"}`, "warning"); remaining.push(flagged); continue; } @@ -3309,11 +3622,12 @@ while (attempted.size < Math.max(1, accountCount)) { resolved.primary.accountLabel = flagged.accountLabel; } restored.push(...resolved.variantsForPersistence); - console.log(`[${i + 1}/${flaggedStorage.accounts.length}] ${label}: RESTORED`); + emit(`${getStatusMarker(ui, "ok")} ${label} | restored`, "success"); } catch (error) { const message = error instanceof Error ? error.message : String(error); - console.log( - `[${i + 1}/${flaggedStorage.accounts.length}] ${label}: ERROR (${message.slice(0, 120)})`, + emit( + `${getStatusMarker(ui, "error")} ${label} | error: ${message.slice(0, 120)}`, + "danger", ); remaining.push({ ...flagged, @@ -3332,8 +3646,284 @@ while (attempted.size < Math.max(1, accountCount)) { accounts: remaining, }); + const summaryLines: Array<{ + line: string; + tone?: "normal" | "muted" | "success" | "warning" | "danger" | "accent"; + }> = [{ line: `Results: ${restored.length} restored, ${remaining.length} still flagged`, tone: remaining.length > 0 ? "warning" : "success" }]; + if (screen) { + await screen.finish(summaryLines); + return; + } + console.log(""); + for (const line of summaryLines) { + console.log(line.line); + } + console.log(""); + }; + + const toggleCodexMultiAuthSyncSetting = (): void => { + const currentConfig = loadPluginConfig(); + const enabled = getSyncFromCodexMultiAuthEnabled(currentConfig); + setSyncFromCodexMultiAuthEnabled(!enabled); + const nextLabel = !enabled ? "enabled" : "disabled"; + console.log(`\nSync from codex-multi-auth ${nextLabel}.\n`); + }; + + const runCodexMultiAuthSync = async (): Promise => { + const currentConfig = loadPluginConfig(); + if (!getSyncFromCodexMultiAuthEnabled(currentConfig)) { + console.log("\nEnable sync from codex-multi-auth in Experimental settings first.\n"); + return; + } + + const removeAccountsForSync = async (indexes: number[]): Promise => { + const currentStorage = + (await loadAccounts()) ?? + ({ + version: 3, + accounts: [], + activeIndex: 0, + activeIndexByFamily: {}, + } satisfies AccountStorageV3); + const currentFlaggedStorage = await loadFlaggedAccounts(); + const descending = [...indexes].sort((left, right) => right - left); + const removedTargets = descending + .map((index) => ({ index, account: currentStorage.accounts[index] })) + .filter((entry) => entry.account); + if (removedTargets.length === 0) { + return; + } + for (const { index } of removedTargets) { + currentStorage.accounts.splice(index, 1); + } + clampActiveIndices(currentStorage); + await saveAccounts(currentStorage); + const removedRefreshTokens = new Set( + removedTargets.map((entry) => entry.account?.refreshToken).filter((token): token is string => Boolean(token)), + ); + await saveFlaggedAccounts({ + version: 1, + accounts: currentFlaggedStorage.accounts.filter( + (flagged) => !removedRefreshTokens.has(flagged.refreshToken), + ), + }); + invalidateAccountManagerCache(); + const removedLabels = removedTargets + .map((entry) => entry.account?.email ?? `Account ${entry.index + 1}`) + .join(", "); + console.log(`\nRemoved ${removedTargets.length} account(s): ${removedLabels}\n`); + }; + + const buildSyncRemovalPreview = async (indexes: number[]): Promise => { + const currentStorage = + (await loadAccounts()) ?? + ({ + version: 3, + accounts: [], + activeIndex: 0, + activeIndexByFamily: {}, + } satisfies AccountStorageV3); + return [...indexes] + .sort((left, right) => left - right) + .map((index) => { + const account = currentStorage.accounts[index]; + if (!account) return `Account ${index + 1}`; + const label = account.email ?? account.accountLabel ?? `Account ${index + 1}`; + const currentSuffix = index === currentStorage.activeIndex ? " | current" : ""; + return `${index + 1}. ${label}${currentSuffix}`; + }); + }; + + while (true) { + try { + const preview = await previewSyncFromCodexMultiAuth(process.cwd()); + 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) { + console.log("No new accounts to import.\n"); + return; + } + + const confirmed = await confirm( + `Import ${preview.imported} new account(s) from codex-multi-auth?`, + ); + if (!confirmed) { + console.log("\nSync cancelled.\n"); + return; + } + + const result = await syncFromCodexMultiAuth(process.cwd()); + 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(`Source accounts: ${details.sourceCount}`); + console.log(`Deduped total after merge: ${details.dedupedTotal}`); + console.log(`Overlap accounts skipped by dedupe: ${details.skippedOverlaps}`); + console.log(`Importable new accounts: ${details.importableNewAccounts}`); + console.log(`Maximum allowed: ${details.maxAccounts}`); + console.log(`Remove at least ${details.needToRemove} account(s) first.`); + if (details.suggestedRemovals.length > 0) { + console.log("Suggested removals:"); + for (const suggestion of details.suggestedRemovals) { + const label = + suggestion.email ?? + suggestion.accountLabel ?? + `Account ${suggestion.index + 1}`; + const currentSuffix = suggestion.isCurrentAccount ? " | current" : ""; + console.log( + ` ${suggestion.index + 1}. ${label}${currentSuffix} | score ${suggestion.score} | ${suggestion.reason}`, + ); + } + } + console.log(""); + const indexesToRemove = await promptCodexMultiAuthSyncPrune( + details.needToRemove, + details.suggestedRemovals, + ); + if (!indexesToRemove || indexesToRemove.length === 0) { + console.log("Sync cancelled.\n"); + return; + } + const previewLines = await buildSyncRemovalPreview(indexesToRemove); + console.log("Dry run removal:"); + for (const line of previewLines) { + console.log(` ${line}`); + } + console.log(""); + const confirmed = await confirm( + `Remove ${indexesToRemove.length} selected account(s) and retry sync?`, + ); + if (!confirmed) { + console.log("Sync cancelled.\n"); + return; + } + await removeAccountsForSync(indexesToRemove); + continue; + } + const message = error instanceof Error ? error.message : String(error); + console.log(`\nSync failed: ${message}\n`); + return; + } + } + }; + + const runCodexMultiAuthOverlapCleanup = async (): Promise => { + try { + 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(""); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + console.log(`\nCleanup failed: ${message}\n`); + } + }; + + const pickBestAccountFromDashboard = async (): Promise => { + const storage = await loadAccounts(); + if (!storage || storage.accounts.length === 0) { + console.log("\nNo accounts available.\n"); + return; + } + const now = Date.now(); + const managerForFix = cachedAccountManager ?? (await AccountManager.loadFromDisk()); + const explainability = managerForFix.getSelectionExplainability("codex", undefined, now); + const eligible = explainability + .filter((entry) => entry.eligible) + .sort((a, b) => { + if (b.healthScore !== a.healthScore) return b.healthScore - a.healthScore; + return b.tokensAvailable - a.tokensAvailable; + }); + const best = eligible[0]; + if (!best) { + console.log("\nNo eligible account available.\n"); + return; + } + storage.activeIndex = best.index; + storage.activeIndexByFamily = storage.activeIndexByFamily ?? {}; + for (const family of MODEL_FAMILIES) { + storage.activeIndexByFamily[family] = best.index; + } + await saveAccounts(storage); + invalidateAccountManagerCache(); + const account = storage.accounts[best.index]; + console.log(`\nSelected best account: ${account?.email ?? `Account ${best.index + 1}`}\n`); + }; + + const runAutoRepairFromDashboard = async (): Promise => { + const storage = await loadAccounts(); + if (!storage || storage.accounts.length === 0) { + console.log("\nNo accounts available.\n"); + return; + } + const appliedFixes: string[] = []; + const fixErrors: string[] = []; + const cleanupResult = await cleanupCodexMultiAuthSyncedOverlaps(); + if (cleanupResult.removed > 0) { + appliedFixes.push(`Removed ${cleanupResult.removed} synced overlap(s).`); + } + + let changedByRefresh = false; + let refreshedCount = 0; + for (const account of storage.accounts) { + try { + const refreshResult = await queuedRefresh(account.refreshToken); + if (refreshResult.type === "success") { + account.refreshToken = refreshResult.refresh; + account.accessToken = refreshResult.access; + account.expiresAt = refreshResult.expires; + changedByRefresh = true; + refreshedCount += 1; + } + } catch (error) { + fixErrors.push(error instanceof Error ? error.message : String(error)); + } + } + if (changedByRefresh) { + await saveAccounts(storage); + appliedFixes.push(`Refreshed ${refreshedCount} account token(s).`); + } + await verifyFlaggedAccounts(); + await pickBestAccountFromDashboard(); console.log(""); - console.log(`Results: ${restored.length} restored, ${remaining.length} still flagged`); + console.log("Auto-repair complete."); + for (const entry of appliedFixes) { + console.log(`- ${entry}`); + } + for (const entry of fixErrors) { + console.log(`- warning: ${entry}`); + } console.log(""); }; @@ -3378,9 +3968,12 @@ while (attempted.size < Math.max(1, accountCount)) { accountLabel: account.accountLabel, email: account.email, index, + sourceIndex: index, + quickSwitchNumber: index + 1, addedAt: account.addedAt, lastUsed: account.lastUsed, status, + quotaSummary: formatRateLimitEntry(account, now) ?? undefined, isCurrentAccount: index === activeIndex, enabled: account.enabled !== false, }; @@ -3388,6 +3981,12 @@ while (attempted.size < Math.max(1, accountCount)) { const menuResult = await promptLoginMode(existingAccounts, { flaggedCount: flaggedStorage.accounts.length, + syncFromCodexMultiAuthEnabled: getSyncFromCodexMultiAuthEnabled(loadPluginConfig()), + statusMessage: () => { + const snapshot = runtimeMetrics.lastSelectionSnapshot; + if (!snapshot) return undefined; + return snapshot.model ? `Current lens: ${snapshot.family}:${snapshot.model}` : `Current lens: ${snapshot.family}`; + }, }); if (menuResult.mode === "cancel") { @@ -3414,6 +4013,29 @@ while (attempted.size < Math.max(1, accountCount)) { await verifyFlaggedAccounts(); continue; } + if (menuResult.mode === "forecast") { + await pickBestAccountFromDashboard(); + continue; + } + if (menuResult.mode === "fix") { + await runAutoRepairFromDashboard(); + continue; + } + if (menuResult.mode === "settings") { + continue; + } + if (menuResult.mode === "experimental-toggle-sync") { + 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") { @@ -3452,6 +4074,20 @@ while (attempted.size < Math.max(1, accountCount)) { startFresh = false; break; } + if (typeof menuResult.switchAccountIndex === "number") { + const target = workingStorage.accounts[menuResult.switchAccountIndex]; + if (target) { + workingStorage.activeIndex = menuResult.switchAccountIndex; + workingStorage.activeIndexByFamily = workingStorage.activeIndexByFamily ?? {}; + for (const family of MODEL_FAMILIES) { + workingStorage.activeIndexByFamily[family] = menuResult.switchAccountIndex; + } + await saveAccounts(workingStorage); + invalidateAccountManagerCache(); + console.log(`\nSet current account: ${target.email ?? `Account ${menuResult.switchAccountIndex + 1}`}.\n`); + } + continue; + } continue; } diff --git a/lib/cli.ts b/lib/cli.ts index 1bd6656f..5aa44af1 100644 --- a/lib/cli.ts +++ b/lib/cli.ts @@ -4,15 +4,13 @@ import type { AccountIdSource } from "./types.js"; import { showAuthMenu, showAccountDetails, + showSettingsMenu, + showSyncPruneMenu, isTTY, type AccountStatus, } from "./ui/auth-menu.js"; +import { UI_COPY } from "./ui/copy.js"; -/** - * Detect if running in OpenCode Desktop/TUI mode where readline prompts don't work. - * In TUI mode, stdin/stdout are controlled by the TUI renderer, so readline breaks. - * Exported for testing purposes. - */ export function isNonInteractiveMode(): boolean { if (process.env.FORCE_INTERACTIVE_MODE === "1") return false; if (!input.isTTY || !output.isTTY) return true; @@ -30,8 +28,8 @@ export async function promptAddAnotherAccount(currentCount: number): Promise 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); + + if (isTTY()) { + return showSyncPruneMenu(neededCount, candidates); + } + + 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; + } + + const invalid = unique.filter((index) => !candidates.some((candidate) => candidate.index === index)); + if (invalid.length > 0) { + console.log("One or more selected account numbers are not valid for removal."); + continue; + } + + return unique; + } + } finally { + rl.close(); + } +} + export type LoginMode = | "add" + | "forecast" + | "fix" + | "settings" + | "experimental-toggle-sync" + | "experimental-sync-now" + | "experimental-cleanup-overlaps" | "fresh" | "manage" | "check" @@ -53,15 +152,20 @@ export interface ExistingAccountInfo { accountLabel?: string; email?: string; index: number; + sourceIndex?: number; + quickSwitchNumber?: number; addedAt?: number; lastUsed?: number; status?: AccountStatus; + quotaSummary?: string; isCurrentAccount?: boolean; enabled?: boolean; } export interface LoginMenuOptions { flaggedCount?: number; + syncFromCodexMultiAuthEnabled?: boolean; + statusMessage?: string | (() => string | undefined); } export interface LoginMenuResult { @@ -69,11 +173,12 @@ export interface LoginMenuResult { deleteAccountIndex?: number; refreshAccountIndex?: number; toggleAccountIndex?: number; + switchAccountIndex?: number; deleteAll?: boolean; } function formatAccountLabel(account: ExistingAccountInfo, index: number): string { - const num = index + 1; + const num = account.quickSwitchNumber ?? (index + 1); const label = account.accountLabel?.trim(); const email = account.email?.trim(); const accountId = account.accountId?.trim(); @@ -85,16 +190,25 @@ function formatAccountLabel(account: ExistingAccountInfo, index: number): string if (email) details.push(email); if (label) details.push(`workspace:${label}`); if (accountIdDisplay) details.push(`id:${accountIdDisplay}`); - if (details.length > 0) { - return `${num}. ${details.join(" | ")}`; + return details.length > 0 ? `${num}. ${details.join(" | ")}` : `${num}. Account`; +} + +function resolveAccountSourceIndex(account: ExistingAccountInfo): number { + const sourceIndex = + typeof account.sourceIndex === "number" && Number.isFinite(account.sourceIndex) + ? Math.max(0, Math.floor(account.sourceIndex)) + : undefined; + if (typeof sourceIndex === "number") return sourceIndex; + if (typeof account.index === "number" && Number.isFinite(account.index)) { + return Math.max(0, Math.floor(account.index)); } - return `${num}. Account`; + return -1; } async function promptDeleteAllTypedConfirm(): Promise { const rl = createInterface({ input, output }); try { - const answer = await rl.question("Type DELETE to confirm removing all accounts: "); + const answer = await rl.question("Type DELETE to remove all saved accounts: "); return answer.trim() === "DELETE"; } finally { rl.close(); @@ -113,15 +227,18 @@ 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(UI_COPY.fallback.selectModePrompt); const normalized = answer.trim().toLowerCase(); if (normalized === "a" || normalized === "add") return { mode: "add" }; + if (normalized === "b" || normalized === "forecast") return { mode: "forecast" }; + if (normalized === "x" || normalized === "fix") return { mode: "fix" }; + if (normalized === "s" || normalized === "settings") return { mode: "settings" }; 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 === "g" || normalized === "verify" || normalized === "problem") return { mode: "verify-flagged" }; if (normalized === "q" || normalized === "quit") return { mode: "cancel" }; - console.log("Please enter one of: a, f, c, d, v, q."); + console.log(UI_COPY.fallback.invalidModePrompt); } } finally { rl.close(); @@ -143,14 +260,26 @@ export async function promptLoginMode( while (true) { const action = await showAuthMenu(existingAccounts, { flaggedCount: options.flaggedCount ?? 0, + statusMessage: options.statusMessage, }); switch (action.type) { case "add": return { mode: "add" }; + case "forecast": + return { mode: "forecast" }; + case "fix": + return { mode: "fix" }; + case "settings": { + const settingsAction = await showSettingsMenu(options.syncFromCodexMultiAuthEnabled === true); + if (settingsAction === "toggle-sync") return { mode: "experimental-toggle-sync" }; + if (settingsAction === "sync-now") return { mode: "experimental-sync-now" }; + if (settingsAction === "cleanup-overlaps") return { mode: "experimental-cleanup-overlaps" }; + continue; + } case "fresh": if (!(await promptDeleteAllTypedConfirm())) { - console.log("\nDelete-all cancelled.\n"); + console.log("\nDelete all cancelled.\n"); continue; } return { mode: "fresh", deleteAll: true }; @@ -160,11 +289,19 @@ export async function promptLoginMode( return { mode: "deep-check" }; case "verify-flagged": return { mode: "verify-flagged" }; + case "set-current-account": { + const index = resolveAccountSourceIndex(action.account); + if (index >= 0) return { mode: "manage", switchAccountIndex: index }; + continue; + } case "select-account": { const accountAction = await showAccountDetails(action.account); if (accountAction === "delete") { return { mode: "manage", deleteAccountIndex: action.account.index }; } + if (accountAction === "set-current") { + return { mode: "manage", switchAccountIndex: action.account.index }; + } if (accountAction === "refresh") { return { mode: "manage", refreshAccountIndex: action.account.index }; } @@ -173,9 +310,11 @@ export async function promptLoginMode( } continue; } + case "search": + continue; case "delete-all": if (!(await promptDeleteAllTypedConfirm())) { - console.log("\nDelete-all cancelled.\n"); + console.log("\nDelete all cancelled.\n"); continue; } return { mode: "fresh", deleteAll: true }; diff --git a/lib/codex-multi-auth-sync.ts b/lib/codex-multi-auth-sync.ts new file mode 100644 index 00000000..9ed7b0ad --- /dev/null +++ b/lib/codex-multi-auth-sync.ts @@ -0,0 +1,546 @@ +import { existsSync, readFileSync, promises as fs } from "node:fs"; +import { homedir, tmpdir } from "node:os"; +import { join, win32 } from "node:path"; +import { ACCOUNT_LIMITS } from "./constants.js"; +import { + deduplicateAccounts, + deduplicateAccountsByEmail, + importAccounts, + normalizeAccountStorage, + previewImportAccounts, + withAccountStorageTransaction, + type AccountStorageV3, + type ImportAccountsResult, +} from "./storage.js"; +import { findProjectRoot, getProjectStorageKey } from "./storage/paths.js"; + +const EXTERNAL_ROOT_SUFFIX = "multi-auth"; +const EXTERNAL_ACCOUNT_FILE_NAMES = [ + "openai-codex-accounts.json", + "codex-accounts.json", +]; + +export interface CodexMultiAuthResolvedSource { + rootDir: string; + accountsPath: string; + scope: "project" | "global"; +} + +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; + dedupedTotal: number; + maxAccounts: number; + needToRemove: number; + importableNewAccounts: number; + skippedOverlaps: number; + suggestedRemovals: Array<{ + index: number; + email?: string; + accountLabel?: string; + isCurrentAccount: boolean; + score: number; + reason: string; + }>; +} + +export class CodexMultiAuthSyncCapacityError extends Error { + readonly details: CodexMultiAuthSyncCapacityDetails; + + constructor(details: CodexMultiAuthSyncCapacityDetails) { + super( + `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.`, + ); + this.name = "CodexMultiAuthSyncCapacityError"; + this.details = details; + } +} + +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, + }; +} + +async function withNormalizedImportFile( + storage: AccountStorageV3, + handler: (filePath: string) => Promise, +): Promise { + const tempPath = join( + tmpdir(), + `oc-chatgpt-multi-auth-sync-${process.pid}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}.json`, + ); + await fs.writeFile(tempPath, `${JSON.stringify(storage, null, 2)}\n`, "utf-8"); + try { + return await handler(tempPath); + } finally { + await fs.unlink(tempPath).catch(() => undefined); + } +} + +function deduplicateAccountsForSync(storage: AccountStorageV3): AccountStorageV3 { + return { + ...storage, + accounts: deduplicateAccountsByEmail(deduplicateAccounts(storage.accounts)), + }; +} + +function buildMergedDedupedAccounts( + currentAccounts: AccountStorageV3["accounts"], + sourceAccounts: AccountStorageV3["accounts"], +): AccountStorageV3["accounts"] { + return deduplicateAccountsForSync({ + version: 3, + accounts: [...currentAccounts, ...sourceAccounts], + activeIndex: 0, + activeIndexByFamily: {}, + }).accounts; +} + +function normalizeIdentity(value: string | undefined): string | undefined { + const trimmed = value?.trim(); + return trimmed && trimmed.length > 0 ? trimmed.toLowerCase() : undefined; +} + +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); + if (organizationId) identities.add(`org:${organizationId}`); + if (accountId) identities.add(`account:${accountId}`); + if (email) identities.add(`email:${email}`); + } + 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); + return ( + (organizationId ? sourceIdentities.has(`org:${organizationId}`) : false) || + (accountId ? sourceIdentities.has(`account:${accountId}`) : false) || + (email ? sourceIdentities.has(`email:${email}`) : 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 hasAccountsStorage(dir: string): boolean { + return EXTERNAL_ACCOUNT_FILE_NAMES.some((fileName) => { + return existsSync(join(dir, fileName)) || existsSync(join(dir, `${fileName}.wal`)); + }); +} + +function getCodexHomeDir(): string { + const fromEnv = (process.env.CODEX_HOME ?? "").trim(); + return fromEnv.length > 0 ? fromEnv : join(getResolvedUserHomeDir(), ".codex"); +} + +export function getCodexMultiAuthSourceRootDir(): string { + const fromEnv = (process.env.CODEX_MULTI_AUTH_DIR ?? "").trim(); + if (fromEnv.length > 0) { + return fromEnv; + } + + const userHome = getResolvedUserHomeDir(); + const primary = join(getCodexHomeDir(), EXTERNAL_ROOT_SUFFIX); + const candidates = deduplicatePaths([ + primary, + join(userHome, "DevTools", "config", "codex", EXTERNAL_ROOT_SUFFIX), + join(userHome, ".codex", EXTERNAL_ROOT_SUFFIX), + ]); + + for (const candidate of candidates) { + if (hasAccountsStorage(candidate)) { + return candidate; + } + } + + for (const candidate of candidates) { + if (hasStorageSignals(candidate)) { + return candidate; + } + } + + return primary; +} + +function getProjectScopedAccountsPath(rootDir: string, projectPath: string): string | undefined { + const projectRoot = findProjectRoot(projectPath); + if (!projectRoot) { + return undefined; + } + + const projectKey = getProjectStorageKey(projectRoot); + for (const fileName of EXTERNAL_ACCOUNT_FILE_NAMES) { + const candidate = join(rootDir, "projects", projectKey, 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 rootDir = getCodexMultiAuthSourceRootDir(); + 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", + }; + } + + throw new Error( + `No codex-multi-auth accounts file found under ${rootDir}`, + ); +} + +export function loadCodexMultiAuthSourceStorage( + projectPath = process.cwd(), +): CodexMultiAuthResolvedSource & { storage: AccountStorageV3 } { + const resolved = resolveCodexMultiAuthAccountsSource(projectPath); + const raw = readFileSync(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), + }; +} + +export async function previewSyncFromCodexMultiAuth( + projectPath = process.cwd(), +): Promise { + const resolved = loadCodexMultiAuthSourceStorage(projectPath); + await assertSyncWithinCapacity(resolved); + const preview = await withNormalizedImportFile( + resolved.storage, + (filePath) => previewImportAccounts(filePath), + ); + return { + rootDir: resolved.rootDir, + accountsPath: resolved.accountsPath, + scope: resolved.scope, + ...preview, + }; +} + +export async function syncFromCodexMultiAuth( + projectPath = process.cwd(), +): Promise { + const resolved = loadCodexMultiAuthSourceStorage(projectPath); + await assertSyncWithinCapacity(resolved); + const preview = await withNormalizedImportFile( + resolved.storage, + (filePath) => previewImportAccounts(filePath), + ); + const result = await withNormalizedImportFile( + resolved.storage, + (filePath) => importAccounts(filePath, { + preImportBackupPrefix: "codex-multi-auth-sync-backup", + backupMode: "required", + }), + ); + return { + ...preview, + 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, + }; +} + +export async function cleanupCodexMultiAuthSyncedOverlaps(): Promise { + return withAccountStorageTransaction(async (current, persist) => { + const existing = current ?? { + version: 3 as const, + accounts: [], + activeIndex: 0, + activeIndexByFamily: {}, + }; + const before = existing.accounts.length; + const normalized = normalizeAccountStorage(normalizeSourceStorage(existing)); + if (!normalized) { + return { + before, + after: before, + removed: 0, + updated: 0, + }; + } + + const after = normalized.accounts.length; + const removed = Math.max(0, before - after); + const updated = normalized.accounts.reduce((count, account) => { + return account.accountIdSource === "org" && account.organizationId ? count + 1 : count; + }, 0); + + if (removed > 0 || after !== before || JSON.stringify(normalized) !== JSON.stringify(existing)) { + await persist(normalized); + } + + return { + before, + after, + removed, + updated, + }; + }); +} + +async function assertSyncWithinCapacity( + resolved: CodexMultiAuthResolvedSource & { storage: AccountStorageV3 }, +): Promise { + const details = await withAccountStorageTransaction((current) => { + const existing = current ?? { + version: 3 as const, + accounts: [], + activeIndex: 0, + activeIndexByFamily: {}, + }; + const mergedAccounts = buildMergedDedupedAccounts(existing.accounts, resolved.storage.accounts); + if (mergedAccounts.length <= ACCOUNT_LIMITS.MAX_ACCOUNTS) { + return Promise.resolve(null); + } + + const currentCount = existing.accounts.length; + const sourceCount = resolved.storage.accounts.length; + const dedupedTotal = mergedAccounts.length; + const importableNewAccounts = Math.max(0, dedupedTotal - currentCount); + const skippedOverlaps = Math.max(0, sourceCount - importableNewAccounts); + const sourceIdentities = buildSourceIdentitySet(resolved.storage); + 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, resolved.storage.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 - ACCOUNT_LIMITS.MAX_ACCOUNTS)) + .map(({ index, email, accountLabel, isCurrentAccount, score, reason }) => ({ + index, + email, + accountLabel, + isCurrentAccount, + score, + reason, + })); + + return Promise.resolve({ + rootDir: resolved.rootDir, + accountsPath: resolved.accountsPath, + scope: resolved.scope, + currentCount, + sourceCount, + dedupedTotal, + maxAccounts: ACCOUNT_LIMITS.MAX_ACCOUNTS, + needToRemove: dedupedTotal - ACCOUNT_LIMITS.MAX_ACCOUNTS, + importableNewAccounts, + skippedOverlaps, + suggestedRemovals, + } satisfies CodexMultiAuthSyncCapacityDetails); + }); + + if (details) { + throw new CodexMultiAuthSyncCapacityError(details); + } +} diff --git a/lib/config.ts b/lib/config.ts index af93ee73..e6c7476a 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 { readFileSync, existsSync, mkdirSync, writeFileSync } from "node:fs"; +import { dirname, join } from "node:path"; import { homedir } from "node:os"; import type { PluginConfig } from "./types.js"; import { @@ -19,6 +19,8 @@ const RETRY_PROFILES = new Set(["conservative", "balanced", "aggressive"]); export type UnsupportedCodexPolicy = "strict" | "fallback"; +type RawPluginConfig = Record; + /** * Default plugin configuration * CODEX_MODE is enabled by default for better Codex CLI parity @@ -106,6 +108,34 @@ export function loadPluginConfig(): PluginConfig { } } +function readRawPluginConfig(): RawPluginConfig { + if (!existsSync(CONFIG_PATH)) { + return {}; + } + + const fileContent = readFileSync(CONFIG_PATH, "utf-8"); + const normalizedFileContent = stripUtf8Bom(fileContent); + const parsed = JSON.parse(normalizedFileContent) as unknown; + if (!isRecord(parsed)) { + throw new Error("Plugin config root must be a JSON object"); + } + return { ...parsed }; +} + +export function savePluginConfigMutation( + mutate: (current: RawPluginConfig) => RawPluginConfig, +): void { + const current = readRawPluginConfig(); + const next = mutate({ ...current }); + + if (!isRecord(next)) { + throw new Error("Plugin config mutation must return a JSON object"); + } + + mkdirSync(dirname(CONFIG_PATH), { recursive: true }); + writeFileSync(CONFIG_PATH, `${JSON.stringify(next, null, 2)}\n`, "utf-8"); +} + function stripUtf8Bom(content: string): string { return content.charCodeAt(0) === 0xfeff ? content.slice(1) : content; } @@ -198,6 +228,24 @@ export function getCodexTuiV2(pluginConfig: PluginConfig): boolean { return resolveBooleanSetting("CODEX_TUI_V2", pluginConfig.codexTuiV2, true); } +export function getSyncFromCodexMultiAuthEnabled(pluginConfig: PluginConfig): boolean { + return pluginConfig.experimental?.syncFromCodexMultiAuth?.enabled === true; +} + +export function setSyncFromCodexMultiAuthEnabled(enabled: boolean): void { + savePluginConfigMutation((current) => { + const experimental = isRecord(current.experimental) ? { ...current.experimental } : {}; + const syncSettings = isRecord(experimental.syncFromCodexMultiAuth) + ? { ...experimental.syncFromCodexMultiAuth } + : {}; + + syncSettings.enabled = enabled; + experimental.syncFromCodexMultiAuth = syncSettings; + current.experimental = experimental; + return current; + }); +} + export function getCodexTuiColorProfile( pluginConfig: PluginConfig, ): "truecolor" | "ansi16" | "ansi256" { diff --git a/lib/constants.ts b/lib/constants.ts index 69e7f5bf..ebb5ad84 100644 --- a/lib/constants.ts +++ b/lib/constants.ts @@ -74,8 +74,8 @@ export const PLATFORM_OPENERS = { /** OAuth authorization labels */ export const AUTH_LABELS = { - OAUTH: "ChatGPT Plus/Pro MULTI (Codex Subscription)", - OAUTH_MANUAL: "ChatGPT Plus/Pro MULTI (Manual URL Paste)", + OAUTH: "ChatGPT Plus/Pro (Browser Login)", + OAUTH_MANUAL: "ChatGPT Plus/Pro (Manual Paste)", API_KEY: "Manually enter API Key MULTI", INSTRUCTIONS: "A browser window should open. If it doesn't, copy the URL and open it manually.", diff --git a/lib/schemas.ts b/lib/schemas.ts index 6028246d..9f93401c 100644 --- a/lib/schemas.ts +++ b/lib/schemas.ts @@ -13,6 +13,11 @@ import { MODEL_FAMILIES, type ModelFamily } from "./prompts/codex.js"; export const PluginConfigSchema = z.object({ codexMode: z.boolean().optional(), requestTransformMode: z.enum(["native", "legacy"]).optional(), + experimental: z.object({ + syncFromCodexMultiAuth: z.object({ + enabled: z.boolean().optional(), + }).optional(), + }).optional(), codexTuiV2: z.boolean().optional(), codexTuiColorProfile: z.enum(["truecolor", "ansi16", "ansi256"]).optional(), codexTuiGlyphMode: z.enum(["ascii", "unicode", "auto"]).optional(), diff --git a/lib/ui/ansi.ts b/lib/ui/ansi.ts index 4d804c3c..3fe73abf 100644 --- a/lib/ui/ansi.ts +++ b/lib/ui/ansi.ts @@ -6,28 +6,62 @@ export const ANSI = { // Cursor control hide: "\x1b[?25l", show: "\x1b[?25h", + altScreenOn: "\x1b[?1049h", + altScreenOff: "\x1b[?1049l", up: (lines = 1) => `\x1b[${lines}A`, clearLine: "\x1b[2K", clearScreen: "\x1b[2J", moveTo: (row: number, col: number) => `\x1b[${row};${col}H`, // Styling + black: "\x1b[30m", + white: "\x1b[97m", cyan: "\x1b[36m", green: "\x1b[32m", red: "\x1b[31m", yellow: "\x1b[33m", + bgBlue: "\x1b[44m", + bgBrightBlue: "\x1b[104m", + bgGreen: "\x1b[42m", + bgYellow: "\x1b[43m", + bgRed: "\x1b[41m", + inverse: "\x1b[7m", dim: "\x1b[2m", bold: "\x1b[1m", reset: "\x1b[0m", } as const; -export type KeyAction = "up" | "down" | "enter" | "escape" | "escape-start" | null; +export type KeyAction = + | "up" + | "down" + | "home" + | "end" + | "enter" + | "escape" + | "escape-start" + | null; export function parseKey(data: Buffer): KeyAction { const input = data.toString(); if (input === "\x1b[A" || input === "\x1bOA") return "up"; if (input === "\x1b[B" || input === "\x1bOB") return "down"; + if ( + input === "\x1b[H" || + input === "\x1bOH" || + input === "\x1b[1~" || + input === "\x1b[7~" + ) { + return "home"; + } + if ( + input === "\x1b[F" || + input === "\x1bOF" || + input === "\x1b[4~" || + input === "\x1b[8~" + ) { + return "end"; + } if (input === "\r" || input === "\n") return "enter"; if (input === "\x03") return "escape"; if (input === "\x1b") return "escape-start"; diff --git a/lib/ui/auth-menu.ts b/lib/ui/auth-menu.ts index 12007a4e..a263a40e 100644 --- a/lib/ui/auth-menu.ts +++ b/lib/ui/auth-menu.ts @@ -1,8 +1,11 @@ +import { createInterface } from "node:readline/promises"; +import { stdin as input, stdout as output } from "node:process"; import { ANSI, isTTY } from "./ansi.js"; import { confirm } from "./confirm.js"; import { getUiRuntimeOptions } from "./runtime.js"; import { select, type MenuItem } from "./select.js"; import { paintUiText, formatUiBadge } from "./format.js"; +import { UI_COPY, formatCheckFlaggedLabel } from "./copy.js"; export type AccountStatus = | "active" @@ -16,31 +19,59 @@ export type AccountStatus = export interface AccountInfo { index: number; + sourceIndex?: number; + quickSwitchNumber?: number; accountId?: string; accountLabel?: string; email?: string; addedAt?: number; lastUsed?: number; status?: AccountStatus; + quotaSummary?: string; isCurrentAccount?: boolean; enabled?: boolean; } export interface AuthMenuOptions { flaggedCount?: number; + statusMessage?: string | (() => string | undefined); } export type AuthMenuAction = | { type: "add" } + | { type: "forecast" } + | { type: "fix" } + | { type: "settings" } | { type: "fresh" } | { type: "check" } | { type: "deep-check" } | { type: "verify-flagged" } | { type: "select-account"; account: AccountInfo } + | { type: "set-current-account"; account: AccountInfo } + | { type: "search" } | { type: "delete-all" } | { type: "cancel" }; -export type AccountAction = "back" | "delete" | "refresh" | "toggle" | "cancel"; +export type AccountAction = "back" | "delete" | "refresh" | "toggle" | "set-current" | "cancel"; +export type SettingsAction = "toggle-sync" | "sync-now" | "cleanup-overlaps" | "back" | "cancel"; +export interface SyncPruneCandidate { + index: number; + email?: string; + accountLabel?: string; + isCurrentAccount?: boolean; + score?: number; + reason?: string; +} + +type SyncPruneAction = + | { type: "toggle"; candidate: SyncPruneCandidate } + | { type: "confirm" } + | { type: "cancel" }; + +function sanitizeTerminalText(value: string | undefined): string | undefined { + if (!value) return undefined; + return value.replace(/\x1b\[[0-?]*[ -/]*[@-~]/g, "").replace(/[\u0000-\u001f\u007f]/g, "").trim(); +} function formatRelativeTime(timestamp: number | undefined): string { if (!timestamp) return "never"; @@ -59,166 +90,345 @@ function formatDate(timestamp: number | undefined): string { function statusBadge(status: AccountStatus | undefined): string { const ui = getUiRuntimeOptions(); - if (ui.v2Enabled) { - switch (status) { - case "active": - return formatUiBadge(ui, "active", "success"); - case "ok": - return formatUiBadge(ui, "ok", "success"); - case "rate-limited": - return formatUiBadge(ui, "rate-limited", "warning"); - case "cooldown": - return formatUiBadge(ui, "cooldown", "warning"); - case "flagged": - return formatUiBadge(ui, "flagged", "danger"); - case "disabled": - return formatUiBadge(ui, "disabled", "danger"); - case "error": - return formatUiBadge(ui, "error", "danger"); + const withTone = ( + label: string, + tone: "accent" | "success" | "warning" | "danger" | "muted", + ): string => { + if (ui.v2Enabled) return formatUiBadge(ui, label, tone); + switch (tone) { + case "success": + return `${ANSI.green}[${label}]${ANSI.reset}`; + case "warning": + return `${ANSI.yellow}[${label}]${ANSI.reset}`; + case "danger": + return `${ANSI.red}[${label}]${ANSI.reset}`; + case "accent": + return `${ANSI.cyan}[${label}]${ANSI.reset}`; default: - return ""; + return `${ANSI.dim}[${label}]${ANSI.reset}`; } - } + }; switch (status) { case "active": - return `${ANSI.green}[active]${ANSI.reset}`; + return withTone("active", "success"); case "ok": - return `${ANSI.green}[ok]${ANSI.reset}`; + return withTone("ok", "success"); case "rate-limited": - return `${ANSI.yellow}[rate-limited]${ANSI.reset}`; + return withTone("rate-limited", "warning"); case "cooldown": - return `${ANSI.yellow}[cooldown]${ANSI.reset}`; + return withTone("cooldown", "warning"); case "flagged": - return `${ANSI.red}[flagged]${ANSI.reset}`; + return withTone("flagged", "danger"); case "disabled": - return `${ANSI.red}[disabled]${ANSI.reset}`; + return withTone("disabled", "danger"); case "error": - return `${ANSI.red}[error]${ANSI.reset}`; + return withTone("error", "danger"); default: - return ""; + return withTone("unknown", "muted"); } } function formatAccountIdSuffix(accountId: string | undefined): string | undefined { const trimmed = accountId?.trim(); if (!trimmed) return undefined; - return trimmed.length > 14 - ? `${trimmed.slice(0, 8)}...${trimmed.slice(-6)}` - : trimmed; + return trimmed.length > 14 ? `${trimmed.slice(0, 8)}...${trimmed.slice(-6)}` : trimmed; } function accountTitle(account: AccountInfo): string { - const email = account.email?.trim(); - const label = account.accountLabel?.trim(); + const number = account.quickSwitchNumber ?? (account.index + 1); + const email = sanitizeTerminalText(account.email); + const label = sanitizeTerminalText(account.accountLabel); const accountIdSuffix = formatAccountIdSuffix(account.accountId); - const details: string[] = []; if (email) details.push(email); - if (label) details.push(`workspace:${label}`); + if (label) details.push(label.startsWith("workspace:") ? label : `workspace:${label}`); if (accountIdSuffix && (!label || !label.includes(accountIdSuffix))) { details.push(`id:${accountIdSuffix}`); } + return details.length > 0 ? `${number}. ${details.join(" | ")}` : `${number}. Account`; +} + +function accountSearchText(account: AccountInfo): string { + return [ + sanitizeTerminalText(account.email), + sanitizeTerminalText(account.accountLabel), + sanitizeTerminalText(account.accountId), + String(account.quickSwitchNumber ?? (account.index + 1)), + ] + .filter((value): value is string => typeof value === "string" && value.length > 0) + .join(" ") + .toLowerCase(); +} + +function accountRowColor(account: AccountInfo): MenuItem["color"] { + if (account.isCurrentAccount) return "green"; + switch (account.status) { + case "active": + case "ok": + return "green"; + case "rate-limited": + case "cooldown": + return "yellow"; + case "disabled": + case "error": + case "flagged": + return "red"; + default: + return undefined; + } +} - if (details.length === 0) { - return `${account.index + 1}. Account`; +function formatAccountHint(account: AccountInfo, ui = getUiRuntimeOptions()): string { + const parts: string[] = []; + parts.push(ui.v2Enabled ? paintUiText(ui, `used ${formatRelativeTime(account.lastUsed)}`, "muted") : `used ${formatRelativeTime(account.lastUsed)}`); + if (account.quotaSummary?.trim()) { + parts.push(ui.v2Enabled ? paintUiText(ui, account.quotaSummary, "muted") : account.quotaSummary); + } + return parts.join(ui.v2Enabled ? ` ${paintUiText(ui, "|", "muted")} ` : " | "); +} + +async function promptSearchQuery(current: string): Promise { + if (!input.isTTY || !output.isTTY) { + return current; + } + const rl = createInterface({ input, output }); + try { + const suffix = current ? ` (${current})` : ""; + const answer = await rl.question(`Search${suffix} (blank clears): `); + return answer.trim().toLowerCase(); + } finally { + rl.close(); + } +} + +function authMenuFocusKey(action: AuthMenuAction): string { + switch (action.type) { + case "select-account": + case "set-current-account": + return `account:${action.account.sourceIndex ?? action.account.index}`; + default: + return `action:${action.type}`; } - return `${account.index + 1}. ${details.join(" | ")}`; } export async function showAuthMenu( accounts: AccountInfo[], options: AuthMenuOptions = {}, ): Promise { - const ui = getUiRuntimeOptions(); const flaggedCount = options.flaggedCount ?? 0; - const verifyLabel = - flaggedCount > 0 - ? `Verify flagged accounts (${flaggedCount})` - : "Verify flagged accounts"; - - const items: MenuItem[] = [ - { label: "Actions", value: { type: "cancel" }, kind: "heading" }, - { label: "Add account", value: { type: "add" }, color: "cyan" }, - { 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: "Start fresh", value: { type: "fresh" }, color: "yellow" }, - { label: "", value: { type: "cancel" }, separator: true }, - { label: "Accounts", value: { type: "cancel" }, kind: "heading" }, - ...accounts.map((account) => { - const currentBadge = account.isCurrentAccount - ? (ui.v2Enabled ? ` ${formatUiBadge(ui, "current", "accent")}` : ` ${ANSI.cyan}[current]${ANSI.reset}`) - : ""; - const badge = statusBadge(account.status); - const disabledBadge = - account.enabled === false - ? (ui.v2Enabled ? ` ${formatUiBadge(ui, "disabled", "danger")}` : ` ${ANSI.red}[disabled]${ANSI.reset}`) - : ""; - const statusSuffix = badge ? ` ${badge}` : ""; - const label = `${accountTitle(account)}${currentBadge}${statusSuffix}${disabledBadge}`; - return { - label: ui.v2Enabled ? paintUiText(ui, label, "heading") : label, - hint: `used ${formatRelativeTime(account.lastUsed)}`, - value: { type: "select-account" as const, account }, - }; - }), - { label: "", value: { type: "cancel" }, separator: true }, - { label: "Danger zone", value: { type: "cancel" }, kind: "heading" }, - { label: "Delete all accounts", value: { type: "delete-all" }, color: "red" }, - ]; + const verifyLabel = formatCheckFlaggedLabel(flaggedCount); + const ui = getUiRuntimeOptions(); + let showDetailedHelp = false; + let searchQuery = ""; + let focusKey = "action:add"; while (true) { + const normalizedSearch = searchQuery.trim().toLowerCase(); + const visibleAccounts = normalizedSearch.length > 0 + ? accounts.filter((account) => accountSearchText(account).includes(normalizedSearch)) + : accounts; + const visibleByNumber = new Map(); + const duplicateQuickSwitchNumbers = new Set(); + for (const account of visibleAccounts) { + const quickSwitchNumber = account.quickSwitchNumber ?? (account.index + 1); + if (visibleByNumber.has(quickSwitchNumber)) { + duplicateQuickSwitchNumbers.add(quickSwitchNumber); + continue; + } + visibleByNumber.set(quickSwitchNumber, account); + } + + const items: MenuItem[] = [ + { label: UI_COPY.mainMenu.quickStart, value: { type: "cancel" }, kind: "heading" }, + { label: UI_COPY.mainMenu.addAccount, value: { type: "add" }, color: "green" }, + { label: UI_COPY.mainMenu.checkAccounts, value: { type: "check" }, color: "green" }, + { label: UI_COPY.mainMenu.bestAccount, value: { type: "forecast" }, color: "green" }, + { label: UI_COPY.mainMenu.fixIssues, value: { type: "fix" }, color: "green" }, + { label: UI_COPY.mainMenu.settings, value: { type: "settings" }, color: "green" }, + { label: "", value: { type: "cancel" }, separator: true }, + { label: UI_COPY.mainMenu.moreChecks, value: { type: "cancel" }, kind: "heading" }, + { label: UI_COPY.mainMenu.refreshChecks, value: { type: "deep-check" }, color: "green" }, + { label: verifyLabel, value: { type: "verify-flagged" }, color: flaggedCount > 0 ? "red" : "yellow" }, + { label: "", value: { type: "cancel" }, separator: true }, + { label: UI_COPY.mainMenu.accounts, value: { type: "cancel" }, kind: "heading" }, + ]; + + if (visibleAccounts.length === 0) { + items.push({ label: UI_COPY.mainMenu.noSearchMatches, value: { type: "cancel" }, disabled: true }); + } else { + items.push( + ...visibleAccounts.map((account) => { + const currentBadge = account.isCurrentAccount + ? (ui.v2Enabled ? ` ${formatUiBadge(ui, "current", "accent")}` : ` ${ANSI.cyan}[current]${ANSI.reset}`) + : ""; + const badge = statusBadge(account.status); + const title = ui.v2Enabled + ? paintUiText(ui, accountTitle(account), account.isCurrentAccount ? "accent" : "heading") + : accountTitle(account); + return { + label: `${title}${currentBadge} ${badge}`.trim(), + hint: formatAccountHint(account, ui), + selectedLabel: `${accountTitle(account)}${currentBadge} ${badge}`.trim(), + color: accountRowColor(account), + value: { type: "select-account" as const, account }, + }; + }), + ); + } + + items.push({ label: "", value: { type: "cancel" }, separator: true }); + items.push({ label: UI_COPY.mainMenu.dangerZone, value: { type: "cancel" }, kind: "heading" }); + items.push({ label: UI_COPY.mainMenu.removeAllAccounts, value: { type: "delete-all" }, color: "red" }); + + const buildSubtitle = (): string | undefined => { + const parts: string[] = []; + if (normalizedSearch.length > 0) { + parts.push(`${UI_COPY.mainMenu.searchSubtitlePrefix} ${normalizedSearch}`); + } + const statusText = typeof options.statusMessage === "function" ? options.statusMessage() : options.statusMessage; + if (typeof statusText === "string" && statusText.trim().length > 0) { + parts.push(statusText.trim()); + } + return parts.length > 0 ? parts.join(" | ") : undefined; + }; + + const initialCursor = items.findIndex((item) => { + if (item.separator || item.disabled || item.kind === "heading") return false; + return authMenuFocusKey(item.value) === focusKey; + }); + const result = await select(items, { - message: ui.v2Enabled ? "OpenAI accounts (Codex)" : "Codex accounts", - subtitle: "Select action or account", + message: UI_COPY.mainMenu.title, + subtitle: buildSubtitle(), + dynamicSubtitle: buildSubtitle, + help: showDetailedHelp ? UI_COPY.mainMenu.helpDetailed : UI_COPY.mainMenu.helpCompact, clearScreen: true, - variant: ui.v2Enabled ? "codex" : "legacy", + selectedEmphasis: "minimal", + focusStyle: "row-invert", + showHintsForUnselected: false, + refreshIntervalMs: 200, + initialCursor: initialCursor >= 0 ? initialCursor : undefined, theme: ui.theme, + onInput: (input, context) => { + const lower = input.toLowerCase(); + if (lower === "?") { + showDetailedHelp = !showDetailedHelp; + context.requestRerender(); + return undefined; + } + if (lower === "q") return { type: "cancel" as const }; + if (lower === "/") return { type: "search" as const }; + const parsed = Number.parseInt(input, 10); + if (Number.isFinite(parsed) && parsed >= 1 && parsed <= 9) { + if (duplicateQuickSwitchNumbers.has(parsed)) return undefined; + const direct = visibleByNumber.get(parsed); + if (direct) { + return { type: "set-current-account" as const, account: direct }; + } + } + return undefined; + }, + onCursorChange: ({ cursor }) => { + const selected = items[cursor]; + if (!selected || selected.separator || selected.disabled || selected.kind === "heading") return; + focusKey = authMenuFocusKey(selected.value); + }, }); if (!result) return { type: "cancel" }; + if (result.type === "search") { + searchQuery = await promptSearchQuery(searchQuery); + focusKey = "action:search"; + continue; + } if (result.type === "delete-all") { const confirmed = await confirm("Delete all accounts?"); if (!confirmed) continue; } + focusKey = authMenuFocusKey(result); return result; } } +export async function showSettingsMenu( + syncFromCodexMultiAuthEnabled: boolean, +): Promise { + const ui = getUiRuntimeOptions(); + const syncBadge = syncFromCodexMultiAuthEnabled + ? formatUiBadge(ui, "enabled", "success") + : formatUiBadge(ui, "disabled", "danger"); + const syncLabel = ui.v2Enabled + ? `${UI_COPY.settings.syncToggle} ${syncBadge}` + : `${UI_COPY.settings.syncToggle} ${syncFromCodexMultiAuthEnabled ? `${ANSI.green}[enabled]${ANSI.reset}` : `${ANSI.red}[disabled]${ANSI.reset}`}`; + + const action = await select( + [ + { label: UI_COPY.settings.back, value: "back" }, + { label: syncLabel, value: "toggle-sync", color: syncFromCodexMultiAuthEnabled ? "green" : "yellow" }, + { label: UI_COPY.settings.syncNow, value: "sync-now", color: "cyan" }, + { label: UI_COPY.settings.cleanupOverlaps, value: "cleanup-overlaps", color: "yellow" }, + ], + { + message: UI_COPY.settings.title, + subtitle: UI_COPY.settings.subtitle, + help: UI_COPY.settings.help, + clearScreen: true, + selectedEmphasis: "minimal", + focusStyle: "row-invert", + theme: ui.theme, + }, + ); + + return action ?? "cancel"; +} + export async function showAccountDetails(account: AccountInfo): Promise { const ui = getUiRuntimeOptions(); - const header = - `${accountTitle(account)} ${statusBadge(account.status)}` + - (account.enabled === false - ? (ui.v2Enabled - ? ` ${formatUiBadge(ui, "disabled", "danger")}` - : ` ${ANSI.red}[disabled]${ANSI.reset}`) - : ""); - const subtitle = `Added: ${formatDate(account.addedAt)} | Last used: ${formatRelativeTime(account.lastUsed)}`; + const header = `${accountTitle(account)} ${statusBadge(account.status)}`; + const subtitle = `Added: ${formatDate(account.addedAt)} | Used: ${formatRelativeTime(account.lastUsed)} | Status: ${account.status ?? "unknown"}`; + let focusAction: AccountAction = "back"; while (true) { - const action = await select( - [ - { label: "Back", value: "back" }, - { - label: account.enabled === false ? "Enable account" : "Disable account", - value: "toggle", - color: account.enabled === false ? "green" : "yellow", - }, - { label: "Refresh account", value: "refresh", color: "cyan" }, - { label: "Delete this account", value: "delete", color: "red" }, - ], + const items: MenuItem[] = [ + { label: UI_COPY.accountDetails.back, value: "back" }, { - message: header, - subtitle, - clearScreen: true, - variant: ui.v2Enabled ? "codex" : "legacy", - theme: ui.theme, + label: account.enabled === false ? UI_COPY.accountDetails.enable : UI_COPY.accountDetails.disable, + value: "toggle", + color: account.enabled === false ? "green" : "yellow", + }, + { label: UI_COPY.accountDetails.setCurrent, value: "set-current", color: "green" }, + { label: UI_COPY.accountDetails.refresh, value: "refresh", color: "green" }, + { label: UI_COPY.accountDetails.remove, value: "delete", color: "red" }, + ]; + const initialCursor = items.findIndex((item) => item.value === focusAction); + const action = await select(items, { + message: header, + subtitle, + help: UI_COPY.accountDetails.help, + clearScreen: true, + selectedEmphasis: "minimal", + focusStyle: "row-invert", + initialCursor: initialCursor >= 0 ? initialCursor : undefined, + theme: ui.theme, + onInput: (input) => { + const lower = input.toLowerCase(); + if (lower === "q") return "cancel"; + if (lower === "s") return "set-current"; + if (lower === "r") return "refresh"; + if (lower === "d") return "delete"; + if (lower === "e" || lower === "t" || lower === "x") return "toggle"; + return undefined; + }, + onCursorChange: ({ cursor }) => { + const selected = items[cursor]; + if (!selected || selected.separator || selected.disabled || selected.kind === "heading") return; + focusAction = selected.value; }, - ); + }); if (!action) return "cancel"; + focusAction = action; if (action === "delete") { const confirmed = await confirm(`Delete ${accountTitle(account)}?`); if (!confirmed) continue; @@ -231,5 +441,125 @@ export async function showAccountDetails(account: AccountInfo): Promise { + const ui = getUiRuntimeOptions(); + const selected = new Set(); + for (const candidate of candidates) { + if (candidate.isCurrentAccount !== true && selected.size < neededCount) { + selected.add(candidate.index); + } + } + let focusKey = candidates[0] ? `candidate:${candidates[0].index}` : "confirm"; + + while (true) { + const items: MenuItem[] = candidates.map((candidate) => { + const isSelected = selected.has(candidate.index); + const selectionBadge = isSelected + ? ui.v2Enabled + ? formatUiBadge(ui, UI_COPY.syncPrune.selected, "warning") + : `${ANSI.yellow}[${UI_COPY.syncPrune.selected}]${ANSI.reset}` + : ""; + return { + label: `${syncPruneTitle(candidate)} ${selectionBadge}`.trim(), + selectedLabel: `${syncPruneTitle(candidate)} ${selectionBadge}`.trim(), + hint: syncPruneHint(candidate), + color: isSelected ? "yellow" : candidate.isCurrentAccount ? "cyan" : "green", + value: { type: "toggle", candidate }, + }; + }); + + items.push({ label: "", value: { type: "cancel" }, separator: true }); + items.push({ + label: `${UI_COPY.syncPrune.confirm}${selected.size >= neededCount ? "" : ` (${selected.size}/${neededCount})`}`, + value: { type: "confirm" }, + color: selected.size >= neededCount ? "green" : "yellow", + }); + items.push({ label: UI_COPY.syncPrune.cancel, value: { type: "cancel" }, color: "red" }); + + const initialCursor = items.findIndex((item) => { + if (item.separator || item.disabled || item.kind === "heading") return false; + if (item.value.type === "toggle") return focusKey === `candidate:${item.value.candidate.index}`; + return focusKey === item.value.type; + }); + + const action = await select(items, { + message: UI_COPY.syncPrune.title, + subtitle: `${UI_COPY.syncPrune.subtitle(neededCount)} | Selected ${selected.size}`, + help: UI_COPY.syncPrune.help, + clearScreen: true, + selectedEmphasis: "minimal", + focusStyle: "row-invert", + initialCursor: initialCursor >= 0 ? initialCursor : undefined, + theme: ui.theme, + onInput: (input, context) => { + const lower = input.toLowerCase(); + if (lower === "q") return { type: "cancel" as const }; + if (lower === "c") return { type: "confirm" as const }; + if (input === " ") { + const current = items[context.cursor]; + if (current?.value.type === "toggle") { + const candidate = current.value.candidate; + if (selected.has(candidate.index)) selected.delete(candidate.index); + else selected.add(candidate.index); + context.requestRerender(); + } + return undefined; + } + return undefined; + }, + onCursorChange: ({ cursor }) => { + const current = items[cursor]; + if (!current || current.separator || current.disabled || current.kind === "heading") return; + if (current.value.type === "toggle") focusKey = `candidate:${current.value.candidate.index}`; + else focusKey = current.value.type; + }, + }); + + if (!action || action.type === "cancel") { + return null; + } + if (action.type === "toggle") { + if (selected.has(action.candidate.index)) selected.delete(action.candidate.index); + else selected.add(action.candidate.index); + focusKey = `candidate:${action.candidate.index}`; + continue; + } + if (action.type === "confirm") { + if (selected.size < neededCount) { + continue; + } + return Array.from(selected); + } + } +} +export { isTTY }; diff --git a/lib/ui/copy.ts b/lib/ui/copy.ts new file mode 100644 index 00000000..22e3f8d8 --- /dev/null +++ b/lib/ui/copy.ts @@ -0,0 +1,61 @@ +export const UI_COPY = { + mainMenu: { + title: "Accounts Dashboard", + searchSubtitlePrefix: "Search:", + quickStart: "Quick Actions", + addAccount: "Add New Account", + checkAccounts: "Run Health Check", + bestAccount: "Pick Best Account", + fixIssues: "Auto-Repair Issues", + settings: "Settings", + moreChecks: "Advanced Checks", + refreshChecks: "Refresh All Accounts", + checkFlagged: "Check Problem Accounts", + accounts: "Saved Accounts", + noSearchMatches: "No accounts match your search", + dangerZone: "Danger Zone", + removeAllAccounts: "Delete All Accounts", + helpCompact: "↑↓ Move | Enter Select | / Search | 1-9 Switch | Q Back", + helpDetailed: "Arrow keys move, Enter selects, / searches, 1-9 switches account, Q goes back", + }, + accountDetails: { + back: "Back", + enable: "Enable Account", + disable: "Disable Account", + setCurrent: "Set As Current", + refresh: "Re-Login", + remove: "Delete Account", + help: "↑↓ Move | Enter Select | S Use | R Sign In | D Delete | Q Back", + }, + settings: { + title: "Settings", + subtitle: "Manage sync and cleanup options", + help: "↑↓ Move | Enter Select | Q Back", + syncToggle: "Sync from codex-multi-auth", + syncNow: "Sync Now", + cleanupOverlaps: "Cleanup Synced Overlaps", + back: "Back", + }, + syncPrune: { + title: "Prepare Sync", + subtitle: (neededCount: number) => `Select ${neededCount} account(s) to remove before syncing`, + help: "↑↓ Move | Enter Toggle | Space Toggle | C Continue | Q Cancel", + selected: "selected", + current: "current", + confirm: "Continue With Selected Accounts", + cancel: "Cancel", + }, + fallback: { + addAnotherTip: "Tip: Use private mode or sign out before adding another account.", + addAnotherQuestion: (count: number) => `Add another account? (${count} added) (y/n): `, + selectModePrompt: + "(a) add, (c) check, (b) best, fi(x), (s) settings, (d) deep, (g) problem, (f) fresh, (q) back [a/c/b/x/s/d/g/f/q]: ", + invalidModePrompt: "Use one of: a, c, b, x, s, d, g, f, q.", + }, +} as const; + +export function formatCheckFlaggedLabel(flaggedCount: number): string { + return flaggedCount > 0 + ? `${UI_COPY.mainMenu.checkFlagged} (${flaggedCount})` + : UI_COPY.mainMenu.checkFlagged; +} diff --git a/lib/ui/runtime.ts b/lib/ui/runtime.ts index abaf6270..9adef31d 100644 --- a/lib/ui/runtime.ts +++ b/lib/ui/runtime.ts @@ -2,6 +2,8 @@ import { createUiTheme, type UiColorProfile, type UiGlyphMode, + type UiPalette, + type UiAccent, type UiTheme, } from "./theme.js"; @@ -9,6 +11,8 @@ export interface UiRuntimeOptions { v2Enabled: boolean; colorProfile: UiColorProfile; glyphMode: UiGlyphMode; + palette: UiPalette; + accent: UiAccent; theme: UiTheme; } @@ -16,9 +20,13 @@ const DEFAULT_OPTIONS: UiRuntimeOptions = { v2Enabled: true, colorProfile: "truecolor", glyphMode: "ascii", + palette: "green", + accent: "green", theme: createUiTheme({ profile: "truecolor", glyphMode: "ascii", + palette: "green", + accent: "green", }), }; @@ -30,11 +38,15 @@ export function setUiRuntimeOptions( const v2Enabled = options.v2Enabled ?? runtimeOptions.v2Enabled; const colorProfile = options.colorProfile ?? runtimeOptions.colorProfile; const glyphMode = options.glyphMode ?? runtimeOptions.glyphMode; + const palette = options.palette ?? runtimeOptions.palette; + const accent = options.accent ?? runtimeOptions.accent; runtimeOptions = { v2Enabled, colorProfile, glyphMode, - theme: createUiTheme({ profile: colorProfile, glyphMode }), + palette, + accent, + theme: createUiTheme({ profile: colorProfile, glyphMode, palette, accent }), }; return runtimeOptions; } @@ -47,4 +59,3 @@ export function resetUiRuntimeOptions(): UiRuntimeOptions { runtimeOptions = { ...DEFAULT_OPTIONS }; return runtimeOptions; } - diff --git a/lib/ui/select.ts b/lib/ui/select.ts index 3ba4a3c1..002027d5 100644 --- a/lib/ui/select.ts +++ b/lib/ui/select.ts @@ -1,28 +1,78 @@ import { ANSI, isTTY, parseKey } from "./ansi.js"; import type { UiTheme } from "./theme.js"; +import { appendFileSync, mkdirSync } from "node:fs"; +import { join } from "node:path"; export interface MenuItem { label: string; + selectedLabel?: string; value: T; hint?: string; disabled?: boolean; + hideUnavailableSuffix?: boolean; separator?: boolean; kind?: "heading"; color?: "red" | "green" | "yellow" | "cyan"; } -export interface SelectOptions { +export interface SelectOptions { message: string; subtitle?: string; + dynamicSubtitle?: () => string | undefined; help?: string; clearScreen?: boolean; variant?: "legacy" | "codex"; theme?: UiTheme; + selectedEmphasis?: "chip" | "minimal"; + focusStyle?: "row-invert" | "chip"; + showHintsForUnselected?: boolean; + refreshIntervalMs?: number; + initialCursor?: number; + allowEscape?: boolean; + onCursorChange?: ( + context: { + cursor: number; + items: MenuItem[]; + requestRerender: () => void; + }, + ) => void; + onInput?: ( + input: string, + context: { + cursor: number; + items: MenuItem[]; + requestRerender: () => void; + }, + ) => T | null | undefined; } const ESCAPE_TIMEOUT_MS = 50; const ANSI_REGEX = /\x1b\[[0-9;]*m/g; const ANSI_LEADING_REGEX = /^\x1b\[[0-9;]*m/; +const CSI_FINAL_KEYS = new Set(["A", "B", "C", "D", "H", "F"]); +const CSI_TILDE_PATTERN = /^(1|4|7|8)~$/; + +export interface PendingInputSequence { + value: string; + hasEscape: boolean; +} + +function writeTuiAudit(event: Record): void { + if (process.env.CODEX_TUI_AUDIT !== "1") return; + try { + const home = process.env.USERPROFILE ?? process.env.HOME; + if (!home) return; + const logDir = join(home, ".opencode", "logs"); + mkdirSync(logDir, { recursive: true }); + appendFileSync( + join(logDir, "codex-tui-audit.log"), + `${JSON.stringify({ ts: new Date().toISOString(), ...event })}\n`, + "utf8", + ); + } catch { + // best effort audit logging only + } +} function stripAnsi(input: string): string { return input.replace(ANSI_REGEX, ""); @@ -71,22 +121,126 @@ function colorCode(color: MenuItem["color"]): string { } } -function codexColorCode(theme: UiTheme, color: MenuItem["color"]): string { - switch (color) { - case "red": - return theme.colors.danger; - case "green": - return theme.colors.success; - case "yellow": - return theme.colors.warning; - case "cyan": - return theme.colors.accent; - default: - return theme.colors.heading; +function decodeHotkeyInput(data: Buffer): string | null { + const input = data.toString("utf8"); + const keypadMap: Record = { + "\x1bOp": "0", + "\x1bOq": "1", + "\x1bOr": "2", + "\x1bOs": "3", + "\x1bOt": "4", + "\x1bOu": "5", + "\x1bOv": "6", + "\x1bOw": "7", + "\x1bOx": "8", + "\x1bOy": "9", + "\x1bOk": "+", + "\x1bOm": "-", + "\x1bOj": "*", + "\x1bOo": "/", + "\x1bOn": ".", + }; + const mapped = keypadMap[input]; + if (mapped) return mapped; + + for (const ch of input) { + const code = ch.charCodeAt(0); + if (code >= 32 && code <= 126) return ch; + } + return null; +} + +function canCompleteCsi(chunk: string): boolean { + return CSI_FINAL_KEYS.has(chunk) || CSI_TILDE_PATTERN.test(chunk); +} + +export function coalesceTerminalInput( + rawInput: string, + pending: PendingInputSequence | null, +): { normalizedInput: string | null; pending: PendingInputSequence | null } { + let nextInput = rawInput; + let nextPending = pending; + + if (nextPending) { + const base = nextPending.value; + if ((base === "\x1b[" || base === "[") && canCompleteCsi(nextInput)) { + return { normalizedInput: `\x1b[${nextInput}`, pending: null }; + } + if ((base === "\x1bO" || base === "O") && CSI_FINAL_KEYS.has(nextInput)) { + return { normalizedInput: `\x1bO${nextInput}`, pending: null }; + } + if (base === "\x1b" && (nextInput === "[" || nextInput === "O")) { + return { normalizedInput: null, pending: { value: `\x1b${nextInput}`, hasEscape: true } }; + } + if (base === "\x1b" && ((nextInput.startsWith("[") && nextInput.length > 1) || (nextInput.startsWith("O") && nextInput.length > 1))) { + return { normalizedInput: `\x1b${nextInput}`, pending: null }; + } + nextInput = `${base}${nextInput}`; + nextPending = null; + } + + if ((nextInput.startsWith("[") || nextInput.startsWith("O")) && nextInput.length > 1) { + const prefix = nextInput[0]; + const remainder = nextInput.slice(1); + if ((prefix === "[" && canCompleteCsi(remainder)) || (prefix === "O" && CSI_FINAL_KEYS.has(remainder))) { + return { normalizedInput: `\x1b${nextInput}`, pending: null }; + } + } + + if (nextInput === "\x1b") { + return { normalizedInput: null, pending: { value: "\x1b", hasEscape: true } }; + } + if (nextInput === "\x1b[" || nextInput === "\x1bO") { + return { normalizedInput: null, pending: { value: nextInput, hasEscape: true } }; } + if (nextInput === "[" || nextInput === "O") { + return { normalizedInput: null, pending: { value: nextInput, hasEscape: false } }; + } + + return { normalizedInput: nextInput, pending: nextPending }; } -export async function select(items: MenuItem[], options: SelectOptions): Promise { +export function tokenizeTerminalInput(rawInput: string): string[] { + const tokens: string[] = []; + let index = 0; + while (index < rawInput.length) { + const ch = rawInput.charAt(index); + if (ch !== "\x1b") { + tokens.push(ch); + index += 1; + continue; + } + + const next = rawInput[index + 1]; + const third = rawInput[index + 2]; + const fourth = rawInput[index + 3]; + if (next === "[" && third && CSI_FINAL_KEYS.has(third)) { + tokens.push(rawInput.slice(index, index + 3)); + index += 3; + continue; + } + if (next === "[" && third && fourth && CSI_TILDE_PATTERN.test(`${third}${fourth}`)) { + tokens.push(rawInput.slice(index, index + 4)); + index += 4; + continue; + } + if (next === "O" && third && CSI_FINAL_KEYS.has(third)) { + tokens.push(rawInput.slice(index, index + 3)); + index += 3; + continue; + } + if (next === "[" || next === "O") { + tokens.push(rawInput.slice(index, index + 2)); + index += 2; + continue; + } + tokens.push(ch); + index += 1; + } + return tokens; +} + +export async function select(items: MenuItem[], options: SelectOptions): Promise { if (!isTTY()) { throw new Error("Interactive select requires a TTY terminal"); } @@ -106,121 +260,90 @@ export async function select(items: MenuItem[], options: SelectOptions): P const { stdin, stdout } = process; let cursor = items.findIndex(isSelectable); + if (typeof options.initialCursor === "number" && Number.isFinite(options.initialCursor)) { + const bounded = Math.max(0, Math.min(items.length - 1, Math.trunc(options.initialCursor))); + cursor = bounded; + } + if (cursor < 0 || !isSelectable(items[cursor] as MenuItem)) { + cursor = items.findIndex(isSelectable); + } if (cursor < 0) cursor = 0; let escapeTimeout: ReturnType | null = null; let cleanedUp = false; let renderedLines = 0; + let hasRendered = false; + let inputGuardUntil = 0; + const theme = options.theme; + let rerenderRequested = false; - const renderLegacy = () => { - const columns = stdout.columns ?? 80; - const rows = stdout.rows ?? 24; - const previousRenderedLines = renderedLines; - - if (options.clearScreen) { - stdout.write(ANSI.clearScreen + ANSI.moveTo(1, 1)); - } else if (previousRenderedLines > 0) { - stdout.write(ANSI.up(previousRenderedLines)); - } - - let linesWritten = 0; - const writeLine = (line: string) => { - stdout.write(`${ANSI.clearLine}${line}\n`); - linesWritten += 1; - }; + const requestRerender = () => { + rerenderRequested = true; + }; - const subtitleLines = options.subtitle ? 3 : 0; - const fixedLines = 1 + subtitleLines + 2; - const maxVisibleItems = Math.max(1, Math.min(items.length, rows - fixedLines - 1)); + const notifyCursorChange = () => { + if (!options.onCursorChange) return; + rerenderRequested = false; + const current = items[cursor]; + writeTuiAudit({ + type: "focus", + message: options.message, + cursor, + label: current?.label, + }); + options.onCursorChange({ + cursor, + items, + requestRerender, + }); + }; - let windowStart = 0; - let windowEnd = items.length; - if (items.length > maxVisibleItems) { - windowStart = cursor - Math.floor(maxVisibleItems / 2); - windowStart = Math.max(0, Math.min(windowStart, items.length - maxVisibleItems)); - windowEnd = windowStart + maxVisibleItems; + const drainStdinBuffer = () => { + try { + let chunk: Buffer | string | null; + do { + chunk = stdin.read(); + } while (chunk !== null); + } catch { + // best effort } + }; - const visibleItems = items.slice(windowStart, windowEnd); - writeLine(`${ANSI.dim}+ ${ANSI.reset}${truncateAnsi(options.message, Math.max(1, columns - 4))}`); - - if (options.subtitle) { - writeLine("|"); - writeLine(`${ANSI.cyan}>${ANSI.reset} ${truncateAnsi(options.subtitle, Math.max(1, columns - 4))}`); - writeLine(""); + const codexColorCode = (color: MenuItem["color"]): string => { + if (!theme) { + return colorCode(color); } - - for (let i = 0; i < visibleItems.length; i += 1) { - const itemIndex = windowStart + i; - const item = visibleItems[i]; - if (!item) continue; - - if (item.separator) { - writeLine("|"); - continue; - } - - if (item.kind === "heading") { - const heading = truncateAnsi( - `${ANSI.dim}${ANSI.bold}${item.label}${ANSI.reset}`, - Math.max(1, columns - 6), - ); - writeLine(`${ANSI.cyan}|${ANSI.reset} ${heading}`); - continue; - } - - const selected = itemIndex === cursor; - let labelText: string; - if (item.disabled) { - labelText = `${ANSI.dim}${item.label} (unavailable)${ANSI.reset}`; - } else if (selected) { - const color = colorCode(item.color); - labelText = color ? `${color}${item.label}${ANSI.reset}` : item.label; - if (item.hint) { - labelText += ` ${ANSI.dim}${item.hint}${ANSI.reset}`; - } - } else { - const color = colorCode(item.color); - labelText = color - ? `${ANSI.dim}${color}${item.label}${ANSI.reset}` - : `${ANSI.dim}${item.label}${ANSI.reset}`; - if (item.hint) { - labelText += ` ${ANSI.dim}${item.hint}${ANSI.reset}`; - } - } - - labelText = truncateAnsi(labelText, Math.max(1, columns - 8)); - if (selected) { - writeLine(`${ANSI.cyan}|${ANSI.reset} ${ANSI.green}*${ANSI.reset} ${labelText}`); - } else { - writeLine(`${ANSI.cyan}|${ANSI.reset} ${ANSI.dim}o${ANSI.reset} ${labelText}`); - } + switch (color) { + case "red": + return theme.colors.danger; + case "green": + return theme.colors.success; + case "yellow": + return theme.colors.warning; + case "cyan": + return theme.colors.accent; + default: + return theme.colors.heading; } + }; - const windowHint = - items.length > visibleItems.length ? ` (${windowStart + 1}-${windowEnd}/${items.length})` : ""; - const helpText = options.help ?? `Up/Down select | Enter confirm | Esc back${windowHint}`; - writeLine( - `${ANSI.cyan}|${ANSI.reset} ${ANSI.dim}${truncateAnsi(helpText, Math.max(1, columns - 6))}${ANSI.reset}`, - ); - writeLine(`${ANSI.cyan}+${ANSI.reset}`); - - if (!options.clearScreen && previousRenderedLines > linesWritten) { - const extra = previousRenderedLines - linesWritten; - for (let i = 0; i < extra; i += 1) { - writeLine(""); - } + const selectedLabelStart = (): string => { + if (!theme) { + return `${ANSI.bgGreen}${ANSI.black}${ANSI.bold}`; } - - renderedLines = linesWritten; + return `${theme.colors.focusBg}${theme.colors.focusText}${ANSI.bold}`; }; - const renderCodex = (theme: UiTheme) => { + const render = () => { const columns = stdout.columns ?? 80; const rows = stdout.rows ?? 24; const previousRenderedLines = renderedLines; + const subtitleText = options.dynamicSubtitle ? options.dynamicSubtitle() : options.subtitle; + const focusStyle = options.focusStyle ?? "row-invert"; + let didFullClear = false; - if (options.clearScreen) { + if (options.clearScreen && !hasRendered) { stdout.write(ANSI.clearScreen + ANSI.moveTo(1, 1)); + didFullClear = true; } else if (previousRenderedLines > 0) { stdout.write(ANSI.up(previousRenderedLines)); } @@ -231,7 +354,7 @@ export async function select(items: MenuItem[], options: SelectOptions): P linesWritten += 1; }; - const subtitleLines = options.subtitle ? 2 : 0; + const subtitleLines = subtitleText ? 2 : 0; const fixedLines = 2 + subtitleLines + 2; const maxVisibleItems = Math.max(1, Math.min(items.length, rows - fixedLines - 1)); @@ -244,21 +367,20 @@ export async function select(items: MenuItem[], options: SelectOptions): P } const visibleItems = items.slice(windowStart, windowEnd); - const border = theme.colors.border; - const muted = theme.colors.muted; - const heading = theme.colors.heading; - const accent = theme.colors.accent; - const reset = theme.colors.reset; - const selectedGlyph = theme.glyphs.selected; - const unselectedGlyph = theme.glyphs.unselected; + const border = theme?.colors.border ?? ANSI.dim; + const muted = theme?.colors.muted ?? ANSI.dim; + const heading = theme?.colors.heading ?? ANSI.reset; + const reset = theme?.colors.reset ?? ANSI.reset; + const selectedGlyph = theme?.glyphs.selected ?? ">"; + const unselectedGlyph = theme?.glyphs.unselected ?? "o"; + const selectedGlyphColor = theme?.colors.success ?? ANSI.green; + const selectedChip = selectedLabelStart(); writeLine(`${border}+${reset} ${heading}${truncateAnsi(options.message, Math.max(1, columns - 4))}${reset}`); - if (options.subtitle) { - writeLine( - `${border}|${reset} ${muted}${truncateAnsi(options.subtitle, Math.max(1, columns - 4))}${reset}`, - ); + if (subtitleText) { + writeLine(` ${muted}${truncateAnsi(subtitleText, Math.max(1, columns - 2))}${reset}`); } - writeLine(`${border}|${reset}`); + writeLine(""); for (let i = 0; i < visibleItems.length; i += 1) { const itemIndex = windowStart + i; @@ -266,47 +388,67 @@ export async function select(items: MenuItem[], options: SelectOptions): P if (!item) continue; if (item.separator) { - writeLine(`${border}|${reset}`); + writeLine(""); continue; } if (item.kind === "heading") { - const headingText = truncateAnsi( - `${theme.colors.dim}${heading}${item.label}${reset}`, - Math.max(1, columns - 6), - ); - writeLine(`${border}|${reset} ${headingText}`); + const headingText = truncateAnsi(`${muted}${item.label}${reset}`, Math.max(1, columns - 2)); + writeLine(` ${headingText}`); continue; } const selected = itemIndex === cursor; - const prefix = selected - ? `${accent}${selectedGlyph}${reset}` - : `${muted}${unselectedGlyph}${reset}`; - const itemColor = codexColorCode(theme, item.color); - let labelText: string; - if (item.disabled) { - labelText = `${muted}${item.label} (unavailable)${reset}`; - } else if (selected) { - labelText = `${itemColor}${item.label}${reset}`; + if (selected) { + const selectedText = item.selectedLabel + ? stripAnsi(item.selectedLabel) + : item.disabled + ? item.hideUnavailableSuffix + ? stripAnsi(item.label) + : `${stripAnsi(item.label)} (unavailable)` + : stripAnsi(item.label); + if (focusStyle === "row-invert") { + const rowText = `${selectedGlyph} ${selectedText}`; + const focusedRow = theme + ? `${theme.colors.focusBg}${theme.colors.focusText}${ANSI.bold}${truncateAnsi(rowText, Math.max(1, columns - 2))}${reset}` + : `${ANSI.inverse}${truncateAnsi(rowText, Math.max(1, columns - 2))}${ANSI.reset}`; + writeLine(` ${focusedRow}`); + } else { + const selectedLabel = `${selectedChip}${selectedText}${reset}`; + writeLine(` ${selectedGlyphColor}${selectedGlyph}${reset} ${truncateAnsi(selectedLabel, Math.max(1, columns - 4))}`); + } + if (item.hint) { + const detailLines = item.hint.split("\n").slice(0, 3); + for (const detailLine of detailLines) { + const detail = truncateAnsi(detailLine, Math.max(1, columns - 8)); + writeLine(` ${muted}${detail}${reset}`); + } + } } else { - labelText = `${muted}${item.label}${reset}`; - } - if (item.hint) { - labelText += ` ${muted}${item.hint}${reset}`; + const itemColor = codexColorCode(item.color); + const labelText = item.disabled + ? item.hideUnavailableSuffix + ? `${muted}${item.label}${reset}` + : `${muted}${item.label} (unavailable)${reset}` + : `${itemColor}${item.label}${reset}`; + writeLine(` ${muted}${unselectedGlyph}${reset} ${truncateAnsi(labelText, Math.max(1, columns - 4))}`); + if (item.hint && (options.showHintsForUnselected ?? true)) { + const detailLines = item.hint.split("\n").slice(0, 2); + for (const detailLine of detailLines) { + const detail = truncateAnsi(`${muted}${detailLine}${reset}`, Math.max(1, columns - 8)); + writeLine(` ${detail}`); + } + } } - - labelText = truncateAnsi(labelText, Math.max(1, columns - 8)); - writeLine(`${border}|${reset} ${prefix} ${labelText}`); } - const windowHint = - items.length > visibleItems.length ? ` (${windowStart + 1}-${windowEnd}/${items.length})` : ""; - const helpText = options.help ?? `Up/Down select | Enter confirm | Esc back${windowHint}`; - writeLine(`${border}|${reset} ${muted}${truncateAnsi(helpText, Math.max(1, columns - 4))}${reset}`); + const windowHint = items.length > visibleItems.length ? ` (${windowStart + 1}-${windowEnd}/${items.length})` : ""; + const backLabel = "Q Back"; + const helpText = options.help ?? `↑↓ Move | Enter Select | ${backLabel}${windowHint}`; + writeLine(` ${muted}${truncateAnsi(helpText, Math.max(1, columns - 2))}${reset}`); writeLine(`${border}+${reset}`); - if (!options.clearScreen && previousRenderedLines > linesWritten) { + if (!didFullClear && previousRenderedLines > linesWritten) { const extra = previousRenderedLines - linesWritten; for (let i = 0; i < extra; i += 1) { writeLine(""); @@ -314,18 +456,13 @@ export async function select(items: MenuItem[], options: SelectOptions): P } renderedLines = linesWritten; - }; - - const render = () => { - if (options.variant === "codex" && options.theme) { - renderCodex(options.theme); - return; - } - renderLegacy(); + hasRendered = true; }; return new Promise((resolve) => { const wasRaw = stdin.isRaw ?? false; + let refreshTimer: ReturnType | null = null; + let pendingEscapeSequence: PendingInputSequence | null = null; const cleanup = () => { if (cleanedUp) return; @@ -340,6 +477,10 @@ export async function select(items: MenuItem[], options: SelectOptions): P stdin.removeListener("data", onKey); stdin.setRawMode(wasRaw); stdin.pause(); + if (refreshTimer) { + clearInterval(refreshTimer); + refreshTimer = null; + } stdout.write(ANSI.show); } catch { // best effort cleanup @@ -350,6 +491,13 @@ export async function select(items: MenuItem[], options: SelectOptions): P }; const finish = (value: T | null) => { + writeTuiAudit({ + type: "finish", + message: options.message, + cursor, + label: items[cursor]?.label, + result: value === null ? "cancel" : "selected", + }); cleanup(); resolve(value); }; @@ -366,32 +514,154 @@ export async function select(items: MenuItem[], options: SelectOptions): P }; const onKey = (data: Buffer) => { - if (escapeTimeout) { - clearTimeout(escapeTimeout); - escapeTimeout = null; - } + const rawInput = data.toString("utf8"); + writeTuiAudit({ + type: "raw", + message: options.message, + bytesHex: Array.from(data.values()).map((value) => value.toString(16).padStart(2, "0")).join(" "), + utf8: rawInput, + }); + + const processToken = (token: string): boolean => { + writeTuiAudit({ + type: "token", + message: options.message, + cursor, + token, + }); + + if (escapeTimeout) { + clearTimeout(escapeTimeout); + escapeTimeout = null; + } - const action = parseKey(data); - switch (action) { + const { normalizedInput, pending } = coalesceTerminalInput( + token, + pendingEscapeSequence, + ); + pendingEscapeSequence = pending; + writeTuiAudit({ + type: "coalesced", + message: options.message, + cursor, + token, + normalizedInput, + pending: pendingEscapeSequence?.value ?? null, + hasEscape: pendingEscapeSequence?.hasEscape ?? false, + }); + if (pendingEscapeSequence) { + if (pendingEscapeSequence.hasEscape && options.allowEscape !== false) { + const pendingValue = pendingEscapeSequence.value; + escapeTimeout = setTimeout(() => { + if (pendingEscapeSequence?.value === pendingValue) { + pendingEscapeSequence = null; + finish(null); + } + }, ESCAPE_TIMEOUT_MS); + } + return false; + } + if (normalizedInput === null) { + return false; + } + + const normalizedData = Buffer.from(normalizedInput, "utf8"); + + if (Date.now() < inputGuardUntil) { + const guardedAction = parseKey(normalizedData); + if (guardedAction === "enter" || guardedAction === "escape" || guardedAction === "escape-start") { + return false; + } + } + + const action = parseKey(normalizedData); + switch (action) { case "up": + writeTuiAudit({ type: "key", message: options.message, action: "up", cursor }); cursor = findNextSelectable(cursor, -1); + notifyCursorChange(); render(); - return; + return false; case "down": + writeTuiAudit({ type: "key", message: options.message, action: "down", cursor }); cursor = findNextSelectable(cursor, 1); + notifyCursorChange(); render(); - return; + return false; + case "home": + writeTuiAudit({ type: "key", message: options.message, action: "home", cursor }); + cursor = items.findIndex(isSelectable); + notifyCursorChange(); + render(); + return false; + case "end": { + writeTuiAudit({ type: "key", message: options.message, action: "end", cursor }); + for (let i = items.length - 1; i >= 0; i -= 1) { + const item = items[i]; + if (item && isSelectable(item)) { + cursor = i; + break; + } + } + notifyCursorChange(); + render(); + return false; + } case "enter": + writeTuiAudit({ type: "key", message: options.message, action: "enter", cursor }); finish(items[cursor]?.value ?? null); - return; + return true; case "escape": - finish(null); - return; + writeTuiAudit({ type: "key", message: options.message, action: "escape", cursor }); + if (options.allowEscape !== false) { + finish(null); + } + return true; case "escape-start": - escapeTimeout = setTimeout(() => finish(null), ESCAPE_TIMEOUT_MS); - return; + writeTuiAudit({ type: "key", message: options.message, action: "escape-start", cursor }); + pendingEscapeSequence = { value: "\x1b", hasEscape: true }; + if (options.allowEscape !== false) { + escapeTimeout = setTimeout(() => { + if (pendingEscapeSequence?.value === "\x1b") { + pendingEscapeSequence = null; + finish(null); + } + }, ESCAPE_TIMEOUT_MS); + } + return false; default: + if (options.onInput) { + const hotkey = decodeHotkeyInput(normalizedData); + if (hotkey) { + writeTuiAudit({ + type: "input", + message: options.message, + cursor, + hotkey, + }); + rerenderRequested = false; + const result = options.onInput(hotkey, { + cursor, + items, + requestRerender, + }); + if (result !== undefined) { + finish(result); + return true; + } + if (rerenderRequested) { + render(); + } + } + } + return false; + } + }; + + for (const token of tokenizeTerminalInput(rawInput)) { + if (processToken(token)) { return; + } } }; @@ -407,9 +677,23 @@ export async function select(items: MenuItem[], options: SelectOptions): P } stdin.resume(); + drainStdinBuffer(); + inputGuardUntil = Date.now() + 120; stdout.write(ANSI.hide); + writeTuiAudit({ + type: "open", + message: options.message, + subtitle: options.dynamicSubtitle ? options.dynamicSubtitle() : options.subtitle, + itemCount: items.length, + }); + notifyCursorChange(); render(); + if (options.dynamicSubtitle && (options.refreshIntervalMs ?? 0) > 0) { + const intervalMs = Math.max(80, Math.round(options.refreshIntervalMs ?? 0)); + refreshTimer = setInterval(() => { + render(); + }, intervalMs); + } stdin.on("data", onKey); }); } - diff --git a/lib/ui/theme.ts b/lib/ui/theme.ts index 56ebecbd..8efc9f65 100644 --- a/lib/ui/theme.ts +++ b/lib/ui/theme.ts @@ -4,6 +4,8 @@ export type UiColorProfile = "ansi16" | "ansi256" | "truecolor"; export type UiGlyphMode = "ascii" | "unicode" | "auto"; +export type UiPalette = "green" | "blue"; +export type UiAccent = "green" | "cyan" | "blue" | "yellow"; export interface UiGlyphSet { selected: string; @@ -18,11 +20,14 @@ export interface UiThemeColors { dim: string; muted: string; heading: string; + primary: string; accent: string; success: string; warning: string; danger: string; border: string; + focusBg: string; + focusText: string; } export interface UiTheme { @@ -35,6 +40,8 @@ export interface UiTheme { const ansi16 = (code: number): string => `\x1b[${code}m`; const ansi256 = (code: number): string => `\x1b[38;5;${code}m`; const truecolor = (r: number, g: number, b: number): string => `\x1b[38;2;${r};${g};${b}m`; +const ansi256Bg = (code: number): string => `\x1b[48;5;${code}m`; +const truecolorBg = (r: number, g: number, b: number): string => `\x1b[48;2;${r};${g};${b}m`; function resolveGlyphMode(mode: UiGlyphMode): Exclude { if (mode !== "auto") return mode; @@ -64,31 +71,77 @@ function getGlyphs(mode: Exclude): UiGlyphSet { }; } -function getColors(profile: UiColorProfile): UiThemeColors { +function accentColorForProfile(profile: UiColorProfile, accent: UiAccent): string { + switch (profile) { + case "truecolor": + switch (accent) { + case "cyan": + return truecolor(34, 211, 238); + case "blue": + return truecolor(59, 130, 246); + case "yellow": + return truecolor(245, 158, 11); + default: + return truecolor(74, 222, 128); + } + case "ansi256": + switch (accent) { + case "cyan": + return ansi256(51); + case "blue": + return ansi256(75); + case "yellow": + return ansi256(214); + default: + return ansi256(83); + } + default: + switch (accent) { + case "cyan": + return ansi16(96); + case "blue": + return ansi16(94); + case "yellow": + return ansi16(93); + default: + return ansi16(92); + } + } +} + +function getColors(profile: UiColorProfile, palette: UiPalette, accent: UiAccent): UiThemeColors { + const accentColor = accentColorForProfile(profile, accent); + const isBluePalette = palette === "blue"; switch (profile) { case "truecolor": return { reset: "\x1b[0m", dim: "\x1b[2m", muted: truecolor(148, 163, 184), - heading: truecolor(226, 232, 240), - accent: truecolor(56, 189, 248), - success: truecolor(74, 222, 128), - warning: truecolor(251, 191, 36), - danger: truecolor(248, 113, 113), - border: truecolor(100, 116, 139), + heading: truecolor(240, 253, 244), + primary: isBluePalette ? truecolor(96, 165, 250) : truecolor(74, 222, 128), + accent: accentColor, + success: isBluePalette ? truecolor(96, 165, 250) : truecolor(74, 222, 128), + warning: truecolor(245, 158, 11), + danger: truecolor(239, 68, 68), + border: isBluePalette ? truecolor(59, 130, 246) : truecolor(34, 197, 94), + focusBg: isBluePalette ? truecolorBg(37, 99, 235) : truecolorBg(22, 101, 52), + focusText: truecolor(248, 250, 252), }; case "ansi256": return { reset: "\x1b[0m", dim: "\x1b[2m", - muted: ansi256(109), + muted: ansi256(102), heading: ansi256(255), - accent: ansi256(45), - success: ansi256(84), - warning: ansi256(220), - danger: ansi256(203), - border: ansi256(67), + primary: isBluePalette ? ansi256(75) : ansi256(83), + accent: accentColor, + success: isBluePalette ? ansi256(75) : ansi256(83), + warning: ansi256(214), + danger: ansi256(196), + border: isBluePalette ? ansi256(27) : ansi256(40), + focusBg: isBluePalette ? ansi256Bg(26) : ansi256Bg(28), + focusText: ansi256(231), }; default: return { @@ -96,11 +149,14 @@ function getColors(profile: UiColorProfile): UiThemeColors { dim: "\x1b[2m", muted: ansi16(37), heading: ansi16(97), - accent: ansi16(96), - success: ansi16(92), + primary: isBluePalette ? ansi16(94) : ansi16(92), + accent: accentColor, + success: isBluePalette ? ansi16(94) : ansi16(92), warning: ansi16(93), danger: ansi16(91), - border: ansi16(90), + border: isBluePalette ? ansi16(94) : ansi16(92), + focusBg: isBluePalette ? "\x1b[104m" : "\x1b[102m", + focusText: "\x1b[30m", }; } } @@ -108,15 +164,18 @@ function getColors(profile: UiColorProfile): UiThemeColors { export function createUiTheme(options?: { profile?: UiColorProfile; glyphMode?: UiGlyphMode; + palette?: UiPalette; + accent?: UiAccent; }): UiTheme { const profile = options?.profile ?? "truecolor"; const glyphMode = options?.glyphMode ?? "ascii"; + const palette = options?.palette ?? "green"; + const accent = options?.accent ?? "green"; const resolvedGlyphMode = resolveGlyphMode(glyphMode); return { profile, glyphMode, glyphs: getGlyphs(resolvedGlyphMode), - colors: getColors(profile), + colors: getColors(profile, palette, accent), }; } - diff --git a/package.json b/package.json index 725e1853..6d8a24c8 100644 --- a/package.json +++ b/package.json @@ -40,6 +40,7 @@ "test": "vitest run", "test:watch": "vitest", "test:ui": "vitest --ui", + "capture:keys": "node scripts/capture-tui-input.js", "test:coverage": "vitest run --coverage", "coverage": "vitest run --coverage", "audit:prod": "npm audit --omit=dev --audit-level=high", diff --git a/scripts/capture-tui-input.js b/scripts/capture-tui-input.js new file mode 100644 index 00000000..ecd8377a --- /dev/null +++ b/scripts/capture-tui-input.js @@ -0,0 +1,127 @@ +import { appendFileSync, existsSync, mkdirSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { homedir } from "node:os"; + +function resolveDefaultLogPath() { + const home = process.env.USERPROFILE ?? process.env.HOME ?? homedir(); + return join(home, ".opencode", "logs", "capture-tui-input.log"); +} + +function parseArgs(argv) { + const parsed = { + output: resolveDefaultLogPath(), + }; + + for (let i = 0; i < argv.length; i += 1) { + const arg = argv[i]; + if (arg === "--output" && argv[i + 1]) { + parsed.output = argv[i + 1]; + i += 1; + } + } + + return parsed; +} + +function printableHotkey(value) { + if (value.length === 1) { + const code = value.charCodeAt(0); + if (code >= 32 && code <= 126) return value; + } + return null; +} + +const { output } = parseArgs(process.argv.slice(2)); + +const selectModulePath = new URL("../dist/lib/ui/select.js", import.meta.url); +const ansiModulePath = new URL("../dist/lib/ui/ansi.js", import.meta.url); + +if (!existsSync(selectModulePath) || !existsSync(ansiModulePath)) { + console.error("dist/ build output is missing. Run `npm run build` first."); + process.exit(1); +} + +const { coalesceTerminalInput, tokenizeTerminalInput } = await import(selectModulePath); +const { parseKey } = await import(ansiModulePath); + +mkdirSync(dirname(output), { recursive: true }); + +const logEvent = (event) => { + appendFileSync(output, `${JSON.stringify({ ts: new Date().toISOString(), ...event })}\n`, "utf8"); +}; + +if (!process.stdin.isTTY || !process.stdout.isTTY) { + console.error("capture-tui-input requires a TTY"); + process.exit(1); +} + +console.log(`Logging raw terminal input to ${output}`); +console.log("Press keys to capture. Ctrl+C exits."); + +let pending = null; +const stdin = process.stdin; +const stdout = process.stdout; +const wasRaw = stdin.isRaw ?? false; + +const cleanup = () => { + try { + stdin.setRawMode(wasRaw); + stdin.pause(); + } catch { + // best effort cleanup + } +}; + +stdin.setRawMode(true); +stdin.resume(); + +stdin.on("data", (data) => { + const rawInput = data.toString("utf8"); + logEvent({ + type: "raw", + bytesHex: Array.from(data.values()).map((value) => value.toString(16).padStart(2, "0")).join(" "), + utf8: rawInput, + }); + + let shouldExit = false; + for (const token of tokenizeTerminalInput(rawInput)) { + const coalesced = coalesceTerminalInput(token, pending); + pending = coalesced.pending; + logEvent({ + type: "token", + token, + pending: pending?.value ?? null, + hasEscape: pending?.hasEscape ?? false, + normalizedInput: coalesced.normalizedInput, + }); + if (coalesced.normalizedInput === null) { + continue; + } + + const buffer = Buffer.from(coalesced.normalizedInput, "utf8"); + const action = parseKey(buffer); + const hotkey = printableHotkey(coalesced.normalizedInput); + logEvent({ + type: "parsed", + normalizedInput: coalesced.normalizedInput, + action, + hotkey, + }); + + if (action === "escape" || coalesced.normalizedInput === "\u0003") { + shouldExit = true; + break; + } + } + + if (shouldExit) { + cleanup(); + stdout.write("\nCapture complete.\n"); + process.exit(0); + } +}); + +process.on("SIGINT", () => { + cleanup(); + process.exit(0); +}); diff --git a/test/auth-menu.test.ts b/test/auth-menu.test.ts index 5edc8f28..77f81d87 100644 --- a/test/auth-menu.test.ts +++ b/test/auth-menu.test.ts @@ -1,5 +1,11 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; -import { showAuthMenu, showAccountDetails, type AccountInfo } from "../lib/ui/auth-menu.js"; +import { + showAuthMenu, + showAccountDetails, + showSettingsMenu, + showSyncPruneMenu, + type AccountInfo, +} from "../lib/ui/auth-menu.js"; import { setUiRuntimeOptions, resetUiRuntimeOptions } from "../lib/ui/runtime.js"; import { select } from "../lib/ui/select.js"; import { confirm } from "../lib/ui/confirm.js"; @@ -72,4 +78,44 @@ describe("auth-menu", () => { expect.stringContaining("shared@example.com | workspace:Workspace A | id:org-aaaa...bb2222"), ); }); + + it("shows settings in the main auth menu", async () => { + vi.mocked(select).mockResolvedValueOnce({ type: "cancel" }); + + await showAuthMenu([]); + + const firstCall = vi.mocked(select).mock.calls[0]; + expect(firstCall).toBeDefined(); + const items = firstCall?.[0] as Array<{ label: string; value?: { type?: string } }>; + expect(items.some((item) => item.value?.type === "settings")).toBe(true); + }); + + it("renders sync toggle state in settings menu", async () => { + vi.mocked(select).mockResolvedValueOnce("cancel"); + + await showSettingsMenu(true); + + const firstCall = vi.mocked(select).mock.calls[0]; + expect(firstCall).toBeDefined(); + const items = firstCall?.[0] as Array<{ label: string; value?: string }>; + const toggleItem = items.find((item) => item.value === "toggle-sync"); + expect(toggleItem?.label).toContain("Sync from codex-multi-auth"); + expect(toggleItem?.label).toContain("[enabled]"); + }); + + it("preselects suggested prune candidates and exposes confirm action", async () => { + vi.mocked(select).mockResolvedValueOnce({ type: "confirm" }); + + const result = await showSyncPruneMenu(1, [ + { index: 0, email: "current@example.com", isCurrentAccount: true, score: -1000, reason: "current" }, + { index: 2, email: "old@example.com", score: 180, reason: "disabled, not present in codex-multi-auth source" }, + ]); + + expect(result).toEqual([2]); + const firstCall = vi.mocked(select).mock.calls[0]; + expect(firstCall).toBeDefined(); + const items = firstCall?.[0] as Array<{ label: string; value?: { type?: string } }>; + expect(items.some((item) => item.label.includes("Continue With Selected Accounts"))).toBe(true); + expect(firstCall?.[0][0]?.hint ?? "").toContain("score"); + }); }); diff --git a/test/cli.test.ts b/test/cli.test.ts index b51dfd7a..f92d02e0 100644 --- a/test/cli.test.ts +++ b/test/cli.test.ts @@ -154,7 +154,7 @@ describe("CLI Module", () => { it("re-prompts on invalid input then accepts valid", async () => { mockRl.question .mockResolvedValueOnce("invalid") - .mockResolvedValueOnce("x") + .mockResolvedValueOnce("invalid-again") .mockResolvedValueOnce("a"); const { promptLoginMode } = await import("../lib/cli.js"); @@ -222,6 +222,43 @@ describe("CLI Module", () => { }); }); + describe("promptCodexMultiAuthSyncPrune", () => { + it("uses suggested removals on empty input", async () => { + mockRl.question.mockResolvedValueOnce(""); + + const { promptCodexMultiAuthSyncPrune } = await import("../lib/cli.js"); + const result = await promptCodexMultiAuthSyncPrune(1, [ + { index: 2, email: "old@example.com", reason: "least recently used" }, + { index: 3, email: "disabled@example.com", reason: "disabled", isCurrentAccount: false }, + ]); + + expect(result).toEqual([2]); + }); + + it("parses comma-separated account numbers", async () => { + mockRl.question.mockResolvedValueOnce("2, 4"); + + const { promptCodexMultiAuthSyncPrune } = await import("../lib/cli.js"); + const result = await promptCodexMultiAuthSyncPrune(2, [ + { index: 1, email: "one@example.com", reason: "least recently used" }, + { index: 3, email: "two@example.com", reason: "disabled" }, + ]); + + expect(result).toEqual([1, 3]); + }); + + it("returns null when pruning is cancelled", async () => { + mockRl.question.mockResolvedValueOnce("q"); + + const { promptCodexMultiAuthSyncPrune } = await import("../lib/cli.js"); + const result = await promptCodexMultiAuthSyncPrune(1, [ + { index: 0, email: "one@example.com", reason: "least recently used" }, + ]); + + expect(result).toBeNull(); + }); + }); + describe("isNonInteractiveMode", () => { it("returns false when FORCE_INTERACTIVE_MODE is set", async () => { process.env.FORCE_INTERACTIVE_MODE = "1"; @@ -438,5 +475,13 @@ describe("CLI Module", () => { const result = await promptAccountSelection(candidates, { defaultIndex: 1 }); expect(result).toEqual(candidates[1]); }); + + it("promptCodexMultiAuthSyncPrune returns null in non-interactive mode", async () => { + const { promptCodexMultiAuthSyncPrune } = await import("../lib/cli.js"); + const result = await promptCodexMultiAuthSyncPrune(1, [ + { index: 0, email: "one@example.com", reason: "least recently used" }, + ]); + expect(result).toBeNull(); + }); }); }); diff --git a/test/codex-multi-auth-sync.test.ts b/test/codex-multi-auth-sync.test.ts new file mode 100644 index 00000000..558e41de --- /dev/null +++ b/test/codex-multi-auth-sync.test.ts @@ -0,0 +1,416 @@ +import { beforeEach, afterEach, describe, expect, it, vi } from "vitest"; +import * as fs from "node:fs"; +import { join } from "node:path"; +import { findProjectRoot, getProjectStorageKey } from "../lib/storage/paths.js"; + +vi.mock("node:fs", async () => { + const actual = await vi.importActual("node:fs"); + return { + ...actual, + existsSync: vi.fn(), + readFileSync: vi.fn(), + }; +}); + +vi.mock("../lib/storage.js", () => ({ + deduplicateAccounts: vi.fn((accounts) => accounts), + deduplicateAccountsByEmail: vi.fn((accounts) => accounts), + previewImportAccounts: 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 mockReadFileSync = vi.mocked(fs.readFileSync); + 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, + }; + + beforeEach(() => { + vi.resetModules(); + vi.clearAllMocks(); + delete process.env.CODEX_MULTI_AUTH_DIR; + delete process.env.CODEX_HOME; + }); + + afterEach(() => { + process.env.CODEX_MULTI_AUTH_DIR = originalEnv.CODEX_MULTI_AUTH_DIR; + process.env.CODEX_HOME = originalEnv.CODEX_HOME; + process.env.USERPROFILE = originalEnv.USERPROFILE; + process.env.HOME = originalEnv.HOME; + }); + + 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("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); + mockReadFileSync.mockReturnValue( + 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.previewImportAccounts)).toHaveBeenCalledWith( + expect.stringContaining("oc-chatgpt-multi-auth-sync-"), + ); + expect(vi.mocked(storageModule.importAccounts)).toHaveBeenCalledWith( + expect.stringContaining("oc-chatgpt-multi-auth-sync-"), + { + preImportBackupPrefix: "codex-multi-auth-sync-backup", + backupMode: "required", + }, + ); + }); + + 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); + mockReadFileSync.mockReturnValue( + 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 = 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); + mockReadFileSync.mockReturnValue("not valid json"); + + const { loadCodexMultiAuthSourceStorage } = await import("../lib/codex-multi-auth-sync.js"); + expect(() => loadCodexMultiAuthSourceStorage(process.cwd())).toThrow(/Invalid JSON/); + }); + + it("cleans up existing overlaps by normalizing org-scoped identities first", async () => { + const storageModule = await import("../lib/storage.js"); + vi.mocked(storageModule.normalizeAccountStorage).mockImplementationOnce((value: unknown) => { + const record = value as { + version: 3; + activeIndex: number; + activeIndexByFamily: Record; + accounts: Array>; + }; + 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: 1, + }); + }); + + it("surfaces actionable capacity details when sync would exceed the account limit", 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); + mockReadFileSync.mockReturnValue( + JSON.stringify({ + version: 3, + activeIndex: 0, + activeIndexByFamily: {}, + accounts: [ + { + accountId: "org-new-1", + organizationId: "org-new-1", + accountIdSource: "org", + email: "new1@example.com", + refreshToken: "rt-new-1", + addedAt: 1, + lastUsed: 1, + }, + { + accountId: "org-new-2", + organizationId: "org-new-2", + accountIdSource: "org", + email: "new2@example.com", + refreshToken: "rt-new-2", + addedAt: 2, + lastUsed: 2, + }, + ], + }), + ); + + const storageModule = await import("../lib/storage.js"); + vi.mocked(storageModule.withAccountStorageTransaction).mockImplementationOnce(async (handler) => + handler( + { + version: 3, + activeIndex: 0, + activeIndexByFamily: {}, + accounts: Array.from({ length: 19 }, (_, index) => ({ + accountId: `org-existing-${index + 1}`, + organizationId: `org-existing-${index + 1}`, + accountIdSource: "org", + email: `existing${index + 1}@example.com`, + refreshToken: `rt-existing-${index + 1}`, + addedAt: index + 1, + lastUsed: index + 1, + })), + }, + vi.fn(async () => {}), + ), + ); + + const { CodexMultiAuthSyncCapacityError, previewSyncFromCodexMultiAuth } = await import( + "../lib/codex-multi-auth-sync.js" + ); + + try { + await previewSyncFromCodexMultiAuth(process.cwd()); + throw new Error("Expected previewSyncFromCodexMultiAuth to reject"); + } catch (error) { + expect(error).toBeInstanceOf(CodexMultiAuthSyncCapacityError); + expect(error).toMatchObject({ + name: "CodexMultiAuthSyncCapacityError", + }); + const details = (error as InstanceType).details; + expect(details).toMatchObject({ + accountsPath: globalPath, + currentCount: 19, + sourceCount: 2, + dedupedTotal: 21, + maxAccounts: 20, + needToRemove: 1, + importableNewAccounts: 2, + skippedOverlaps: 0, + }); + expect(details.suggestedRemovals[0]).toMatchObject({ + index: 1, + score: expect.any(Number), + reason: expect.stringContaining("not present in codex-multi-auth source"), + }); + } + }); + + it("prioritizes removals that actually reduce merged capacity over same-email matches", 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 makeOverlap = (suffix: string, lastUsed: number) => ({ + accountId: `org-${suffix}`, + organizationId: `org-${suffix}`, + accountIdSource: "org" as const, + email: `${suffix}@example.com`, + refreshToken: `rt-${suffix}`, + addedAt: lastUsed, + lastUsed, + }); + const sharedPrimary = { + accountId: "org-shared-primary", + organizationId: "org-shared-primary", + accountIdSource: "org" as const, + email: "shared@example.com", + refreshToken: "rt-shared-primary", + addedAt: 1, + lastUsed: 1, + }; + const sharedSecondary = { + accountId: "org-shared-secondary", + organizationId: "org-shared-secondary", + accountIdSource: "org" as const, + email: "shared@example.com", + refreshToken: "rt-shared-secondary", + addedAt: 2, + lastUsed: 2, + }; + const overlapAccounts = Array.from({ length: 18 }, (_, index) => + makeOverlap(`overlap-${index + 1}`, 10 + index), + ); + const sourceStorage = { + version: 3, + activeIndex: 0, + activeIndexByFamily: {}, + accounts: [ + sharedPrimary, + ...overlapAccounts, + { + accountId: "org-source-only", + organizationId: "org-source-only", + accountIdSource: "org" as const, + email: "source-only@example.com", + refreshToken: "rt-source-only", + addedAt: 100, + lastUsed: 100, + }, + ], + }; + mockReadFileSync.mockReturnValue(JSON.stringify(sourceStorage)); + + const storageModule = await import("../lib/storage.js"); + vi.mocked(storageModule.withAccountStorageTransaction).mockImplementationOnce(async (handler) => + handler( + { + version: 3, + activeIndex: 0, + activeIndexByFamily: {}, + accounts: [ + sharedPrimary, + sharedSecondary, + ...overlapAccounts, + ], + }, + vi.fn(async () => {}), + ), + ); + + const { CodexMultiAuthSyncCapacityError, previewSyncFromCodexMultiAuth } = await import( + "../lib/codex-multi-auth-sync.js" + ); + + try { + await previewSyncFromCodexMultiAuth(process.cwd()); + throw new Error("Expected previewSyncFromCodexMultiAuth to reject"); + } catch (error) { + expect(error).toBeInstanceOf(CodexMultiAuthSyncCapacityError); + const details = (error as InstanceType).details; + expect(details.suggestedRemovals[0]).toMatchObject({ + index: 1, + reason: expect.stringContaining("frees 1 sync slot"), + }); + } + }); +}); diff --git a/test/index.test.ts b/test/index.test.ts index 40501343..70c8ab4a 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,7 +110,9 @@ vi.mock("../lib/config.js", () => ({ getCodexTuiColorProfile: () => "ansi16", getCodexTuiGlyphMode: () => "ascii", getBeginnerSafeMode: () => false, + getSyncFromCodexMultiAuthEnabled: () => false, loadPluginConfig: () => ({}), + setSyncFromCodexMultiAuthEnabled: vi.fn(), })); vi.mock("../lib/request/request-transformer.js", () => ({ @@ -169,6 +172,27 @@ vi.mock("../lib/recovery.js", () => ({ getRecoveryToastContent: () => ({ title: "Error", message: "Test" }), })); +vi.mock("../lib/codex-multi-auth-sync.js", () => ({ + previewSyncFromCodexMultiAuth: vi.fn(async () => ({ + rootDir: "/tmp/codex-root", + accountsPath: "/tmp/codex-root/openai-codex-accounts.json", + scope: "global", + imported: 2, + skipped: 0, + total: 4, + })), + syncFromCodexMultiAuth: vi.fn(async () => ({ + rootDir: "/tmp/codex-root", + accountsPath: "/tmp/codex-root/openai-codex-accounts.json", + scope: "global", + imported: 2, + skipped: 0, + total: 4, + backupStatus: "created", + backupPath: "/tmp/codex-sync-backup.json", + })), +})); + vi.mock("../lib/request/rate-limit-backoff.js", () => ({ getRateLimitBackoff: () => ({ attempt: 1, delayMs: 1000 }), RATE_LIMIT_SHORT_RETRY_THRESHOLD_MS: 5000, @@ -471,8 +495,8 @@ describe("OpenAIOAuthPlugin", () => { it("has two auth methods", () => { expect(plugin.auth.methods).toHaveLength(2); - expect(plugin.auth.methods[0].label).toBe("ChatGPT Plus/Pro MULTI (Codex Subscription)"); - expect(plugin.auth.methods[1].label).toBe("ChatGPT Plus/Pro MULTI (Manual URL Paste)"); + expect(plugin.auth.methods[0].label).toBe("ChatGPT Plus/Pro (Browser Login)"); + expect(plugin.auth.methods[1].label).toBe("ChatGPT Plus/Pro (Manual Paste)"); }); it("rejects manual OAuth callbacks with mismatched state", async () => { diff --git a/test/plugin-config.test.ts b/test/plugin-config.test.ts index 1cf69951..bc39f758 100644 --- a/test/plugin-config.test.ts +++ b/test/plugin-config.test.ts @@ -1,6 +1,7 @@ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; import { loadPluginConfig, + savePluginConfigMutation, getCodexMode, getCodexTuiV2, getCodexTuiColorProfile, @@ -20,6 +21,8 @@ import { getRequestTransformMode, getFetchTimeoutMs, getStreamStallTimeoutMs, + getSyncFromCodexMultiAuthEnabled, + setSyncFromCodexMultiAuthEnabled, } from '../lib/config.js'; import type { PluginConfig } from '../lib/types.js'; import * as fs from 'node:fs'; @@ -34,6 +37,8 @@ vi.mock('node:fs', async () => { ...actual, existsSync: vi.fn(), readFileSync: vi.fn(), + mkdirSync: vi.fn(), + writeFileSync: vi.fn(), }; }); @@ -49,6 +54,8 @@ vi.mock('../lib/logger.js', async () => { describe('Plugin Configuration', () => { const mockExistsSync = vi.mocked(fs.existsSync); const mockReadFileSync = vi.mocked(fs.readFileSync); + const mockMkdirSync = vi.mocked(fs.mkdirSync); + const mockWriteFileSync = vi.mocked(fs.writeFileSync); const envKeys = [ 'CODEX_MODE', 'CODEX_TUI_V2', @@ -171,13 +178,20 @@ describe('Plugin Configuration', () => { it('should merge user config with defaults', () => { mockExistsSync.mockReturnValue(true); - mockReadFileSync.mockReturnValue(JSON.stringify({})); + mockReadFileSync.mockReturnValue(JSON.stringify({ + experimental: { syncFromCodexMultiAuth: { enabled: true } }, + })); const config = loadPluginConfig(); expect(config).toEqual({ codexMode: true, requestTransformMode: 'native', + experimental: { + syncFromCodexMultiAuth: { + enabled: true, + }, + }, codexTuiV2: true, codexTuiColorProfile: 'truecolor', codexTuiGlyphMode: 'ascii', @@ -260,7 +274,7 @@ describe('Plugin Configuration', () => { fetchTimeoutMs: 60_000, streamStallTimeoutMs: 45_000, }); - expect(mockLogWarn).toHaveBeenCalled(); + expect(mockLogWarn).toHaveBeenCalled(); }); it('should handle file read errors gracefully', () => { @@ -739,5 +753,75 @@ describe('Plugin Configuration', () => { delete process.env.CODEX_AUTH_STREAM_STALL_TIMEOUT_MS; }); }); + + describe('experimental sync settings', () => { + it('defaults sync-from-codex-multi-auth to false', () => { + expect(getSyncFromCodexMultiAuthEnabled({})).toBe(false); + }); + + it('reads sync-from-codex-multi-auth from config', () => { + expect( + getSyncFromCodexMultiAuthEnabled({ + experimental: { + syncFromCodexMultiAuth: { + enabled: true, + }, + }, + }), + ).toBe(true); + }); + + it('persists sync-from-codex-multi-auth while preserving unrelated keys', () => { + mockExistsSync.mockReturnValue(true); + mockReadFileSync.mockReturnValue( + JSON.stringify({ + codexMode: false, + customKey: 'keep-me', + }), + ); + + setSyncFromCodexMultiAuthEnabled(true); + + expect(mockMkdirSync).toHaveBeenCalledWith( + path.join(os.homedir(), '.opencode'), + { recursive: true }, + ); + expect(mockWriteFileSync).toHaveBeenCalledTimes(1); + const [writtenPath, writtenContent] = mockWriteFileSync.mock.calls[0] ?? []; + expect(writtenPath).toBe(path.join(os.homedir(), '.opencode', 'openai-codex-auth-config.json')); + expect(JSON.parse(String(writtenContent))).toEqual({ + codexMode: false, + customKey: 'keep-me', + experimental: { + syncFromCodexMultiAuth: { + enabled: true, + }, + }, + }); + }); + + it('creates a new config file when enabling sync on a missing config', () => { + mockExistsSync.mockReturnValue(false); + + setSyncFromCodexMultiAuthEnabled(true); + + const [, writtenContent] = mockWriteFileSync.mock.calls[0] ?? []; + expect(JSON.parse(String(writtenContent))).toEqual({ + experimental: { + syncFromCodexMultiAuth: { + enabled: true, + }, + }, + }); + }); + + it('throws when mutating an invalid existing config file to avoid clobbering it', () => { + mockExistsSync.mockReturnValue(true); + mockReadFileSync.mockReturnValue('invalid json'); + + expect(() => savePluginConfigMutation((current) => current)).toThrow(); + expect(mockWriteFileSync).not.toHaveBeenCalled(); + }); + }); }); diff --git a/test/schemas.test.ts b/test/schemas.test.ts index 81283aff..561923ae 100644 --- a/test/schemas.test.ts +++ b/test/schemas.test.ts @@ -26,6 +26,11 @@ describe("PluginConfigSchema", () => { it("accepts valid full config", () => { const config = { codexMode: true, + experimental: { + syncFromCodexMultiAuth: { + enabled: true, + }, + }, fastSession: true, retryProfile: "balanced", retryBudgetOverrides: { diff --git a/test/ui-select.test.ts b/test/ui-select.test.ts new file mode 100644 index 00000000..0afa170e --- /dev/null +++ b/test/ui-select.test.ts @@ -0,0 +1,50 @@ +import { describe, expect, it } from "vitest"; +import { coalesceTerminalInput, tokenizeTerminalInput, type PendingInputSequence } from "../lib/ui/select.js"; + +describe("ui-select", () => { + it("reconstructs orphan bracket arrow chunks", () => { + const first = coalesceTerminalInput("[", null); + expect(first).toEqual({ + normalizedInput: null, + pending: { value: "[", hasEscape: false }, + }); + + const second = coalesceTerminalInput("B", first.pending as PendingInputSequence); + expect(second).toEqual({ + normalizedInput: "\u001b[B", + pending: null, + }); + }); + + it("reconstructs escape-plus-bracket chunks", () => { + const first = coalesceTerminalInput("\u001b", null); + expect(first).toEqual({ + normalizedInput: null, + pending: { value: "\u001b", hasEscape: true }, + }); + + const second = coalesceTerminalInput("[", first.pending as PendingInputSequence); + expect(second).toEqual({ + normalizedInput: null, + pending: { value: "\u001b[", hasEscape: true }, + }); + + const third = coalesceTerminalInput("B", second.pending as PendingInputSequence); + expect(third).toEqual({ + normalizedInput: "\u001b[B", + pending: null, + }); + }); + + it("reconstructs compact orphan sequences", () => { + const result = coalesceTerminalInput("[B", null); + expect(result).toEqual({ + normalizedInput: "\u001b[B", + pending: null, + }); + }); + + it("tokenizes packed escape and control chunks", () => { + expect(tokenizeTerminalInput("\u001b[B\u0003")).toEqual(["\u001b[B", "\u0003"]); + }); +}); From f022bd0f39ac8a0a883abf3144d4d313deff2e38 Mon Sep 17 00:00:00 2001 From: ndycode Date: Sat, 7 Mar 2026 15:18:14 +0800 Subject: [PATCH 02/81] fix: address PR review findings - reload storage after overlap cleanup before persisting auto-repair refreshes\n- ignore WAL-only roots when choosing a codex-multi-auth source directory\n- add regressions for both review findings\n\nCo-authored-by: Codex --- index.ts | 9 +++- lib/codex-multi-auth-sync.ts | 2 +- test/codex-multi-auth-sync.test.ts | 29 +++++++++++ test/index.test.ts | 79 ++++++++++++++++++++++++++++++ 4 files changed, 116 insertions(+), 3 deletions(-) diff --git a/index.ts b/index.ts index 35fce855..c6a83587 100644 --- a/index.ts +++ b/index.ts @@ -3882,8 +3882,8 @@ while (attempted.size < Math.max(1, accountCount)) { }; const runAutoRepairFromDashboard = async (): Promise => { - const storage = await loadAccounts(); - if (!storage || storage.accounts.length === 0) { + const initialStorage = await loadAccounts(); + if (!initialStorage || initialStorage.accounts.length === 0) { console.log("\nNo accounts available.\n"); return; } @@ -3893,6 +3893,11 @@ while (attempted.size < Math.max(1, accountCount)) { if (cleanupResult.removed > 0) { appliedFixes.push(`Removed ${cleanupResult.removed} synced overlap(s).`); } + const storage = await loadAccounts(); + if (!storage || storage.accounts.length === 0) { + console.log("\nNo accounts available after cleanup.\n"); + return; + } let changedByRefresh = false; let refreshedCount = 0; diff --git a/lib/codex-multi-auth-sync.ts b/lib/codex-multi-auth-sync.ts index 9ed7b0ad..8f7602d6 100644 --- a/lib/codex-multi-auth-sync.ts +++ b/lib/codex-multi-auth-sync.ts @@ -268,7 +268,7 @@ function hasStorageSignals(dir: string): boolean { function hasAccountsStorage(dir: string): boolean { return EXTERNAL_ACCOUNT_FILE_NAMES.some((fileName) => { - return existsSync(join(dir, fileName)) || existsSync(join(dir, `${fileName}.wal`)); + return existsSync(join(dir, fileName)); }); } diff --git a/test/codex-multi-auth-sync.test.ts b/test/codex-multi-auth-sync.test.ts index 558e41de..2ddf5676 100644 --- a/test/codex-multi-auth-sync.test.ts +++ b/test/codex-multi-auth-sync.test.ts @@ -139,6 +139,35 @@ describe("codex-multi-auth sync", () => { ); }); + 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; diff --git a/test/index.test.ts b/test/index.test.ts index 70c8ab4a..2231f089 100644 --- a/test/index.test.ts +++ b/test/index.test.ts @@ -173,6 +173,15 @@ vi.mock("../lib/recovery.js", () => ({ })); vi.mock("../lib/codex-multi-auth-sync.js", () => ({ + CodexMultiAuthSyncCapacityError: class CodexMultiAuthSyncCapacityError extends Error { + details: Record; + + constructor(details: Record) { + super("capacity"); + this.name = "CodexMultiAuthSyncCapacityError"; + this.details = details; + } + }, previewSyncFromCodexMultiAuth: vi.fn(async () => ({ rootDir: "/tmp/codex-root", accountsPath: "/tmp/codex-root/openai-codex-accounts.json", @@ -191,6 +200,12 @@ vi.mock("../lib/codex-multi-auth-sync.js", () => ({ backupStatus: "created", backupPath: "/tmp/codex-sync-backup.json", })), + cleanupCodexMultiAuthSyncedOverlaps: vi.fn(async () => ({ + before: 0, + after: 0, + removed: 0, + updated: 0, + })), })); vi.mock("../lib/request/rate-limit-backoff.js", () => ({ @@ -2633,6 +2648,70 @@ describe("OpenAIOAuthPlugin persistAccountPool", () => { accounts: [], }); }); + + it("reloads storage after synced overlap cleanup before persisting auto-repair refreshes", async () => { + const cliModule = await import("../lib/cli.js"); + const storageModule = await import("../lib/storage.js"); + const syncModule = await import("../lib/codex-multi-auth-sync.js"); + + mockStorage.accounts = [ + { + accountId: "org-keep", + organizationId: "org-keep", + accountIdSource: "org", + email: "keep@example.com", + refreshToken: "refresh-keep", + }, + { + accountId: "org-overlap", + organizationId: "org-overlap", + accountIdSource: "org", + email: "overlap@example.com", + refreshToken: "refresh-overlap", + }, + ]; + mockStorage.activeIndex = 0; + mockStorage.activeIndexByFamily = {}; + + vi.mocked(cliModule.promptLoginMode) + .mockResolvedValueOnce({ mode: "fix" }) + .mockResolvedValueOnce({ mode: "cancel" }); + + vi.mocked(syncModule.cleanupCodexMultiAuthSyncedOverlaps).mockImplementationOnce(async () => { + mockStorage.accounts = [mockStorage.accounts[0]].filter(Boolean) as typeof mockStorage.accounts; + return { + before: 2, + after: 1, + removed: 1, + updated: 0, + }; + }); + + vi.mocked(storageModule.loadAccounts).mockImplementation(async () => ({ + version: mockStorage.version, + activeIndex: mockStorage.activeIndex, + activeIndexByFamily: { ...mockStorage.activeIndexByFamily }, + accounts: mockStorage.accounts.map((account) => ({ ...account })), + })); + vi.mocked(storageModule.saveAccounts).mockImplementation(async (nextStorage) => { + mockStorage.version = nextStorage.version; + mockStorage.activeIndex = nextStorage.activeIndex; + mockStorage.activeIndexByFamily = { ...nextStorage.activeIndexByFamily }; + mockStorage.accounts = nextStorage.accounts.map((account) => ({ ...account })); + }); + + const mockClient = createMockClient(); + const { OpenAIOAuthPlugin } = await import("../index.js"); + const plugin = (await OpenAIOAuthPlugin({ client: mockClient } as never)) as unknown as PluginType; + const autoMethod = plugin.auth.methods[0] as unknown as { + authorize: (inputs?: Record) => Promise<{ instructions: string }>; + }; + + const authResult = await autoMethod.authorize(); + expect(authResult.instructions).toBe("Authentication cancelled"); + expect(mockStorage.accounts).toHaveLength(1); + expect(mockStorage.accounts[0]?.email).toBe("keep@example.com"); + }); }); describe("OpenAIOAuthPlugin showToast error handling", () => { From 3ba17171fdc061802d9550f4cf56462f5b61e190 Mon Sep 17 00:00:00 2001 From: ndycode Date: Sat, 7 Mar 2026 15:34:54 +0800 Subject: [PATCH 03/81] fix: address latest review feedback - support q-back in default selector flows\n- harden codex sync temp snapshots with private temp dirs and file modes\n- let the key capture harness exit cleanly on standalone escape\n\nCo-authored-by: Codex --- lib/codex-multi-auth-sync.ts | 19 ++++++++++----- lib/ui/select.ts | 47 +++++++++++++++++++----------------- scripts/capture-tui-input.js | 23 +++++++++++++++++- 3 files changed, 60 insertions(+), 29 deletions(-) diff --git a/lib/codex-multi-auth-sync.ts b/lib/codex-multi-auth-sync.ts index 8f7602d6..b789c2a2 100644 --- a/lib/codex-multi-auth-sync.ts +++ b/lib/codex-multi-auth-sync.ts @@ -108,15 +108,22 @@ async function withNormalizedImportFile( storage: AccountStorageV3, handler: (filePath: string) => Promise, ): Promise { - const tempPath = join( - tmpdir(), - `oc-chatgpt-multi-auth-sync-${process.pid}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}.json`, - ); - await fs.writeFile(tempPath, `${JSON.stringify(storage, null, 2)}\n`, "utf-8"); + const tempDir = await fs.mkdtemp(join(tmpdir(), "oc-chatgpt-multi-auth-sync-")); + try { + await fs.chmod(tempDir, 0o700).catch(() => undefined); + } catch { + // best effort on platforms that ignore chmod + } + const tempPath = join(tempDir, "accounts.json"); + await fs.writeFile(tempPath, `${JSON.stringify(storage, null, 2)}\n`, { + encoding: "utf-8", + mode: 0o600, + flag: "wx", + }); try { return await handler(tempPath); } finally { - await fs.unlink(tempPath).catch(() => undefined); + await fs.rm(tempDir, { recursive: true, force: true }).catch(() => undefined); } } diff --git a/lib/ui/select.ts b/lib/ui/select.ts index 002027d5..b6e0c812 100644 --- a/lib/ui/select.ts +++ b/lib/ui/select.ts @@ -630,30 +630,33 @@ export async function select(items: MenuItem[], options: SelectOptions) } return false; default: - if (options.onInput) { - const hotkey = decodeHotkeyInput(normalizedData); - if (hotkey) { - writeTuiAudit({ - type: "input", - message: options.message, - cursor, - hotkey, - }); - rerenderRequested = false; - const result = options.onInput(hotkey, { - cursor, - items, - requestRerender, - }); - if (result !== undefined) { - finish(result); - return true; - } - if (rerenderRequested) { - render(); - } + const hotkey = decodeHotkeyInput(normalizedData); + if (options.onInput && hotkey) { + writeTuiAudit({ + type: "input", + message: options.message, + cursor, + hotkey, + }); + rerenderRequested = false; + const result = options.onInput(hotkey, { + cursor, + items, + requestRerender, + }); + if (result !== undefined) { + finish(result); + return true; + } + if (rerenderRequested) { + render(); } } + if ((hotkey === "q" || hotkey === "Q") && options.allowEscape !== false) { + writeTuiAudit({ type: "key", message: options.message, action: "q-back", cursor }); + finish(null); + return true; + } return false; } }; diff --git a/scripts/capture-tui-input.js b/scripts/capture-tui-input.js index ecd8377a..4960af0e 100644 --- a/scripts/capture-tui-input.js +++ b/scripts/capture-tui-input.js @@ -43,6 +43,7 @@ if (!existsSync(selectModulePath) || !existsSync(ansiModulePath)) { const { coalesceTerminalInput, tokenizeTerminalInput } = await import(selectModulePath); const { parseKey } = await import(ansiModulePath); +const ESCAPE_TIMEOUT_MS = 50; mkdirSync(dirname(output), { recursive: true }); @@ -59,11 +60,16 @@ console.log(`Logging raw terminal input to ${output}`); console.log("Press keys to capture. Ctrl+C exits."); let pending = null; +let pendingEscapeTimer = null; const stdin = process.stdin; const stdout = process.stdout; const wasRaw = stdin.isRaw ?? false; const cleanup = () => { + if (pendingEscapeTimer) { + clearTimeout(pendingEscapeTimer); + pendingEscapeTimer = null; + } try { stdin.setRawMode(wasRaw); stdin.pause(); @@ -77,6 +83,10 @@ stdin.resume(); stdin.on("data", (data) => { const rawInput = data.toString("utf8"); + if (pendingEscapeTimer) { + clearTimeout(pendingEscapeTimer); + pendingEscapeTimer = null; + } logEvent({ type: "raw", bytesHex: Array.from(data.values()).map((value) => value.toString(16).padStart(2, "0")).join(" "), @@ -95,6 +105,17 @@ stdin.on("data", (data) => { normalizedInput: coalesced.normalizedInput, }); if (coalesced.normalizedInput === null) { + if (pending?.hasEscape && pending.value === "\u001b") { + pendingEscapeTimer = setTimeout(() => { + logEvent({ + type: "timeout", + reason: "escape-start", + }); + cleanup(); + stdout.write("\nCapture complete.\n"); + process.exit(0); + }, ESCAPE_TIMEOUT_MS); + } continue; } @@ -108,7 +129,7 @@ stdin.on("data", (data) => { hotkey, }); - if (action === "escape" || coalesced.normalizedInput === "\u0003") { + if (action === "escape" || action === "escape-start" || coalesced.normalizedInput === "\u0003") { shouldExit = true; break; } From 383fff824ad7278da1880f2053712a605cdec6d8 Mon Sep 17 00:00:00 2001 From: ndycode Date: Sat, 7 Mar 2026 15:44:18 +0800 Subject: [PATCH 04/81] fix: remediate remaining PR review findings - harden config mutation, sync capacity handling, and prune backup flow\n- fix selector/back behavior, capture escape handling, and ui status rendering\n- repair docs table placement and add/update regressions for the reported cases\n\nCo-authored-by: Codex --- docs/configuration.md | 4 +- index.ts | 67 ++++++++++++++++++++++++----- lib/cli.ts | 4 +- lib/codex-multi-auth-sync.ts | 61 +++++++++++++++++++++------ lib/config.ts | 56 ++++++++++++++++++++---- lib/ui/auth-menu.ts | 5 ++- lib/ui/select.ts | 4 +- test/codex-multi-auth-sync.test.ts | 68 +++++++++++++++++++++++++++++- test/plugin-config.test.ts | 27 +++++++++--- 9 files changed, 251 insertions(+), 45 deletions(-) diff --git a/docs/configuration.md b/docs/configuration.md index fbcb2b04..50b8384f 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -165,6 +165,8 @@ The sample above intentionally sets `"retryAllAccountsMaxRetries": 3` as a bound | `autoResume` | `true` | auto-resume after thinking block recovery | | `tokenRefreshSkewMs` | `60000` | refresh tokens this many ms before expiry | | `rateLimitToastDebounceMs` | `60000` | debounce rate limit toasts | +| `fetchTimeoutMs` | `60000` | upstream fetch timeout in ms | +| `streamStallTimeoutMs` | `45000` | max time to wait for next SSE chunk before aborting | ### Experimental Settings @@ -185,8 +187,6 @@ When enabled, the auth dashboard can discover `codex-multi-auth` storage from: - `CODEX_HOME/multi-auth` - `~/DevTools/config/codex/multi-auth` - `~/.codex/multi-auth` -| `fetchTimeoutMs` | `60000` | upstream fetch timeout in ms | -| `streamStallTimeoutMs` | `45000` | max time to wait for next SSE chunk before aborting | ### beginner safe mode behavior diff --git a/index.ts b/index.ts index c6a83587..3d2128bf 100644 --- a/index.ts +++ b/index.ts @@ -24,6 +24,7 @@ */ import { tool } from "@opencode-ai/plugin/tool"; +import { promises as fsPromises } from "node:fs"; import type { Plugin, PluginInput } from "@opencode-ai/plugin"; import type { Auth } from "@opencode-ai/sdk"; import { @@ -211,6 +212,8 @@ import { */ // eslint-disable-next-line @typescript-eslint/require-await export const OpenAIOAuthPlugin: Plugin = async ({ client }: PluginInput) => { + const ANSI_STYLE_REGEX = new RegExp("\\x1b\\[[0-9;]*m", "g"); + const ANSI_STYLE_PREFIX_REGEX = new RegExp("^\\x1b\\[[0-9;]*m"); initLogger(client); let cachedAccountManager: AccountManager | null = null; let accountManagerPromise: Promise | null = null; @@ -1240,7 +1243,7 @@ export const OpenAIOAuthPlugin: Plugin = async ({ client }: PluginInput) => { const lines: Array<{ line: string; tone: "normal" | "muted" | "success" | "warning" | "danger" | "accent" }> = []; let footer = "Running..."; - const stripAnsi = (value: string): string => value.replace(/\x1b\[[0-9;]*m/g, ""); + const stripAnsi = (value: string): string => value.replace(ANSI_STYLE_REGEX, ""); const truncateAnsi = (value: string, maxVisibleChars: number): string => { if (maxVisibleChars <= 0) return ""; const visible = stripAnsi(value); @@ -1252,7 +1255,7 @@ export const OpenAIOAuthPlugin: Plugin = async ({ client }: PluginInput) => { let output = ""; while (index < value.length && kept < keep) { if (value[index] === "\x1b") { - const match = value.slice(index).match(/^\x1b\[[0-9;]*m/); + const match = value.slice(index).match(ANSI_STYLE_PREFIX_REGEX); if (match) { output += match[0]; index += match[0].length; @@ -1441,7 +1444,7 @@ export const OpenAIOAuthPlugin: Plugin = async ({ client }: PluginInput) => { } | null => { if (!ui.v2Enabled || !isInteractiveTTY()) return null; - const stripAnsi = (value: string): string => value.replace(/\x1b\[[0-9;]*m/g, ""); + const stripAnsi = (value: string): string => value.replace(ANSI_STYLE_REGEX, ""); const truncate = (value: string, maxVisibleChars: number): string => { const visible = stripAnsi(value); if (visible.length <= maxVisibleChars) return value; @@ -1457,7 +1460,7 @@ export const OpenAIOAuthPlugin: Plugin = async ({ client }: PluginInput) => { case "warning": return formatUiBadge(ui, "warning", "warning"); case "danger": - return formatUiBadge(ui, "rate-limited", "warning"); + return formatUiBadge(ui, "error", "danger"); case "disabled": return formatUiBadge(ui, "disabled", "danger"); } @@ -3253,12 +3256,12 @@ while (attempted.size < Math.max(1, accountCount)) { if (row) { row.detail = detail; row.status = - tone === "danger" - ? "danger" - : tone === "warning" - ? "warning" - : row.status === "disabled" - ? "disabled" + row.status === "disabled" + ? "disabled" + : tone === "danger" + ? "danger" + : tone === "warning" + ? "warning" : index === activeIndex ? "current" : "ok"; @@ -3676,6 +3679,30 @@ while (attempted.size < Math.max(1, accountCount)) { return; } + const createSyncPruneBackup = async (): Promise<{ + accountsBackupPath: string; + flaggedBackupPath: string; + restore: () => Promise; + }> => { + const currentFlaggedStorage = await loadFlaggedAccounts(); + const accountsBackupPath = createTimestampedBackupPath("codex-sync-prune-backup"); + const flaggedBackupPath = createTimestampedBackupPath("codex-sync-prune-flagged-backup"); + await exportAccounts(accountsBackupPath, true); + const flaggedSnapshot = { ...currentFlaggedStorage, accounts: currentFlaggedStorage.accounts.map((flagged) => ({ ...flagged })) }; + await fsPromises.writeFile(flaggedBackupPath, `${JSON.stringify(flaggedSnapshot, null, 2)}\n`, "utf-8"); + return { + accountsBackupPath, + flaggedBackupPath, + restore: async () => { + const accountsRaw = await fsPromises.readFile(accountsBackupPath, "utf-8"); + await saveAccounts(JSON.parse(accountsRaw) as AccountStorageV3); + const flaggedRaw = await fsPromises.readFile(flaggedBackupPath, "utf-8"); + await saveFlaggedAccounts(JSON.parse(flaggedRaw) as { version: 1; accounts: FlaggedAccountMetadataV1[] }); + invalidateAccountManagerCache(); + }, + }; + }; + const removeAccountsForSync = async (indexes: number[]): Promise => { const currentStorage = (await loadAccounts()) ?? @@ -3734,6 +3761,13 @@ while (attempted.size < Math.max(1, accountCount)) { }); }; + let pruneBackup: + | { + accountsBackupPath: string; + flaggedBackupPath: string; + restore: () => Promise; + } + | null = null; while (true) { try { const preview = await previewSyncFromCodexMultiAuth(process.cwd()); @@ -3753,11 +3787,15 @@ while (attempted.size < Math.max(1, accountCount)) { `Import ${preview.imported} new account(s) from codex-multi-auth?`, ); if (!confirmed) { + if (pruneBackup) { + await pruneBackup.restore(); + } console.log("\nSync cancelled.\n"); return; } const result = await syncFromCodexMultiAuth(process.cwd()); + pruneBackup = null; invalidateAccountManagerCache(); const backupLabel = result.backupStatus === "created" @@ -3821,13 +3859,22 @@ while (attempted.size < Math.max(1, accountCount)) { `Remove ${indexesToRemove.length} selected account(s) and retry sync?`, ); if (!confirmed) { + if (pruneBackup) { + await pruneBackup.restore(); + } console.log("Sync cancelled.\n"); return; } + if (!pruneBackup) { + pruneBackup = await createSyncPruneBackup(); + } await removeAccountsForSync(indexesToRemove); continue; } const message = error instanceof Error ? error.message : String(error); + if (pruneBackup) { + await pruneBackup.restore(); + } console.log(`\nSync failed: ${message}\n`); return; } diff --git a/lib/cli.ts b/lib/cli.ts index 5aa44af1..79a7f6da 100644 --- a/lib/cli.ts +++ b/lib/cli.ts @@ -300,7 +300,9 @@ export async function promptLoginMode( return { mode: "manage", deleteAccountIndex: action.account.index }; } if (accountAction === "set-current") { - return { mode: "manage", switchAccountIndex: action.account.index }; + const index = resolveAccountSourceIndex(action.account); + if (index >= 0) return { mode: "manage", switchAccountIndex: index }; + continue; } if (accountAction === "refresh") { return { mode: "manage", refreshAccountIndex: action.account.index }; diff --git a/lib/codex-multi-auth-sync.ts b/lib/codex-multi-auth-sync.ts index b789c2a2..abb79fb6 100644 --- a/lib/codex-multi-auth-sync.ts +++ b/lib/codex-multi-auth-sync.ts @@ -48,6 +48,7 @@ export interface CodexMultiAuthCleanupResult { export interface CodexMultiAuthSyncCapacityDetails extends CodexMultiAuthResolvedSource { currentCount: number; sourceCount: number; + sourceDedupedTotal: number; dedupedTotal: number; maxAccounts: number; needToRemove: number; @@ -408,20 +409,23 @@ export async function syncFromCodexMultiAuth( projectPath = process.cwd(), ): Promise { const resolved = loadCodexMultiAuthSourceStorage(projectPath); - await assertSyncWithinCapacity(resolved); - const preview = await withNormalizedImportFile( - resolved.storage, - (filePath) => previewImportAccounts(filePath), - ); - const result = await withNormalizedImportFile( - resolved.storage, - (filePath) => importAccounts(filePath, { - preImportBackupPrefix: "codex-multi-auth-sync-backup", - backupMode: "required", - }), - ); + let result: ImportAccountsResult; + try { + result = await withNormalizedImportFile( + resolved.storage, + (filePath) => importAccounts(filePath, { + preImportBackupPrefix: "codex-multi-auth-sync-backup", + backupMode: "required", + }), + ); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + if (message.includes("exceed maximum")) { + await assertSyncWithinCapacity(resolved); + } + throw error; + } return { - ...preview, rootDir: resolved.rootDir, accountsPath: resolved.accountsPath, scope: resolved.scope, @@ -455,8 +459,19 @@ export async function cleanupCodexMultiAuthSyncedOverlaps(): Promise(); + 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) => { - return account.accountIdSource === "org" && account.organizationId ? count + 1 : count; + const key = account.organizationId ?? account.accountId ?? account.refreshToken; + if (!key) return count; + const original = originalAccountsByKey.get(key); + if (!original) return count; + return JSON.stringify(original) === JSON.stringify(account) ? count : count + 1; }, 0); if (removed > 0 || after !== before || JSON.stringify(normalized) !== JSON.stringify(existing)) { @@ -482,6 +497,7 @@ async function assertSyncWithinCapacity( activeIndex: 0, activeIndexByFamily: {}, }; + const sourceDedupedTotal = buildMergedDedupedAccounts([], resolved.storage.accounts).length; const mergedAccounts = buildMergedDedupedAccounts(existing.accounts, resolved.storage.accounts); if (mergedAccounts.length <= ACCOUNT_LIMITS.MAX_ACCOUNTS) { return Promise.resolve(null); @@ -492,6 +508,22 @@ async function assertSyncWithinCapacity( const dedupedTotal = mergedAccounts.length; const importableNewAccounts = Math.max(0, dedupedTotal - currentCount); const skippedOverlaps = Math.max(0, sourceCount - importableNewAccounts); + if (sourceDedupedTotal > ACCOUNT_LIMITS.MAX_ACCOUNTS) { + return Promise.resolve({ + rootDir: resolved.rootDir, + accountsPath: resolved.accountsPath, + scope: resolved.scope, + currentCount, + sourceCount, + sourceDedupedTotal, + dedupedTotal: sourceDedupedTotal, + maxAccounts: ACCOUNT_LIMITS.MAX_ACCOUNTS, + needToRemove: sourceDedupedTotal - ACCOUNT_LIMITS.MAX_ACCOUNTS, + importableNewAccounts: sourceDedupedTotal, + skippedOverlaps: Math.max(0, sourceCount - sourceDedupedTotal), + suggestedRemovals: [], + } satisfies CodexMultiAuthSyncCapacityDetails); + } const sourceIdentities = buildSourceIdentitySet(resolved.storage); const suggestedRemovals = existing.accounts .map((account, index) => { @@ -538,6 +570,7 @@ async function assertSyncWithinCapacity( scope: resolved.scope, currentCount, sourceCount, + sourceDedupedTotal, dedupedTotal, maxAccounts: ACCOUNT_LIMITS.MAX_ACCOUNTS, needToRemove: dedupedTotal - ACCOUNT_LIMITS.MAX_ACCOUNTS, diff --git a/lib/config.ts b/lib/config.ts index e6c7476a..ee1210f8 100644 --- a/lib/config.ts +++ b/lib/config.ts @@ -1,4 +1,4 @@ -import { readFileSync, existsSync, mkdirSync, writeFileSync } from "node:fs"; +import { readFileSync, existsSync, mkdirSync, renameSync, unlinkSync, writeFileSync } from "node:fs"; import { dirname, join } from "node:path"; import { homedir } from "node:os"; import type { PluginConfig } from "./types.js"; @@ -11,6 +11,7 @@ import { logWarn } from "./logger.js"; import { PluginConfigSchema, getValidationErrors } from "./schemas.js"; const CONFIG_PATH = join(homedir(), ".opencode", "openai-codex-auth-config.json"); +const CONFIG_LOCK_PATH = `${CONFIG_PATH}.lock`; const TUI_COLOR_PROFILES = new Set(["truecolor", "ansi16", "ansi256"]); const TUI_GLYPH_MODES = new Set(["ascii", "unicode", "auto"]); const REQUEST_TRANSFORM_MODES = new Set(["native", "legacy"]); @@ -125,15 +126,21 @@ function readRawPluginConfig(): RawPluginConfig { export function savePluginConfigMutation( mutate: (current: RawPluginConfig) => RawPluginConfig, ): void { - const current = readRawPluginConfig(); - const next = mutate({ ...current }); + withPluginConfigLock(() => { + const current = readRawPluginConfig(); + const next = mutate({ ...current }); - if (!isRecord(next)) { - throw new Error("Plugin config mutation must return a JSON object"); - } + if (!isRecord(next)) { + throw new Error("Plugin config mutation must return a JSON object"); + } - mkdirSync(dirname(CONFIG_PATH), { recursive: true }); - writeFileSync(CONFIG_PATH, `${JSON.stringify(next, null, 2)}\n`, "utf-8"); + const tempPath = `${CONFIG_PATH}.${process.pid}.${Date.now()}.tmp`; + writeFileSync(tempPath, `${JSON.stringify(next, null, 2)}\n`, "utf-8"); + if (existsSync(CONFIG_PATH)) { + unlinkSync(CONFIG_PATH); + } + renameSync(tempPath, CONFIG_PATH); + }); } function stripUtf8Bom(content: string): string { @@ -141,7 +148,38 @@ 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); +} + +function sleepSync(ms: number): void { + Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms); +} + +function withPluginConfigLock(fn: () => T): T { + mkdirSync(dirname(CONFIG_PATH), { recursive: true }); + const deadline = Date.now() + 2_000; + while (true) { + try { + writeFileSync(CONFIG_LOCK_PATH, `${process.pid}`, { encoding: "utf-8", flag: "wx" }); + break; + } catch (error) { + const code = (error as NodeJS.ErrnoException).code; + if (code !== "EEXIST" || Date.now() >= deadline) { + throw error; + } + sleepSync(25); + } + } + + try { + return fn(); + } finally { + try { + unlinkSync(CONFIG_LOCK_PATH); + } catch { + // best effort cleanup + } + } } /** diff --git a/lib/ui/auth-menu.ts b/lib/ui/auth-menu.ts index a263a40e..39343d63 100644 --- a/lib/ui/auth-menu.ts +++ b/lib/ui/auth-menu.ts @@ -54,6 +54,9 @@ export type AuthMenuAction = export type AccountAction = "back" | "delete" | "refresh" | "toggle" | "set-current" | "cancel"; export type SettingsAction = "toggle-sync" | "sync-now" | "cleanup-overlaps" | "back" | "cancel"; + +const ANSI_CSI_REGEX = new RegExp("\\x1b\\[[0-?]*[ -/]*[@-~]", "g"); +const CONTROL_CHAR_REGEX = new RegExp("[\\u0000-\\u001f\\u007f]", "g"); export interface SyncPruneCandidate { index: number; email?: string; @@ -70,7 +73,7 @@ type SyncPruneAction = function sanitizeTerminalText(value: string | undefined): string | undefined { if (!value) return undefined; - return value.replace(/\x1b\[[0-?]*[ -/]*[@-~]/g, "").replace(/[\u0000-\u001f\u007f]/g, "").trim(); + return value.replace(ANSI_CSI_REGEX, "").replace(CONTROL_CHAR_REGEX, "").trim(); } function formatRelativeTime(timestamp: number | undefined): string { diff --git a/lib/ui/select.ts b/lib/ui/select.ts index b6e0c812..3bcb5d2f 100644 --- a/lib/ui/select.ts +++ b/lib/ui/select.ts @@ -47,8 +47,8 @@ export interface SelectOptions { } const ESCAPE_TIMEOUT_MS = 50; -const ANSI_REGEX = /\x1b\[[0-9;]*m/g; -const ANSI_LEADING_REGEX = /^\x1b\[[0-9;]*m/; +const ANSI_REGEX = new RegExp("\\x1b\\[[0-9;]*m", "g"); +const ANSI_LEADING_REGEX = new RegExp("^\\x1b\\[[0-9;]*m"); const CSI_FINAL_KEYS = new Set(["A", "B", "C", "D", "H", "F"]); const CSI_TILDE_PATTERN = /^(1|4|7|8)~$/; diff --git a/test/codex-multi-auth-sync.test.ts b/test/codex-multi-auth-sync.test.ts index 2ddf5676..af1cad5d 100644 --- a/test/codex-multi-auth-sync.test.ts +++ b/test/codex-multi-auth-sync.test.ts @@ -265,7 +265,7 @@ describe("codex-multi-auth sync", () => { before: 2, after: 1, removed: 1, - updated: 1, + updated: 0, }); }); @@ -340,6 +340,7 @@ describe("codex-multi-auth sync", () => { accountsPath: globalPath, currentCount: 19, sourceCount: 2, + sourceDedupedTotal: 2, dedupedTotal: 21, maxAccounts: 20, needToRemove: 1, @@ -354,6 +355,71 @@ describe("codex-multi-auth sync", () => { } }); + it("does not suggest local removals when the source itself exceeds the account limit", 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); + mockReadFileSync.mockReturnValue( + JSON.stringify({ + version: 3, + activeIndex: 0, + activeIndexByFamily: {}, + accounts: Array.from({ length: 21 }, (_, index) => ({ + accountId: `org-source-${index + 1}`, + organizationId: `org-source-${index + 1}`, + accountIdSource: "org", + email: `source${index + 1}@example.com`, + refreshToken: `rt-source-${index + 1}`, + addedAt: index + 1, + lastUsed: index + 1, + })), + }), + ); + + const storageModule = await import("../lib/storage.js"); + vi.mocked(storageModule.withAccountStorageTransaction).mockImplementationOnce(async (handler) => + handler( + { + version: 3, + activeIndex: 0, + activeIndexByFamily: {}, + accounts: [ + { + accountId: "org-local", + organizationId: "org-local", + accountIdSource: "org", + email: "local@example.com", + refreshToken: "rt-local", + addedAt: 1, + lastUsed: 1, + }, + ], + }, + vi.fn(async () => {}), + ), + ); + + const { CodexMultiAuthSyncCapacityError, previewSyncFromCodexMultiAuth } = await import( + "../lib/codex-multi-auth-sync.js" + ); + + try { + await previewSyncFromCodexMultiAuth(process.cwd()); + throw new Error("Expected previewSyncFromCodexMultiAuth to reject"); + } catch (error) { + expect(error).toBeInstanceOf(CodexMultiAuthSyncCapacityError); + const details = (error as InstanceType).details; + expect(details).toMatchObject({ + accountsPath: globalPath, + sourceDedupedTotal: 21, + dedupedTotal: 21, + needToRemove: 1, + }); + expect(details.suggestedRemovals).toEqual([]); + } + }); + it("prioritizes removals that actually reduce merged capacity over same-email matches", async () => { const rootDir = join(process.cwd(), ".tmp-codex-multi-auth"); process.env.CODEX_MULTI_AUTH_DIR = rootDir; diff --git a/test/plugin-config.test.ts b/test/plugin-config.test.ts index bc39f758..bab9f463 100644 --- a/test/plugin-config.test.ts +++ b/test/plugin-config.test.ts @@ -38,6 +38,8 @@ vi.mock('node:fs', async () => { existsSync: vi.fn(), readFileSync: vi.fn(), mkdirSync: vi.fn(), + renameSync: vi.fn(), + unlinkSync: vi.fn(), writeFileSync: vi.fn(), }; }); @@ -55,6 +57,8 @@ describe('Plugin Configuration', () => { const mockExistsSync = vi.mocked(fs.existsSync); const mockReadFileSync = vi.mocked(fs.readFileSync); const mockMkdirSync = vi.mocked(fs.mkdirSync); + const mockRenameSync = vi.mocked(fs.renameSync); + const mockUnlinkSync = vi.mocked(fs.unlinkSync); const mockWriteFileSync = vi.mocked(fs.writeFileSync); const envKeys = [ 'CODEX_MODE', @@ -786,9 +790,13 @@ describe('Plugin Configuration', () => { path.join(os.homedir(), '.opencode'), { recursive: true }, ); - expect(mockWriteFileSync).toHaveBeenCalledTimes(1); - const [writtenPath, writtenContent] = mockWriteFileSync.mock.calls[0] ?? []; - expect(writtenPath).toBe(path.join(os.homedir(), '.opencode', 'openai-codex-auth-config.json')); + expect(mockWriteFileSync).toHaveBeenCalledTimes(2); + const [writtenPath, writtenContent] = mockWriteFileSync.mock.calls[1] ?? []; + expect(String(writtenPath)).toContain('.tmp'); + expect(mockUnlinkSync).toHaveBeenCalledWith( + path.join(os.homedir(), '.opencode', 'openai-codex-auth-config.json'), + ); + expect(mockRenameSync).toHaveBeenCalled(); expect(JSON.parse(String(writtenContent))).toEqual({ codexMode: false, customKey: 'keep-me', @@ -805,7 +813,7 @@ describe('Plugin Configuration', () => { setSyncFromCodexMultiAuthEnabled(true); - const [, writtenContent] = mockWriteFileSync.mock.calls[0] ?? []; + const [, writtenContent] = mockWriteFileSync.mock.calls[1] ?? []; expect(JSON.parse(String(writtenContent))).toEqual({ experimental: { syncFromCodexMultiAuth: { @@ -820,7 +828,16 @@ describe('Plugin Configuration', () => { mockReadFileSync.mockReturnValue('invalid json'); expect(() => savePluginConfigMutation((current) => current)).toThrow(); - expect(mockWriteFileSync).not.toHaveBeenCalled(); + expect(mockRenameSync).not.toHaveBeenCalled(); + }); + + it('rejects array roots when reading raw plugin config', () => { + mockExistsSync.mockReturnValue(true); + mockReadFileSync.mockReturnValue('[]'); + + expect(() => savePluginConfigMutation((current) => current)).toThrow( + 'Plugin config root must be a JSON object', + ); }); }); }); From f9c8498e8f7db94dda2a75f9d8bbb565e1468b7f Mon Sep 17 00:00:00 2001 From: ndycode Date: Sat, 7 Mar 2026 15:46:18 +0800 Subject: [PATCH 05/81] fix: resolve remaining review findings - tighten config writes, sync capacity handling, and prune recovery safeguards\n- fix docs placement, source-index switching, audit-log permissions, and ANSI regex lint concerns\n- add/update regressions for the newly reported review cases\n\nCo-authored-by: Codex --- index.ts | 2 ++ lib/ui/auth-menu.ts | 1 + lib/ui/select.ts | 12 ++++++++---- 3 files changed, 11 insertions(+), 4 deletions(-) diff --git a/index.ts b/index.ts index 3d2128bf..1aed7ed5 100644 --- a/index.ts +++ b/index.ts @@ -212,7 +212,9 @@ import { */ // eslint-disable-next-line @typescript-eslint/require-await export const OpenAIOAuthPlugin: Plugin = async ({ client }: PluginInput) => { + // biome-ignore lint/suspicious/noControlCharactersInRegex: matching ANSI escape codes const ANSI_STYLE_REGEX = new RegExp("\\x1b\\[[0-9;]*m", "g"); + // biome-ignore lint/suspicious/noControlCharactersInRegex: matching ANSI escape codes const ANSI_STYLE_PREFIX_REGEX = new RegExp("^\\x1b\\[[0-9;]*m"); initLogger(client); let cachedAccountManager: AccountManager | null = null; diff --git a/lib/ui/auth-menu.ts b/lib/ui/auth-menu.ts index 39343d63..a557c881 100644 --- a/lib/ui/auth-menu.ts +++ b/lib/ui/auth-menu.ts @@ -55,6 +55,7 @@ export type AuthMenuAction = export type AccountAction = "back" | "delete" | "refresh" | "toggle" | "set-current" | "cancel"; export type SettingsAction = "toggle-sync" | "sync-now" | "cleanup-overlaps" | "back" | "cancel"; +// biome-ignore lint/suspicious/noControlCharactersInRegex: matching ANSI escape codes const ANSI_CSI_REGEX = new RegExp("\\x1b\\[[0-?]*[ -/]*[@-~]", "g"); const CONTROL_CHAR_REGEX = new RegExp("[\\u0000-\\u001f\\u007f]", "g"); export interface SyncPruneCandidate { diff --git a/lib/ui/select.ts b/lib/ui/select.ts index 3bcb5d2f..40399446 100644 --- a/lib/ui/select.ts +++ b/lib/ui/select.ts @@ -1,6 +1,6 @@ import { ANSI, isTTY, parseKey } from "./ansi.js"; import type { UiTheme } from "./theme.js"; -import { appendFileSync, mkdirSync } from "node:fs"; +import { appendFileSync, chmodSync, mkdirSync } from "node:fs"; import { join } from "node:path"; export interface MenuItem { @@ -47,7 +47,9 @@ export interface SelectOptions { } const ESCAPE_TIMEOUT_MS = 50; +// biome-ignore lint/suspicious/noControlCharactersInRegex: matching ANSI escape codes const ANSI_REGEX = new RegExp("\\x1b\\[[0-9;]*m", "g"); +// biome-ignore lint/suspicious/noControlCharactersInRegex: matching ANSI escape codes const ANSI_LEADING_REGEX = new RegExp("^\\x1b\\[[0-9;]*m"); const CSI_FINAL_KEYS = new Set(["A", "B", "C", "D", "H", "F"]); const CSI_TILDE_PATTERN = /^(1|4|7|8)~$/; @@ -63,12 +65,14 @@ function writeTuiAudit(event: Record): void { const home = process.env.USERPROFILE ?? process.env.HOME; if (!home) return; const logDir = join(home, ".opencode", "logs"); - mkdirSync(logDir, { recursive: true }); + mkdirSync(logDir, { recursive: true, mode: 0o700 }); + const logPath = join(logDir, "codex-tui-audit.log"); appendFileSync( - join(logDir, "codex-tui-audit.log"), + logPath, `${JSON.stringify({ ts: new Date().toISOString(), ...event })}\n`, - "utf8", + { encoding: "utf8", mode: 0o600 }, ); + chmodSync(logPath, 0o600); } catch { // best effort audit logging only } From 3f9ace3aa5058fda869258bb502bafe385606b53 Mon Sep 17 00:00:00 2001 From: ndycode Date: Sat, 7 Mar 2026 16:00:48 +0800 Subject: [PATCH 06/81] fix: close remaining review threads - restore prune backups on zero-import exit and refresh manager selection state\n- recover malformed config for sync toggle fallback and sanitize account hint rendering\n- tighten selector CSI handling and fallback settings behavior\n\nCo-authored-by: Codex --- index.ts | 15 +++++++++++++- lib/cli.ts | 42 +++++++++++++++++++++++++++++++++++--- lib/config.ts | 27 ++++++++++++++++-------- lib/ui/auth-menu.ts | 5 +++-- lib/ui/select.ts | 12 ++--------- test/auth-menu.test.ts | 17 +++++++++++++++ test/cli.test.ts | 13 ++++++++++++ test/plugin-config.test.ts | 8 ++++++++ test/ui-select.test.ts | 12 +++++------ 9 files changed, 120 insertions(+), 31 deletions(-) diff --git a/index.ts b/index.ts index 1aed7ed5..4c2cb43e 100644 --- a/index.ts +++ b/index.ts @@ -3781,6 +3781,18 @@ while (attempted.size < Math.max(1, accountCount)) { ); if (preview.imported <= 0) { + if (pruneBackup) { + try { + await pruneBackup.restore(); + } catch (restoreError) { + logWarn( + `[${PLUGIN_NAME}] Failed to restore prune backup after zero-import preview: ${ + restoreError instanceof Error ? restoreError.message : String(restoreError) + }`, + ); + } + pruneBackup = null; + } console.log("No new accounts to import.\n"); return; } @@ -3906,7 +3918,8 @@ while (attempted.size < Math.max(1, accountCount)) { return; } const now = Date.now(); - const managerForFix = cachedAccountManager ?? (await AccountManager.loadFromDisk()); + const managerForFix = await AccountManager.loadFromDisk(); + cachedAccountManager = managerForFix; const explainability = managerForFix.getSelectionExplainability("codex", undefined, now); const eligible = explainability .filter((entry) => entry.eligible) diff --git a/lib/cli.ts b/lib/cli.ts index 79a7f6da..2cc5a57f 100644 --- a/lib/cli.ts +++ b/lib/cli.ts @@ -215,7 +215,36 @@ async function promptDeleteAllTypedConfirm(): Promise { } } -async function promptLoginModeFallback(existingAccounts: ExistingAccountInfo[]): Promise { +async function promptSettingsModeFallback( + rl: ReturnType, + syncFromCodexMultiAuthEnabled: boolean, +): Promise { + while (true) { + const syncState = syncFromCodexMultiAuthEnabled ? "enabled" : "disabled"; + const answer = await rl.question( + `(t) toggle sync [${syncState}], (i) sync now, (c) cleanup overlaps, (b) back [t/i/c/b]: `, + ); + const normalized = answer.trim().toLowerCase(); + if (normalized === "t" || normalized === "toggle") { + return { mode: "experimental-toggle-sync" }; + } + if (normalized === "i" || normalized === "import" || normalized === "sync") { + return { mode: "experimental-sync-now" }; + } + if (normalized === "c" || normalized === "cleanup") { + return { mode: "experimental-cleanup-overlaps" }; + } + if (normalized === "b" || normalized === "back") { + return null; + } + console.log("Use one of: t, i, c, b."); + } +} + +async function promptLoginModeFallback( + existingAccounts: ExistingAccountInfo[], + options: LoginMenuOptions = {}, +): Promise { const rl = createInterface({ input, output }); try { if (existingAccounts.length > 0) { @@ -232,7 +261,14 @@ async function promptLoginModeFallback(existingAccounts: ExistingAccountInfo[]): if (normalized === "a" || normalized === "add") return { mode: "add" }; if (normalized === "b" || normalized === "forecast") return { mode: "forecast" }; if (normalized === "x" || normalized === "fix") return { mode: "fix" }; - if (normalized === "s" || normalized === "settings") return { mode: "settings" }; + if (normalized === "s" || normalized === "settings") { + const settingsResult = await promptSettingsModeFallback( + rl, + options.syncFromCodexMultiAuthEnabled === true, + ); + if (settingsResult) return settingsResult; + continue; + } 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" }; @@ -254,7 +290,7 @@ export async function promptLoginMode( } if (!isTTY()) { - return promptLoginModeFallback(existingAccounts); + return promptLoginModeFallback(existingAccounts, options); } while (true) { diff --git a/lib/config.ts b/lib/config.ts index ee1210f8..dc7946cf 100644 --- a/lib/config.ts +++ b/lib/config.ts @@ -109,25 +109,34 @@ export function loadPluginConfig(): PluginConfig { } } -function readRawPluginConfig(): RawPluginConfig { +function readRawPluginConfig(recoverInvalid = false): RawPluginConfig { if (!existsSync(CONFIG_PATH)) { return {}; } - const fileContent = readFileSync(CONFIG_PATH, "utf-8"); - const normalizedFileContent = stripUtf8Bom(fileContent); - const parsed = JSON.parse(normalizedFileContent) as unknown; - if (!isRecord(parsed)) { - throw new Error("Plugin config root must be a JSON object"); + try { + const fileContent = readFileSync(CONFIG_PATH, "utf-8"); + const normalizedFileContent = stripUtf8Bom(fileContent); + const parsed = JSON.parse(normalizedFileContent) as unknown; + if (!isRecord(parsed)) { + throw new Error("Plugin config root must be a JSON object"); + } + return { ...parsed }; + } catch (error) { + if (recoverInvalid) { + logWarn(`Failed to read raw plugin config from ${CONFIG_PATH}: ${(error as Error).message}`); + return {}; + } + throw error; } - return { ...parsed }; } export function savePluginConfigMutation( mutate: (current: RawPluginConfig) => RawPluginConfig, + options: { recoverInvalidCurrent?: boolean } = {}, ): void { withPluginConfigLock(() => { - const current = readRawPluginConfig(); + const current = readRawPluginConfig(options.recoverInvalidCurrent === true); const next = mutate({ ...current }); if (!isRecord(next)) { @@ -281,7 +290,7 @@ export function setSyncFromCodexMultiAuthEnabled(enabled: boolean): void { experimental.syncFromCodexMultiAuth = syncSettings; current.experimental = experimental; return current; - }); + }, { recoverInvalidCurrent: true }); } export function getCodexTuiColorProfile( diff --git a/lib/ui/auth-menu.ts b/lib/ui/auth-menu.ts index a557c881..2d7f89b9 100644 --- a/lib/ui/auth-menu.ts +++ b/lib/ui/auth-menu.ts @@ -186,8 +186,9 @@ function accountRowColor(account: AccountInfo): MenuItem["color" function formatAccountHint(account: AccountInfo, ui = getUiRuntimeOptions()): string { const parts: string[] = []; parts.push(ui.v2Enabled ? paintUiText(ui, `used ${formatRelativeTime(account.lastUsed)}`, "muted") : `used ${formatRelativeTime(account.lastUsed)}`); - if (account.quotaSummary?.trim()) { - parts.push(ui.v2Enabled ? paintUiText(ui, account.quotaSummary, "muted") : account.quotaSummary); + const quotaSummary = sanitizeTerminalText(account.quotaSummary); + if (quotaSummary) { + parts.push(ui.v2Enabled ? paintUiText(ui, quotaSummary, "muted") : quotaSummary); } return parts.join(ui.v2Enabled ? ` ${paintUiText(ui, "|", "muted")} ` : " | "); } diff --git a/lib/ui/select.ts b/lib/ui/select.ts index 40399446..fe020ef5 100644 --- a/lib/ui/select.ts +++ b/lib/ui/select.ts @@ -167,10 +167,10 @@ export function coalesceTerminalInput( if (nextPending) { const base = nextPending.value; - if ((base === "\x1b[" || base === "[") && canCompleteCsi(nextInput)) { + if (nextPending.hasEscape && (base === "\x1b[" || base === "[") && canCompleteCsi(nextInput)) { return { normalizedInput: `\x1b[${nextInput}`, pending: null }; } - if ((base === "\x1bO" || base === "O") && CSI_FINAL_KEYS.has(nextInput)) { + if (nextPending.hasEscape && (base === "\x1bO" || base === "O") && CSI_FINAL_KEYS.has(nextInput)) { return { normalizedInput: `\x1bO${nextInput}`, pending: null }; } if (base === "\x1b" && (nextInput === "[" || nextInput === "O")) { @@ -183,14 +183,6 @@ export function coalesceTerminalInput( nextPending = null; } - if ((nextInput.startsWith("[") || nextInput.startsWith("O")) && nextInput.length > 1) { - const prefix = nextInput[0]; - const remainder = nextInput.slice(1); - if ((prefix === "[" && canCompleteCsi(remainder)) || (prefix === "O" && CSI_FINAL_KEYS.has(remainder))) { - return { normalizedInput: `\x1b${nextInput}`, pending: null }; - } - } - if (nextInput === "\x1b") { return { normalizedInput: null, pending: { value: "\x1b", hasEscape: true } }; } diff --git a/test/auth-menu.test.ts b/test/auth-menu.test.ts index 77f81d87..8b4d9ca3 100644 --- a/test/auth-menu.test.ts +++ b/test/auth-menu.test.ts @@ -118,4 +118,21 @@ describe("auth-menu", () => { expect(items.some((item) => item.label.includes("Continue With Selected Accounts"))).toBe(true); expect(firstCall?.[0][0]?.hint ?? "").toContain("score"); }); + + it("sanitizes quota summaries in account hints", async () => { + vi.mocked(select).mockResolvedValueOnce({ type: "cancel" }); + + await showAuthMenu([ + { + index: 0, + email: "safe@example.com", + quotaSummary: "5h \u001b[31m100%\u001b[0m", + }, + ]); + + const firstCall = vi.mocked(select).mock.calls[0]; + const items = firstCall?.[0] as Array<{ hint?: string; value?: { type?: string } }>; + const accountRow = items.find((item) => item.value?.type === "select-account"); + expect(accountRow?.hint ?? "").not.toContain("\u001b"); + }); }); diff --git a/test/cli.test.ts b/test/cli.test.ts index f92d02e0..b3988962 100644 --- a/test/cli.test.ts +++ b/test/cli.test.ts @@ -142,6 +142,19 @@ describe("CLI Module", () => { expect(result).toEqual({ mode: "fresh", deleteAll: true }); }); + it("routes fallback settings input to experimental sync actions", async () => { + mockRl.question + .mockResolvedValueOnce("s") + .mockResolvedValueOnce("i"); + + const { promptLoginMode } = await import("../lib/cli.js"); + const result = await promptLoginMode([{ index: 0 }], { + syncFromCodexMultiAuthEnabled: true, + }); + + expect(result).toEqual({ mode: "experimental-sync-now" }); + }); + it("is case insensitive", async () => { mockRl.question.mockResolvedValueOnce("A"); diff --git a/test/plugin-config.test.ts b/test/plugin-config.test.ts index bab9f463..194f05b9 100644 --- a/test/plugin-config.test.ts +++ b/test/plugin-config.test.ts @@ -839,6 +839,14 @@ describe('Plugin Configuration', () => { 'Plugin config root must be a JSON object', ); }); + + it('recovers malformed config when toggling sync setting', () => { + mockExistsSync.mockReturnValue(true); + mockReadFileSync.mockReturnValue('invalid json'); + + expect(() => setSyncFromCodexMultiAuthEnabled(true)).not.toThrow(); + expect(mockRenameSync).toHaveBeenCalled(); + }); }); }); diff --git a/test/ui-select.test.ts b/test/ui-select.test.ts index 0afa170e..4b14a24f 100644 --- a/test/ui-select.test.ts +++ b/test/ui-select.test.ts @@ -9,11 +9,11 @@ describe("ui-select", () => { pending: { value: "[", hasEscape: false }, }); - const second = coalesceTerminalInput("B", first.pending as PendingInputSequence); - expect(second).toEqual({ - normalizedInput: "\u001b[B", - pending: null, - }); + const second = coalesceTerminalInput("B", first.pending as PendingInputSequence); + expect(second).toEqual({ + normalizedInput: "[B", + pending: null, + }); }); it("reconstructs escape-plus-bracket chunks", () => { @@ -39,7 +39,7 @@ describe("ui-select", () => { it("reconstructs compact orphan sequences", () => { const result = coalesceTerminalInput("[B", null); expect(result).toEqual({ - normalizedInput: "\u001b[B", + normalizedInput: "[B", pending: null, }); }); From 3ec466df3c39055c909fea60a863b76604c0ef76 Mon Sep 17 00:00:00 2001 From: ndycode Date: Sat, 7 Mar 2026 16:06:57 +0800 Subject: [PATCH 07/81] fix: eliminate remaining unresolved review findings - restore prune backups on zero-import exit and reload account manager from disk\n- recover malformed config for sync toggle fallback and sanitize quota hints/screen output\n- harden selector escape handling, audit redaction, and terminal failure cleanup\n\nCo-authored-by: Codex --- index.ts | 31 ++++++++------ lib/ui/select.ts | 105 ++++++++++++++++++++++++++++++++++++++--------- 2 files changed, 104 insertions(+), 32 deletions(-) diff --git a/index.ts b/index.ts index 4c2cb43e..5664f745 100644 --- a/index.ts +++ b/index.ts @@ -216,6 +216,9 @@ export const OpenAIOAuthPlugin: Plugin = async ({ client }: PluginInput) => { const ANSI_STYLE_REGEX = new RegExp("\\x1b\\[[0-9;]*m", "g"); // biome-ignore lint/suspicious/noControlCharactersInRegex: matching ANSI escape codes const ANSI_STYLE_PREFIX_REGEX = new RegExp("^\\x1b\\[[0-9;]*m"); + const SCREEN_CONTROL_CHAR_REGEX = new RegExp("[\\u0000-\\u0008\\u000b\\u000c\\u000e-\\u001f\\u007f]", "g"); + const sanitizeScreenText = (value: string): string => + value.replace(ANSI_STYLE_REGEX, "").replace(SCREEN_CONTROL_CHAR_REGEX, "").trim(); initLogger(client); let cachedAccountManager: AccountManager | null = null; let accountManagerPromise: Promise | null = null; @@ -1245,7 +1248,7 @@ export const OpenAIOAuthPlugin: Plugin = async ({ client }: PluginInput) => { const lines: Array<{ line: string; tone: "normal" | "muted" | "success" | "warning" | "danger" | "accent" }> = []; let footer = "Running..."; - const stripAnsi = (value: string): string => value.replace(ANSI_STYLE_REGEX, ""); + const stripAnsi = (value: string): string => sanitizeScreenText(value); const truncateAnsi = (value: string, maxVisibleChars: number): string => { if (maxVisibleChars <= 0) return ""; const visible = stripAnsi(value); @@ -1273,9 +1276,9 @@ export const OpenAIOAuthPlugin: Plugin = async ({ client }: PluginInput) => { const render = () => { const screenLines: string[] = []; const columns = process.stdout.columns ?? 120; - screenLines.push(...formatUiHeader(ui, title)); + screenLines.push(...formatUiHeader(ui, sanitizeScreenText(title))); if (subtitle) { - screenLines.push(paintUiText(ui, subtitle, "muted")); + screenLines.push(paintUiText(ui, sanitizeScreenText(subtitle), "muted")); } screenLines.push(""); screenLines.push( @@ -1284,21 +1287,21 @@ export const OpenAIOAuthPlugin: Plugin = async ({ client }: PluginInput) => { ), ); screenLines.push(""); - screenLines.push(paintUiText(ui, footer, "muted")); + screenLines.push(paintUiText(ui, sanitizeScreenText(footer), "muted")); process.stdout.write(ANSI.clearScreen + ANSI.moveTo(1, 1) + screenLines.join("\n")); }; render(); return { push: (line: string, tone = "normal") => { - lines.push({ line, tone }); + lines.push({ line: sanitizeScreenText(line), tone }); render(); }, finish: async (summaryLines?: Array<{ line: string; tone?: "normal" | "muted" | "success" | "warning" | "danger" | "accent" }>) => { if (summaryLines && summaryLines.length > 0) { lines.push({ line: "", tone: "normal" }); for (const entry of summaryLines) { - lines.push({ line: entry.line, tone: entry.tone ?? "normal" }); + lines.push({ line: sanitizeScreenText(entry.line), tone: entry.tone ?? "normal" }); } } footer = "Done."; @@ -1446,7 +1449,7 @@ export const OpenAIOAuthPlugin: Plugin = async ({ client }: PluginInput) => { } | null => { if (!ui.v2Enabled || !isInteractiveTTY()) return null; - const stripAnsi = (value: string): string => value.replace(ANSI_STYLE_REGEX, ""); + const stripAnsi = (value: string): string => sanitizeScreenText(value); const truncate = (value: string, maxVisibleChars: number): string => { const visible = stripAnsi(value); if (visible.length <= maxVisibleChars) return value; @@ -1474,7 +1477,7 @@ export const OpenAIOAuthPlugin: Plugin = async ({ client }: PluginInput) => { const spinner = spinnerFrames[Math.floor(Date.now() / 150) % spinnerFrames.length] ?? "-"; const lines: string[] = []; lines.push(...formatUiHeader(ui, "Accounts Dashboard")); - lines.push(paintUiText(ui, `${spinner} ${progressText}`, "muted")); + lines.push(paintUiText(ui, sanitizeScreenText(`${spinner} ${progressText}`), "muted")); lines.push(""); lines.push(paintUiText(ui, "Quick Actions", "muted")); lines.push(` ${paintUiText(ui, "o", "muted")} ${paintUiText(ui, "Add New Account", "success")}`); @@ -1490,14 +1493,14 @@ export const OpenAIOAuthPlugin: Plugin = async ({ client }: PluginInput) => { lines.push(paintUiText(ui, "Saved Accounts", "muted")); for (const row of rows) { const marker = row.index === selectedIndex ? ">" : "o"; - const primary = `${row.index + 1}. ${row.email ?? `Account ${row.index + 1}`}`; + const primary = `${row.index + 1}. ${sanitizeScreenText(row.email ?? `Account ${row.index + 1}`)}`; lines.push(` ${paintUiText(ui, marker, row.index === selectedIndex ? "accent" : "muted")} ${truncate(`${paintUiText(ui, primary, row.index === selectedIndex ? "accent" : "heading")} ${statusBadgeForRow(row)}`, Math.max(20, cols - 4))}`); if (row.index === selectedIndex && row.detail) { - lines.push(` ${truncate(paintUiText(ui, `Limits: ${row.detail}`, "muted"), Math.max(20, cols - 6))}`); + lines.push(` ${truncate(paintUiText(ui, `Limits: ${sanitizeScreenText(row.detail)}`, "muted"), Math.max(20, cols - 6))}`); } } lines.push(""); - lines.push(paintUiText(ui, footer, "muted")); + lines.push(paintUiText(ui, sanitizeScreenText(footer), "muted")); process.stdout.write(ANSI.clearScreen + ANSI.moveTo(1, 1) + lines.join("\n")); }; @@ -3691,7 +3694,11 @@ while (attempted.size < Math.max(1, accountCount)) { const flaggedBackupPath = createTimestampedBackupPath("codex-sync-prune-flagged-backup"); await exportAccounts(accountsBackupPath, true); const flaggedSnapshot = { ...currentFlaggedStorage, accounts: currentFlaggedStorage.accounts.map((flagged) => ({ ...flagged })) }; - await fsPromises.writeFile(flaggedBackupPath, `${JSON.stringify(flaggedSnapshot, null, 2)}\n`, "utf-8"); + await fsPromises.writeFile(flaggedBackupPath, `${JSON.stringify(flaggedSnapshot, null, 2)}\n`, { + encoding: "utf-8", + mode: 0o600, + flag: "wx", + }); return { accountsBackupPath, flaggedBackupPath, diff --git a/lib/ui/select.ts b/lib/ui/select.ts index fe020ef5..2abcf6d3 100644 --- a/lib/ui/select.ts +++ b/lib/ui/select.ts @@ -69,7 +69,7 @@ function writeTuiAudit(event: Record): void { const logPath = join(logDir, "codex-tui-audit.log"); appendFileSync( logPath, - `${JSON.stringify({ ts: new Date().toISOString(), ...event })}\n`, + `${JSON.stringify(sanitizeAuditValue("event", { ts: new Date().toISOString(), ...event }))}\n`, { encoding: "utf8", mode: 0o600 }, ); chmodSync(logPath, 0o600); @@ -78,6 +78,30 @@ function writeTuiAudit(event: Record): void { } } +function sanitizeAuditValue(key: string, value: unknown): unknown { + if (typeof value === "string") { + if (["label", "message", "utf8", "bytesHex", "token", "normalizedInput", "pending"].includes(key)) { + return `[redacted:${value.length}]`; + } + if (value.includes("@")) { + return "[redacted-email]"; + } + return value; + } + if (Array.isArray(value)) { + return value.map((entry) => sanitizeAuditValue(key, entry)); + } + if (value && typeof value === "object") { + return Object.fromEntries( + Object.entries(value as Record).map(([entryKey, entryValue]) => [ + entryKey, + sanitizeAuditValue(entryKey, entryValue), + ]), + ); + } + return value; +} + function stripAnsi(input: string): string { return input.replace(ANSI_REGEX, ""); } @@ -455,7 +479,8 @@ export async function select(items: MenuItem[], options: SelectOptions) hasRendered = true; }; - return new Promise((resolve) => { + return new Promise((resolve, reject) => { + const rejectPromise = reject; const wasRaw = stdin.isRaw ?? false; let refreshTimer: ReturnType | null = null; let pendingEscapeSequence: PendingInputSequence | null = null; @@ -498,6 +523,12 @@ export async function select(items: MenuItem[], options: SelectOptions) resolve(value); }; + const fail = (error: unknown): boolean => { + cleanup(); + rejectPromise(error); + return true; + }; + const onSignal = () => finish(null); const findNextSelectable = (from: number, direction: 1 | -1): number => { @@ -575,20 +606,32 @@ export async function select(items: MenuItem[], options: SelectOptions) case "up": writeTuiAudit({ type: "key", message: options.message, action: "up", cursor }); cursor = findNextSelectable(cursor, -1); - notifyCursorChange(); - render(); + try { + notifyCursorChange(); + render(); + } catch (error) { + return fail(error); + } return false; case "down": writeTuiAudit({ type: "key", message: options.message, action: "down", cursor }); cursor = findNextSelectable(cursor, 1); - notifyCursorChange(); - render(); + try { + notifyCursorChange(); + render(); + } catch (error) { + return fail(error); + } return false; case "home": writeTuiAudit({ type: "key", message: options.message, action: "home", cursor }); cursor = items.findIndex(isSelectable); - notifyCursorChange(); - render(); + try { + notifyCursorChange(); + render(); + } catch (error) { + return fail(error); + } return false; case "end": { writeTuiAudit({ type: "key", message: options.message, action: "end", cursor }); @@ -599,8 +642,12 @@ export async function select(items: MenuItem[], options: SelectOptions) break; } } - notifyCursorChange(); - render(); + try { + notifyCursorChange(); + render(); + } catch (error) { + return fail(error); + } return false; } case "enter": @@ -635,17 +682,26 @@ export async function select(items: MenuItem[], options: SelectOptions) hotkey, }); rerenderRequested = false; - const result = options.onInput(hotkey, { - cursor, - items, - requestRerender, - }); + let result: T | null | undefined; + try { + result = options.onInput(hotkey, { + cursor, + items, + requestRerender, + }); + } catch (error) { + return fail(error); + } if (result !== undefined) { finish(result); return true; } if (rerenderRequested) { - render(); + try { + render(); + } catch (error) { + return fail(error); + } } } if ((hotkey === "q" || hotkey === "Q") && options.allowEscape !== false) { @@ -682,15 +738,24 @@ export async function select(items: MenuItem[], options: SelectOptions) writeTuiAudit({ type: "open", message: options.message, - subtitle: options.dynamicSubtitle ? options.dynamicSubtitle() : options.subtitle, + subtitle: options.subtitle, itemCount: items.length, }); - notifyCursorChange(); - render(); + try { + notifyCursorChange(); + render(); + } catch (error) { + fail(error); + return; + } if (options.dynamicSubtitle && (options.refreshIntervalMs ?? 0) > 0) { const intervalMs = Math.max(80, Math.round(options.refreshIntervalMs ?? 0)); refreshTimer = setInterval(() => { - render(); + try { + render(); + } catch (error) { + fail(error); + } }, intervalMs); } stdin.on("data", onKey); From 48f6dbeb9f921da97387f864f4b72aed1228713a Mon Sep 17 00:00:00 2001 From: ndycode Date: Sat, 7 Mar 2026 16:53:22 +0800 Subject: [PATCH 08/81] feat: remove sync account cap and dedupe by email - make account add/import paths effectively unlimited\n- skip codex-multi-auth source accounts whose emails already exist locally\n- update sync and storage regressions for unlimited capacity and email-overlap filtering\n\nCo-authored-by: Codex --- README.md | 2 +- lib/codex-multi-auth-sync.ts | 94 +++++++++- lib/constants.ts | 2 +- lib/storage.ts | 6 +- test/codex-multi-auth-sync.test.ts | 266 +++++++++++++++-------------- test/storage.test.ts | 16 +- 6 files changed, 244 insertions(+), 142 deletions(-) diff --git a/README.md b/README.md index 035827d0..37695618 100644 --- a/README.md +++ b/README.md @@ -275,7 +275,7 @@ For legacy OpenCode (v1.0.209 and below), use `config/opencode-legacy.json` whic ## Multi-Account Setup -Add multiple ChatGPT accounts for higher combined quotas. The plugin uses **health-aware rotation** with automatic failover and supports up to 20 accounts. +Add multiple ChatGPT accounts for higher combined quotas. The plugin uses **health-aware rotation** with automatic failover and supports unlimited accounts. ```bash opencode auth login # Run again to add more accounts diff --git a/lib/codex-multi-auth-sync.ts b/lib/codex-multi-auth-sync.ts index abb79fb6..b6ecc4bb 100644 --- a/lib/codex-multi-auth-sync.ts +++ b/lib/codex-multi-auth-sync.ts @@ -135,6 +135,78 @@ function deduplicateAccountsForSync(storage: AccountStorageV3): AccountStorageV3 }; } +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 deduplicated: AccountStorageV3["accounts"] = []; + const emailToIndex = new Map(); + + for (const account of accounts) { + 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 filterSourceAccountsAgainstExistingEmails( + sourceStorage: AccountStorageV3, + existingAccounts: AccountStorageV3["accounts"], +): AccountStorageV3 { + const existingEmails = new Set( + existingAccounts + .map((account) => normalizeIdentity(account.email)) + .filter((email): email is string => typeof email === "string" && email.length > 0), + ); + + return { + ...sourceStorage, + accounts: deduplicateSourceAccountsByEmail(sourceStorage.accounts).filter((account) => { + const normalizedEmail = normalizeIdentity(account.email); + if (!normalizedEmail) return true; + return !existingEmails.has(normalizedEmail); + }), + }; +} + function buildMergedDedupedAccounts( currentAccounts: AccountStorageV3["accounts"], sourceAccounts: AccountStorageV3["accounts"], @@ -388,10 +460,25 @@ export function loadCodexMultiAuthSourceStorage( }; } +async function loadPreparedCodexMultiAuthSourceStorage( + projectPath = process.cwd(), +): Promise { + const resolved = loadCodexMultiAuthSourceStorage(projectPath); + const currentStorage = await withAccountStorageTransaction((current) => Promise.resolve(current)); + const preparedStorage = filterSourceAccountsAgainstExistingEmails( + resolved.storage, + currentStorage?.accounts ?? [], + ); + return { + ...resolved, + storage: preparedStorage, + }; +} + export async function previewSyncFromCodexMultiAuth( projectPath = process.cwd(), ): Promise { - const resolved = loadCodexMultiAuthSourceStorage(projectPath); + const resolved = await loadPreparedCodexMultiAuthSourceStorage(projectPath); await assertSyncWithinCapacity(resolved); const preview = await withNormalizedImportFile( resolved.storage, @@ -408,7 +495,7 @@ export async function previewSyncFromCodexMultiAuth( export async function syncFromCodexMultiAuth( projectPath = process.cwd(), ): Promise { - const resolved = loadCodexMultiAuthSourceStorage(projectPath); + const resolved = await loadPreparedCodexMultiAuthSourceStorage(projectPath); let result: ImportAccountsResult; try { result = await withNormalizedImportFile( @@ -490,6 +577,9 @@ export async function cleanupCodexMultiAuthSyncedOverlaps(): Promise { + if (!Number.isFinite(ACCOUNT_LIMITS.MAX_ACCOUNTS)) { + return; + } const details = await withAccountStorageTransaction((current) => { const existing = current ?? { version: 3 as const, diff --git a/lib/constants.ts b/lib/constants.ts index ebb5ad84..c242c85f 100644 --- a/lib/constants.ts +++ b/lib/constants.ts @@ -86,7 +86,7 @@ export const AUTH_LABELS = { /** Multi-account configuration */ export const ACCOUNT_LIMITS = { /** Maximum number of OAuth accounts that can be registered */ - MAX_ACCOUNTS: 20, + MAX_ACCOUNTS: Number.POSITIVE_INFINITY, /** Cooldown period (ms) after auth failure before retrying account */ AUTH_FAILURE_COOLDOWN_MS: 30_000, /** Number of consecutive auth failures before auto-removing account */ diff --git a/lib/storage.ts b/lib/storage.ts index 151e2213..f5b447cd 100644 --- a/lib/storage.ts +++ b/lib/storage.ts @@ -1157,8 +1157,9 @@ export async function previewImportAccounts( return withAccountStorageTransaction((existing) => { const existingAccounts = existing?.accounts ?? []; const merged = [...existingAccounts, ...normalized.accounts]; + const hasFiniteAccountLimit = Number.isFinite(ACCOUNT_LIMITS.MAX_ACCOUNTS); - if (merged.length > ACCOUNT_LIMITS.MAX_ACCOUNTS) { + if (hasFiniteAccountLimit && merged.length > ACCOUNT_LIMITS.MAX_ACCOUNTS) { const deduped = deduplicateAccountsForStorage(merged); if (deduped.length > ACCOUNT_LIMITS.MAX_ACCOUNTS) { throw new Error( @@ -1263,8 +1264,9 @@ export async function importAccounts( } const merged = [...existingAccounts, ...normalized.accounts]; + const hasFiniteAccountLimit = Number.isFinite(ACCOUNT_LIMITS.MAX_ACCOUNTS); - if (merged.length > ACCOUNT_LIMITS.MAX_ACCOUNTS) { + if (hasFiniteAccountLimit && merged.length > ACCOUNT_LIMITS.MAX_ACCOUNTS) { const deduped = deduplicateAccountsForStorage(merged); if (deduped.length > ACCOUNT_LIMITS.MAX_ACCOUNTS) { throw new Error( diff --git a/test/codex-multi-auth-sync.test.ts b/test/codex-multi-auth-sync.test.ts index af1cad5d..9a3e84b3 100644 --- a/test/codex-multi-auth-sync.test.ts +++ b/test/codex-multi-auth-sync.test.ts @@ -208,6 +208,104 @@ describe("codex-multi-auth sync", () => { ); }); + 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); + mockReadFileSync.mockReturnValue( + 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, + }, + ], + }; + vi.mocked(storageModule.withAccountStorageTransaction) + .mockImplementationOnce(async (handler) => handler(currentStorage, vi.fn(async () => {}))) + .mockImplementationOnce(async (handler) => handler(currentStorage, vi.fn(async () => {}))); + + vi.mocked(storageModule.previewImportAccounts).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) => { + 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, + 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("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; @@ -269,7 +367,7 @@ describe("codex-multi-auth sync", () => { }); }); - it("surfaces actionable capacity details when sync would exceed the account limit", async () => { + it("does not block preview when account limit is unlimited", 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"); @@ -323,39 +421,17 @@ describe("codex-multi-auth sync", () => { ), ); - const { CodexMultiAuthSyncCapacityError, previewSyncFromCodexMultiAuth } = await import( - "../lib/codex-multi-auth-sync.js" - ); + const { previewSyncFromCodexMultiAuth } = await import("../lib/codex-multi-auth-sync.js"); - try { - await previewSyncFromCodexMultiAuth(process.cwd()); - throw new Error("Expected previewSyncFromCodexMultiAuth to reject"); - } catch (error) { - expect(error).toBeInstanceOf(CodexMultiAuthSyncCapacityError); - expect(error).toMatchObject({ - name: "CodexMultiAuthSyncCapacityError", - }); - const details = (error as InstanceType).details; - expect(details).toMatchObject({ - accountsPath: globalPath, - currentCount: 19, - sourceCount: 2, - sourceDedupedTotal: 2, - dedupedTotal: 21, - maxAccounts: 20, - needToRemove: 1, - importableNewAccounts: 2, - skippedOverlaps: 0, - }); - expect(details.suggestedRemovals[0]).toMatchObject({ - index: 1, - score: expect.any(Number), - reason: expect.stringContaining("not present in codex-multi-auth source"), - }); - } + await expect(previewSyncFromCodexMultiAuth(process.cwd())).resolves.toMatchObject({ + accountsPath: globalPath, + imported: 2, + total: 4, + skipped: 0, + }); }); - it("does not suggest local removals when the source itself exceeds the account limit", async () => { + it("does not block source-only imports above the old cap when limit is unlimited", 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"); @@ -400,112 +476,44 @@ describe("codex-multi-auth sync", () => { ), ); - const { CodexMultiAuthSyncCapacityError, previewSyncFromCodexMultiAuth } = await import( - "../lib/codex-multi-auth-sync.js" - ); + const { previewSyncFromCodexMultiAuth } = await import("../lib/codex-multi-auth-sync.js"); - try { - await previewSyncFromCodexMultiAuth(process.cwd()); - throw new Error("Expected previewSyncFromCodexMultiAuth to reject"); - } catch (error) { - expect(error).toBeInstanceOf(CodexMultiAuthSyncCapacityError); - const details = (error as InstanceType).details; - expect(details).toMatchObject({ - accountsPath: globalPath, - sourceDedupedTotal: 21, - dedupedTotal: 21, - needToRemove: 1, - }); - expect(details.suggestedRemovals).toEqual([]); - } + await expect(previewSyncFromCodexMultiAuth(process.cwd())).resolves.toMatchObject({ + accountsPath: globalPath, + imported: 2, + total: 4, + skipped: 0, + }); }); - it("prioritizes removals that actually reduce merged capacity over same-email matches", async () => { + it("does not produce capacity errors for large existing stores when unlimited", 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 makeOverlap = (suffix: string, lastUsed: number) => ({ - accountId: `org-${suffix}`, - organizationId: `org-${suffix}`, - accountIdSource: "org" as const, - email: `${suffix}@example.com`, - refreshToken: `rt-${suffix}`, - addedAt: lastUsed, - lastUsed, - }); - const sharedPrimary = { - accountId: "org-shared-primary", - organizationId: "org-shared-primary", - accountIdSource: "org" as const, - email: "shared@example.com", - refreshToken: "rt-shared-primary", - addedAt: 1, - lastUsed: 1, - }; - const sharedSecondary = { - accountId: "org-shared-secondary", - organizationId: "org-shared-secondary", - accountIdSource: "org" as const, - email: "shared@example.com", - refreshToken: "rt-shared-secondary", - addedAt: 2, - lastUsed: 2, - }; - const overlapAccounts = Array.from({ length: 18 }, (_, index) => - makeOverlap(`overlap-${index + 1}`, 10 + index), - ); - const sourceStorage = { - version: 3, - activeIndex: 0, - activeIndexByFamily: {}, - accounts: [ - sharedPrimary, - ...overlapAccounts, - { - accountId: "org-source-only", - organizationId: "org-source-only", - accountIdSource: "org" as const, - email: "source-only@example.com", - refreshToken: "rt-source-only", - addedAt: 100, - lastUsed: 100, - }, - ], - }; - mockReadFileSync.mockReturnValue(JSON.stringify(sourceStorage)); - - const storageModule = await import("../lib/storage.js"); - vi.mocked(storageModule.withAccountStorageTransaction).mockImplementationOnce(async (handler) => - handler( - { - version: 3, - activeIndex: 0, - activeIndexByFamily: {}, - accounts: [ - sharedPrimary, - sharedSecondary, - ...overlapAccounts, - ], - }, - vi.fn(async () => {}), - ), - ); - - const { CodexMultiAuthSyncCapacityError, previewSyncFromCodexMultiAuth } = await import( - "../lib/codex-multi-auth-sync.js" + mockReadFileSync.mockReturnValue( + JSON.stringify({ + version: 3, + activeIndex: 0, + activeIndexByFamily: {}, + accounts: Array.from({ length: 50 }, (_, index) => ({ + accountId: `org-source-${index + 1}`, + organizationId: `org-source-${index + 1}`, + accountIdSource: "org", + email: `source${index + 1}@example.com`, + refreshToken: `rt-source-${index + 1}`, + addedAt: index + 1, + lastUsed: index + 1, + })), + }), ); - try { - await previewSyncFromCodexMultiAuth(process.cwd()); - throw new Error("Expected previewSyncFromCodexMultiAuth to reject"); - } catch (error) { - expect(error).toBeInstanceOf(CodexMultiAuthSyncCapacityError); - const details = (error as InstanceType).details; - expect(details.suggestedRemovals[0]).toMatchObject({ - index: 1, - reason: expect.stringContaining("frees 1 sync slot"), - }); - } + const { previewSyncFromCodexMultiAuth } = await import("../lib/codex-multi-auth-sync.js"); + await expect(previewSyncFromCodexMultiAuth(process.cwd())).resolves.toMatchObject({ + accountsPath: globalPath, + imported: 2, + total: 4, + skipped: 0, + }); }); }); diff --git a/test/storage.test.ts b/test/storage.test.ts index 94d20141..66aab8ba 100644 --- a/test/storage.test.ts +++ b/test/storage.test.ts @@ -591,11 +591,10 @@ describe("storage", () => { ); }); - it("should enforce MAX_ACCOUNTS during import", async () => { - // @ts-ignore - const { importAccounts } = await import("../lib/storage.js"); - - const manyAccounts = Array.from({ length: 21 }, (_, i) => ({ + it("allows importing more than the old account cap when unlimited", async () => { + const { importAccounts } = await import("../lib/storage.js"); + + const manyAccounts = Array.from({ length: 21 }, (_, i) => ({ accountId: `acct${i}`, refreshToken: `ref${i}`, addedAt: Date.now(), @@ -609,8 +608,11 @@ describe("storage", () => { }; await fs.writeFile(exportPath, JSON.stringify(toImport)); - // @ts-ignore - await expect(importAccounts(exportPath)).rejects.toThrow(/exceed maximum/); + await expect(importAccounts(exportPath)).resolves.toMatchObject({ + imported: 21, + total: 21, + skipped: 0, + }); }); it("should fail export when no accounts exist", async () => { From 5524eafac399f8f38e072a65502c44998c8ce884 Mon Sep 17 00:00:00 2001 From: ndycode Date: Sat, 7 Mar 2026 16:59:24 +0800 Subject: [PATCH 09/81] feat: organize settings menu into categories - group settings into sync, maintenance, and navigation sections\n- keep the current sync actions under a structured layout for future experimental tools\n- cover the grouped settings layout in auth-menu tests\n\nCo-authored-by: Codex --- lib/ui/auth-menu.ts | 7 ++++++- lib/ui/copy.ts | 5 ++++- test/auth-menu.test.ts | 3 +++ 3 files changed, 13 insertions(+), 2 deletions(-) diff --git a/lib/ui/auth-menu.ts b/lib/ui/auth-menu.ts index 2d7f89b9..2ecfadc8 100644 --- a/lib/ui/auth-menu.ts +++ b/lib/ui/auth-menu.ts @@ -369,10 +369,15 @@ export async function showSettingsMenu( const action = await select( [ - { label: UI_COPY.settings.back, value: "back" }, + { label: UI_COPY.settings.syncHeading, value: "cancel", kind: "heading" }, { label: syncLabel, value: "toggle-sync", color: syncFromCodexMultiAuthEnabled ? "green" : "yellow" }, { label: UI_COPY.settings.syncNow, value: "sync-now", color: "cyan" }, + { label: "", value: "cancel", separator: true }, + { label: UI_COPY.settings.maintenanceHeading, value: "cancel", kind: "heading" }, { label: UI_COPY.settings.cleanupOverlaps, value: "cleanup-overlaps", color: "yellow" }, + { label: "", value: "cancel", separator: true }, + { label: UI_COPY.settings.navigationHeading, value: "cancel", kind: "heading" }, + { label: UI_COPY.settings.back, value: "back" }, ], { message: UI_COPY.settings.title, diff --git a/lib/ui/copy.ts b/lib/ui/copy.ts index 22e3f8d8..443b0c24 100644 --- a/lib/ui/copy.ts +++ b/lib/ui/copy.ts @@ -29,8 +29,11 @@ export const UI_COPY = { }, settings: { title: "Settings", - subtitle: "Manage sync and cleanup options", + subtitle: "Organized controls for sync, maintenance, and future tools", help: "↑↓ Move | Enter Select | Q Back", + syncHeading: "Sync", + maintenanceHeading: "Maintenance", + navigationHeading: "Navigation", syncToggle: "Sync from codex-multi-auth", syncNow: "Sync Now", cleanupOverlaps: "Cleanup Synced Overlaps", diff --git a/test/auth-menu.test.ts b/test/auth-menu.test.ts index 8b4d9ca3..b736a5cc 100644 --- a/test/auth-menu.test.ts +++ b/test/auth-menu.test.ts @@ -101,6 +101,9 @@ describe("auth-menu", () => { const toggleItem = items.find((item) => item.value === "toggle-sync"); expect(toggleItem?.label).toContain("Sync from codex-multi-auth"); expect(toggleItem?.label).toContain("[enabled]"); + expect(items.some((item) => item.label === "Sync")).toBe(true); + expect(items.some((item) => item.label === "Maintenance")).toBe(true); + expect(items.some((item) => item.label === "Navigation")).toBe(true); }); it("preselects suggested prune candidates and exposes confirm action", async () => { From cd88317f9d2283130ee4e96553563f1935e89a7f Mon Sep 17 00:00:00 2001 From: ndycode Date: Sat, 7 Mar 2026 17:16:35 +0800 Subject: [PATCH 10/81] feat: add duplicate email maintenance cleanup Adds a Settings > Maintenance action that removes duplicate local accounts by email and preserves active-index mappings. Co-authored-by: Codex --- index.ts | 26 ++++++++ lib/cli.ts | 9 ++- lib/storage.ts | 148 +++++++++++++++++++++++++++++++++++++++++ lib/ui/auth-menu.ts | 9 ++- lib/ui/copy.ts | 1 + test/auth-menu.test.ts | 1 + test/index.test.ts | 59 ++++++++++++++++ test/storage.test.ts | 71 ++++++++++++++++++++ 8 files changed, 321 insertions(+), 3 deletions(-) diff --git a/index.ts b/index.ts index 5664f745..46433e81 100644 --- a/index.ts +++ b/index.ts @@ -112,6 +112,7 @@ import { loadAccounts, saveAccounts, withAccountStorageTransaction, + cleanupDuplicateEmailAccounts, clearAccounts, setStoragePath, exportAccounts, @@ -3918,6 +3919,27 @@ while (attempted.size < Math.max(1, accountCount)) { } }; + const runDuplicateEmailCleanup = async (): Promise => { + try { + const result = await cleanupDuplicateEmailAccounts(); + if (result.removed > 0) { + invalidateAccountManagerCache(); + console.log(""); + console.log("Duplicate email cleanup complete."); + console.log(`Before: ${result.before}`); + console.log(`After: ${result.after}`); + console.log(`Removed duplicates: ${result.removed}`); + console.log(""); + return; + } + + console.log("\nNo duplicate emails found.\n"); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + console.log(`\nDuplicate email cleanup failed: ${message}\n`); + } + }; + const pickBestAccountFromDashboard = async (): Promise => { const storage = await loadAccounts(); if (!storage || storage.accounts.length === 0) { @@ -4110,6 +4132,10 @@ while (attempted.size < Math.max(1, accountCount)) { await runCodexMultiAuthOverlapCleanup(); continue; } + if (menuResult.mode === "maintenance-clean-duplicate-emails") { + await runDuplicateEmailCleanup(); + continue; + } if (menuResult.mode === "manage") { if (typeof menuResult.deleteAccountIndex === "number") { diff --git a/lib/cli.ts b/lib/cli.ts index 2cc5a57f..79025aba 100644 --- a/lib/cli.ts +++ b/lib/cli.ts @@ -140,6 +140,7 @@ export type LoginMode = | "experimental-toggle-sync" | "experimental-sync-now" | "experimental-cleanup-overlaps" + | "maintenance-clean-duplicate-emails" | "fresh" | "manage" | "check" @@ -222,7 +223,7 @@ async function promptSettingsModeFallback( while (true) { const syncState = syncFromCodexMultiAuthEnabled ? "enabled" : "disabled"; const answer = await rl.question( - `(t) toggle sync [${syncState}], (i) sync now, (c) cleanup overlaps, (b) back [t/i/c/b]: `, + `(t) toggle sync [${syncState}], (i) sync now, (c) cleanup overlaps, (d) clean duplicate emails, (b) back [t/i/c/d/b]: `, ); const normalized = answer.trim().toLowerCase(); if (normalized === "t" || normalized === "toggle") { @@ -234,10 +235,13 @@ async function promptSettingsModeFallback( if (normalized === "c" || normalized === "cleanup") { return { mode: "experimental-cleanup-overlaps" }; } + if (normalized === "d" || normalized === "dedupe" || normalized === "duplicates") { + return { mode: "maintenance-clean-duplicate-emails" }; + } if (normalized === "b" || normalized === "back") { return null; } - console.log("Use one of: t, i, c, b."); + console.log("Use one of: t, i, c, d, b."); } } @@ -311,6 +315,7 @@ export async function promptLoginMode( if (settingsAction === "toggle-sync") return { mode: "experimental-toggle-sync" }; if (settingsAction === "sync-now") return { mode: "experimental-sync-now" }; if (settingsAction === "cleanup-overlaps") return { mode: "experimental-cleanup-overlaps" }; + if (settingsAction === "cleanup-duplicate-emails") return { mode: "maintenance-clean-duplicate-emails" }; continue; } case "fresh": diff --git a/lib/storage.ts b/lib/storage.ts index f5b447cd..1175d2c6 100644 --- a/lib/storage.ts +++ b/lib/storage.ts @@ -62,6 +62,12 @@ export interface ImportAccountsResult { backupError?: string; } +export interface CleanupDuplicateEmailAccountsResult { + before: number; + after: number; + removed: number; +} + /** * Custom error class for storage operations with platform-aware hints. */ @@ -415,6 +421,49 @@ function mergeAccountRecords(target: T, source: T): T { }; } +function normalizeEmailIdentity(value: string | undefined): string | undefined { + const trimmed = value?.trim(); + return trimmed && trimmed.length > 0 ? trimmed.toLowerCase() : undefined; +} + +function deduplicateAccountsByEmailForMaintenance(accounts: T[]): T[] { + const working = [...accounts]; + const emailToIndex = new Map(); + const indicesToRemove = new Set(); + + for (let i = 0; i < working.length; i += 1) { + const account = working[i]; + if (!account) continue; + + const email = normalizeEmailIdentity(account.email); + if (!email) continue; + + const existingIndex = emailToIndex.get(email); + if (existingIndex === undefined) { + emailToIndex.set(email, i); + continue; + } + + const newestIndex = pickNewestAccountIndex(working, existingIndex, i); + const obsoleteIndex = newestIndex === existingIndex ? i : existingIndex; + const newest = working[newestIndex]; + const older = working[obsoleteIndex]; + if (newest && older) { + working[newestIndex] = mergeAccountRecords(newest, older); + } + indicesToRemove.add(obsoleteIndex); + emailToIndex.set(email, newestIndex); + } + + const deduplicated: T[] = []; + for (let i = 0; i < working.length; i += 1) { + if (indicesToRemove.has(i)) continue; + const account = working[i]; + if (account) deduplicated.push(account); + } + return deduplicated; +} + /** * Removes duplicate accounts, keeping the most recently used entry for each unique key. * Deduplication identity hierarchy: organizationId -> accountId -> refreshToken. @@ -559,6 +608,15 @@ function extractActiveKeys(accounts: unknown[], activeIndex: number): string[] { }); } +function extractActiveEmail(accounts: unknown[], activeIndex: number): string | undefined { + const candidate = accounts[activeIndex]; + if (!isRecord(candidate)) return undefined; + + return typeof candidate.email === "string" + ? normalizeEmailIdentity(candidate.email) + : undefined; +} + function findAccountIndexByIdentityKeys( accounts: Pick[], identityKeys: string[], @@ -573,6 +631,14 @@ function findAccountIndexByIdentityKeys( return -1; } +function findAccountIndexByNormalizedEmail( + accounts: Pick[], + normalizedEmail: string | undefined, +): number { + if (!normalizedEmail) return -1; + return accounts.findIndex((account) => normalizeEmailIdentity(account.email) === normalizedEmail); +} + /** * Normalizes and validates account storage data, migrating from v1 to v3 if needed. * Handles deduplication, index clamping, and per-family active index mapping. @@ -916,6 +982,88 @@ export async function clearAccounts(): Promise { }); } +export async function cleanupDuplicateEmailAccounts(): Promise { + return withAccountStorageTransaction(async (current, persist) => { + const existing: AccountStorageV3 = + current ?? + ({ + version: 3, + accounts: [], + activeIndex: 0, + activeIndexByFamily: {}, + } satisfies AccountStorageV3); + const before = existing.accounts.length; + const existingActiveIndex = clampIndex(existing.activeIndex, existing.accounts.length); + const existingActiveKeys = extractActiveKeys(existing.accounts, existingActiveIndex); + const existingActiveEmail = extractActiveEmail(existing.accounts, existingActiveIndex); + const deduplicatedAccounts = deduplicateAccountsByEmailForMaintenance(existing.accounts); + const after = deduplicatedAccounts.length; + const removed = Math.max(0, before - after); + + if (removed === 0) { + return { + before, + after, + removed, + }; + } + + const mappedActiveIndex = (() => { + if (deduplicatedAccounts.length === 0) return 0; + if (existingActiveKeys.length > 0) { + const byIdentity = findAccountIndexByIdentityKeys(deduplicatedAccounts, existingActiveKeys); + if (byIdentity >= 0) return byIdentity; + } + const byEmail = findAccountIndexByNormalizedEmail(deduplicatedAccounts, existingActiveEmail); + if (byEmail >= 0) return byEmail; + return clampIndex(existingActiveIndex, deduplicatedAccounts.length); + })(); + + const activeIndexByFamily: Partial> = {}; + const rawFamilyIndices = existing.activeIndexByFamily ?? {}; + + for (const family of MODEL_FAMILIES) { + const rawIndexValue = rawFamilyIndices[family]; + const rawIndex = + typeof rawIndexValue === "number" && Number.isFinite(rawIndexValue) + ? rawIndexValue + : existingActiveIndex; + const clampedRawIndex = clampIndex(rawIndex, existing.accounts.length); + const familyKeys = extractActiveKeys(existing.accounts, clampedRawIndex); + const familyEmail = extractActiveEmail(existing.accounts, clampedRawIndex); + + let mappedIndex = mappedActiveIndex; + if (familyKeys.length > 0) { + const byIdentity = findAccountIndexByIdentityKeys(deduplicatedAccounts, familyKeys); + if (byIdentity >= 0) { + mappedIndex = byIdentity; + activeIndexByFamily[family] = mappedIndex; + continue; + } + } + + const byEmail = findAccountIndexByNormalizedEmail(deduplicatedAccounts, familyEmail); + if (byEmail >= 0) { + mappedIndex = byEmail; + } + activeIndexByFamily[family] = mappedIndex; + } + + await persist({ + version: 3, + accounts: deduplicatedAccounts, + activeIndex: mappedActiveIndex, + activeIndexByFamily, + }); + + return { + before, + after, + removed, + }; + }); +} + function normalizeFlaggedStorage(data: unknown): FlaggedAccountStorageV1 { if (!isRecord(data) || data.version !== 1 || !Array.isArray(data.accounts)) { return { version: 1, accounts: [] }; diff --git a/lib/ui/auth-menu.ts b/lib/ui/auth-menu.ts index 2ecfadc8..96a9135c 100644 --- a/lib/ui/auth-menu.ts +++ b/lib/ui/auth-menu.ts @@ -53,7 +53,13 @@ export type AuthMenuAction = | { type: "cancel" }; export type AccountAction = "back" | "delete" | "refresh" | "toggle" | "set-current" | "cancel"; -export type SettingsAction = "toggle-sync" | "sync-now" | "cleanup-overlaps" | "back" | "cancel"; +export type SettingsAction = + | "toggle-sync" + | "sync-now" + | "cleanup-duplicate-emails" + | "cleanup-overlaps" + | "back" + | "cancel"; // biome-ignore lint/suspicious/noControlCharactersInRegex: matching ANSI escape codes const ANSI_CSI_REGEX = new RegExp("\\x1b\\[[0-?]*[ -/]*[@-~]", "g"); @@ -374,6 +380,7 @@ export async function showSettingsMenu( { label: UI_COPY.settings.syncNow, value: "sync-now", color: "cyan" }, { label: "", value: "cancel", separator: true }, { label: UI_COPY.settings.maintenanceHeading, value: "cancel", kind: "heading" }, + { label: UI_COPY.settings.cleanupDuplicateEmails, value: "cleanup-duplicate-emails", color: "yellow" }, { label: UI_COPY.settings.cleanupOverlaps, value: "cleanup-overlaps", color: "yellow" }, { label: "", value: "cancel", separator: true }, { label: UI_COPY.settings.navigationHeading, value: "cancel", kind: "heading" }, diff --git a/lib/ui/copy.ts b/lib/ui/copy.ts index 443b0c24..39d3d48e 100644 --- a/lib/ui/copy.ts +++ b/lib/ui/copy.ts @@ -36,6 +36,7 @@ export const UI_COPY = { navigationHeading: "Navigation", syncToggle: "Sync from codex-multi-auth", syncNow: "Sync Now", + cleanupDuplicateEmails: "Clean Duplicate Emails", cleanupOverlaps: "Cleanup Synced Overlaps", back: "Back", }, diff --git a/test/auth-menu.test.ts b/test/auth-menu.test.ts index b736a5cc..472cc1f6 100644 --- a/test/auth-menu.test.ts +++ b/test/auth-menu.test.ts @@ -103,6 +103,7 @@ describe("auth-menu", () => { expect(toggleItem?.label).toContain("[enabled]"); expect(items.some((item) => item.label === "Sync")).toBe(true); expect(items.some((item) => item.label === "Maintenance")).toBe(true); + expect(items.some((item) => item.value === "cleanup-duplicate-emails")).toBe(true); expect(items.some((item) => item.label === "Navigation")).toBe(true); }); diff --git a/test/index.test.ts b/test/index.test.ts index 2231f089..6e32fd20 100644 --- a/test/index.test.ts +++ b/test/index.test.ts @@ -279,6 +279,11 @@ vi.mock("../lib/storage.js", () => ({ await callback(loadedStorage, persist); }, ), + cleanupDuplicateEmailAccounts: vi.fn(async () => ({ + before: 0, + after: 0, + removed: 0, + })), clearAccounts: vi.fn(async () => {}), setStoragePath: vi.fn(), exportAccounts: vi.fn(async () => {}), @@ -2712,6 +2717,60 @@ describe("OpenAIOAuthPlugin persistAccountPool", () => { expect(mockStorage.accounts).toHaveLength(1); expect(mockStorage.accounts[0]?.email).toBe("keep@example.com"); }); + + it("runs duplicate email cleanup from maintenance settings", async () => { + const cliModule = await import("../lib/cli.js"); + const storageModule = await import("../lib/storage.js"); + + mockStorage.accounts = [ + { + accountId: "org-older", + organizationId: "org-older", + accountIdSource: "org", + email: "shared@example.com", + refreshToken: "refresh-older", + lastUsed: 1, + }, + { + accountId: "org-newer", + organizationId: "org-newer", + accountIdSource: "org", + email: "shared@example.com", + refreshToken: "refresh-newer", + lastUsed: 2, + }, + ]; + mockStorage.activeIndex = 0; + mockStorage.activeIndexByFamily = {}; + + vi.mocked(cliModule.promptLoginMode) + .mockResolvedValueOnce({ mode: "maintenance-clean-duplicate-emails" }) + .mockResolvedValueOnce({ mode: "cancel" }); + + vi.mocked(storageModule.cleanupDuplicateEmailAccounts).mockImplementationOnce(async () => { + mockStorage.accounts = [mockStorage.accounts[1]].filter(Boolean) as typeof mockStorage.accounts; + mockStorage.activeIndex = 0; + mockStorage.activeIndexByFamily = {}; + return { + before: 2, + after: 1, + removed: 1, + }; + }); + + const mockClient = createMockClient(); + const { OpenAIOAuthPlugin } = await import("../index.js"); + const plugin = (await OpenAIOAuthPlugin({ client: mockClient } as never)) as unknown as PluginType; + const autoMethod = plugin.auth.methods[0] as unknown as { + authorize: (inputs?: Record) => Promise<{ instructions: string }>; + }; + + const authResult = await autoMethod.authorize(); + expect(authResult.instructions).toBe("Authentication cancelled"); + expect(vi.mocked(storageModule.cleanupDuplicateEmailAccounts)).toHaveBeenCalledTimes(1); + expect(mockStorage.accounts).toHaveLength(1); + expect(mockStorage.accounts[0]?.accountId).toBe("org-newer"); + }); }); describe("OpenAIOAuthPlugin showToast error handling", () => { diff --git a/test/storage.test.ts b/test/storage.test.ts index 66aab8ba..06afbd90 100644 --- a/test/storage.test.ts +++ b/test/storage.test.ts @@ -21,6 +21,7 @@ import { previewImportAccounts, createTimestampedBackupPath, withAccountStorageTransaction, + cleanupDuplicateEmailAccounts, } from "../lib/storage.js"; // Mocking the behavior we're about to implement for TDD @@ -89,6 +90,76 @@ describe("storage", () => { expect(deduped[0]?.addedAt).toBe(now - 1500); expect(deduped[0]?.lastUsed).toBe(now); }); + + it("cleans duplicate emails across local accounts and remaps active indices", async () => { + const testStoragePath = join( + tmpdir(), + `codex-clean-duplicate-emails-${Math.random().toString(36).slice(2)}.json`, + ); + setStoragePathDirect(testStoragePath); + + try { + await saveAccounts({ + version: 3, + activeIndex: 0, + activeIndexByFamily: { + codex: 0, + "gpt-5.1": 1, + }, + accounts: [ + { + accountId: "org-older", + organizationId: "org-older", + accountIdSource: "org", + email: "shared@example.com", + refreshToken: "rt-older", + addedAt: 1, + lastUsed: 1, + }, + { + accountId: "org-newer", + organizationId: "org-newer", + accountIdSource: "org", + email: "shared@example.com", + refreshToken: "rt-newer", + addedAt: 2, + lastUsed: 2, + }, + { + accountId: "org-unique", + organizationId: "org-unique", + accountIdSource: "org", + email: "unique@example.com", + refreshToken: "rt-unique", + addedAt: 3, + lastUsed: 3, + }, + ], + }); + + await expect(cleanupDuplicateEmailAccounts()).resolves.toEqual({ + before: 3, + after: 2, + removed: 1, + }); + + const loaded = await loadAccounts(); + expect(loaded?.accounts).toHaveLength(2); + expect(loaded?.accounts[0]).toMatchObject({ + accountId: "org-newer", + organizationId: "org-newer", + email: "shared@example.com", + refreshToken: "rt-newer", + }); + expect(loaded?.accounts[1]?.email).toBe("unique@example.com"); + expect(loaded?.activeIndex).toBe(0); + expect(loaded?.activeIndexByFamily?.codex).toBe(0); + expect(loaded?.activeIndexByFamily?.["gpt-5.1"]).toBe(0); + } finally { + setStoragePathDirect(null); + await fs.rm(testStoragePath, { force: true }); + } + }); }); describe("import/export (TDD)", () => { From e1bde0175e5d3cd588a300639869e37d2e757a40 Mon Sep 17 00:00:00 2001 From: ndycode Date: Sat, 7 Mar 2026 17:19:40 +0800 Subject: [PATCH 11/81] fix: rewrite health check frames in place Updates the dashboard and operation progress renderers to clear and redraw previous lines instead of appending full-screen frames in terminals that do not honor full-screen repaint escapes. Co-authored-by: Codex --- index.ts | 44 ++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 42 insertions(+), 2 deletions(-) diff --git a/index.ts b/index.ts index 46433e81..10384c0f 100644 --- a/index.ts +++ b/index.ts @@ -1249,6 +1249,8 @@ export const OpenAIOAuthPlugin: Plugin = async ({ client }: PluginInput) => { const lines: Array<{ line: string; tone: "normal" | "muted" | "success" | "warning" | "danger" | "accent" }> = []; let footer = "Running..."; + let renderedLineCount = 0; + let hasRendered = false; const stripAnsi = (value: string): string => sanitizeScreenText(value); const truncateAnsi = (value: string, maxVisibleChars: number): string => { if (maxVisibleChars <= 0) return ""; @@ -1274,6 +1276,24 @@ export const OpenAIOAuthPlugin: Plugin = async ({ client }: PluginInput) => { } return output + suffix; }; + const renderFrame = (frameLines: string[]) => { + const previousLineCount = renderedLineCount; + const nextLineCount = Math.max(previousLineCount, frameLines.length); + + if (!hasRendered) { + process.stdout.write(ANSI.clearScreen + ANSI.moveTo(1, 1)); + } else if (previousLineCount > 0) { + process.stdout.write(ANSI.up(previousLineCount)); + } + + for (let i = 0; i < nextLineCount; i += 1) { + const line = frameLines[i] ?? ""; + process.stdout.write(`${ANSI.clearLine}${line}\n`); + } + + renderedLineCount = nextLineCount; + hasRendered = true; + }; const render = () => { const screenLines: string[] = []; const columns = process.stdout.columns ?? 120; @@ -1289,7 +1309,7 @@ export const OpenAIOAuthPlugin: Plugin = async ({ client }: PluginInput) => { ); screenLines.push(""); screenLines.push(paintUiText(ui, sanitizeScreenText(footer), "muted")); - process.stdout.write(ANSI.clearScreen + ANSI.moveTo(1, 1) + screenLines.join("\n")); + renderFrame(screenLines); }; render(); @@ -1451,11 +1471,31 @@ export const OpenAIOAuthPlugin: Plugin = async ({ client }: PluginInput) => { if (!ui.v2Enabled || !isInteractiveTTY()) return null; const stripAnsi = (value: string): string => sanitizeScreenText(value); + let renderedLineCount = 0; + let hasRendered = false; const truncate = (value: string, maxVisibleChars: number): string => { const visible = stripAnsi(value); if (visible.length <= maxVisibleChars) return value; return `${visible.slice(0, Math.max(0, maxVisibleChars - 3))}...`; }; + const renderFrame = (frameLines: string[]) => { + const previousLineCount = renderedLineCount; + const nextLineCount = Math.max(previousLineCount, frameLines.length); + + if (!hasRendered) { + process.stdout.write(ANSI.clearScreen + ANSI.moveTo(1, 1)); + } else if (previousLineCount > 0) { + process.stdout.write(ANSI.up(previousLineCount)); + } + + for (let i = 0; i < nextLineCount; i += 1) { + const line = frameLines[i] ?? ""; + process.stdout.write(`${ANSI.clearLine}${line}\n`); + } + + renderedLineCount = nextLineCount; + hasRendered = true; + }; const statusBadgeForRow = (row: CheckDashboardRow): string => { switch (row.status) { @@ -1502,7 +1542,7 @@ export const OpenAIOAuthPlugin: Plugin = async ({ client }: PluginInput) => { } lines.push(""); lines.push(paintUiText(ui, sanitizeScreenText(footer), "muted")); - process.stdout.write(ANSI.clearScreen + ANSI.moveTo(1, 1) + lines.join("\n")); + renderFrame(lines); }; return { From 495869cb99ef61342b7fd12a4ea6431c3c45c746 Mon Sep 17 00:00:00 2001 From: ndycode Date: Sat, 7 Mar 2026 17:23:02 +0800 Subject: [PATCH 12/81] fix: disable live repaint renderers in opencode hosts Routes progress screens through the safe non-inline path when running inside OpenCode-hosted terminals that do not reliably honor cursor rewrite escapes. Co-authored-by: Codex --- index.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/index.ts b/index.ts index 10384c0f..64c68f2f 100644 --- a/index.ts +++ b/index.ts @@ -157,7 +157,7 @@ import { buildTableHeader, buildTableRow, type TableOptions } from "./lib/table- import { setUiRuntimeOptions, type UiRuntimeOptions } from "./lib/ui/runtime.js"; import { paintUiText, formatUiBadge, formatUiHeader, formatUiItem, formatUiKeyValue, formatUiSection } from "./lib/ui/format.js"; import { confirm } from "./lib/ui/confirm.js"; -import { ANSI, isTTY as isInteractiveTTY } from "./lib/ui/ansi.js"; +import { ANSI } from "./lib/ui/ansi.js"; import { buildBeginnerChecklist, buildBeginnerDoctorFindings, @@ -1243,7 +1243,7 @@ export const OpenAIOAuthPlugin: Plugin = async ({ client }: PluginInput) => { push: (line: string, tone?: "normal" | "muted" | "success" | "warning" | "danger" | "accent") => void; finish: (summaryLines?: Array<{ line: string; tone?: "normal" | "muted" | "success" | "warning" | "danger" | "accent" }>) => Promise; } | null => { - if (!ui.v2Enabled || !isInteractiveTTY()) { + if (!ui.v2Enabled || !supportsInteractiveMenus()) { return null; } @@ -1468,7 +1468,7 @@ export const OpenAIOAuthPlugin: Plugin = async ({ client }: PluginInput) => { render: (progressText: string, footer?: string) => void; finish: (progressText: string, footer?: string) => Promise; } | null => { - if (!ui.v2Enabled || !isInteractiveTTY()) return null; + if (!ui.v2Enabled || !supportsInteractiveMenus()) return null; const stripAnsi = (value: string): string => sanitizeScreenText(value); let renderedLineCount = 0; From 80214e640fe2d2af866da6eede4f983f9f39e412 Mon Sep 17 00:00:00 2001 From: ndycode Date: Sat, 7 Mar 2026 17:28:29 +0800 Subject: [PATCH 13/81] fix: remove live progress repaint screens Drops inline progress screen rendering for health checks and related maintenance flows so terminal hosts always use the safe append-only output path. Co-authored-by: Codex --- index.ts | 254 ++----------------------------------------------------- 1 file changed, 9 insertions(+), 245 deletions(-) diff --git a/index.ts b/index.ts index 64c68f2f..0d01f488 100644 --- a/index.ts +++ b/index.ts @@ -157,7 +157,6 @@ import { buildTableHeader, buildTableRow, type TableOptions } from "./lib/table- import { setUiRuntimeOptions, type UiRuntimeOptions } from "./lib/ui/runtime.js"; import { paintUiText, formatUiBadge, formatUiHeader, formatUiItem, formatUiKeyValue, formatUiSection } from "./lib/ui/format.js"; import { confirm } from "./lib/ui/confirm.js"; -import { ANSI } from "./lib/ui/ansi.js"; import { buildBeginnerChecklist, buildBeginnerDoctorFindings, @@ -213,13 +212,6 @@ import { */ // eslint-disable-next-line @typescript-eslint/require-await export const OpenAIOAuthPlugin: Plugin = async ({ client }: PluginInput) => { - // biome-ignore lint/suspicious/noControlCharactersInRegex: matching ANSI escape codes - const ANSI_STYLE_REGEX = new RegExp("\\x1b\\[[0-9;]*m", "g"); - // biome-ignore lint/suspicious/noControlCharactersInRegex: matching ANSI escape codes - const ANSI_STYLE_PREFIX_REGEX = new RegExp("^\\x1b\\[[0-9;]*m"); - const SCREEN_CONTROL_CHAR_REGEX = new RegExp("[\\u0000-\\u0008\\u000b\\u000c\\u000e-\\u001f\\u007f]", "g"); - const sanitizeScreenText = (value: string): string => - value.replace(ANSI_STYLE_REGEX, "").replace(SCREEN_CONTROL_CHAR_REGEX, "").trim(); initLogger(client); let cachedAccountManager: AccountManager | null = null; let accountManagerPromise: Promise | null = null; @@ -1243,159 +1235,10 @@ export const OpenAIOAuthPlugin: Plugin = async ({ client }: PluginInput) => { push: (line: string, tone?: "normal" | "muted" | "success" | "warning" | "danger" | "accent") => void; finish: (summaryLines?: Array<{ line: string; tone?: "normal" | "muted" | "success" | "warning" | "danger" | "accent" }>) => Promise; } | null => { - if (!ui.v2Enabled || !supportsInteractiveMenus()) { - return null; - } - - const lines: Array<{ line: string; tone: "normal" | "muted" | "success" | "warning" | "danger" | "accent" }> = []; - let footer = "Running..."; - let renderedLineCount = 0; - let hasRendered = false; - const stripAnsi = (value: string): string => sanitizeScreenText(value); - const truncateAnsi = (value: string, maxVisibleChars: number): string => { - if (maxVisibleChars <= 0) return ""; - const visible = stripAnsi(value); - if (visible.length <= maxVisibleChars) return value; - const suffix = maxVisibleChars >= 3 ? "..." : ".".repeat(maxVisibleChars); - const keep = Math.max(0, maxVisibleChars - suffix.length); - let kept = 0; - let index = 0; - let output = ""; - while (index < value.length && kept < keep) { - if (value[index] === "\x1b") { - const match = value.slice(index).match(ANSI_STYLE_PREFIX_REGEX); - if (match) { - output += match[0]; - index += match[0].length; - continue; - } - } - output += value[index]; - index += 1; - kept += 1; - } - return output + suffix; - }; - const renderFrame = (frameLines: string[]) => { - const previousLineCount = renderedLineCount; - const nextLineCount = Math.max(previousLineCount, frameLines.length); - - if (!hasRendered) { - process.stdout.write(ANSI.clearScreen + ANSI.moveTo(1, 1)); - } else if (previousLineCount > 0) { - process.stdout.write(ANSI.up(previousLineCount)); - } - - for (let i = 0; i < nextLineCount; i += 1) { - const line = frameLines[i] ?? ""; - process.stdout.write(`${ANSI.clearLine}${line}\n`); - } - - renderedLineCount = nextLineCount; - hasRendered = true; - }; - const render = () => { - const screenLines: string[] = []; - const columns = process.stdout.columns ?? 120; - screenLines.push(...formatUiHeader(ui, sanitizeScreenText(title))); - if (subtitle) { - screenLines.push(paintUiText(ui, sanitizeScreenText(subtitle), "muted")); - } - screenLines.push(""); - screenLines.push( - ...lines.map((entry) => - truncateAnsi(paintUiText(ui, entry.line, entry.tone), Math.max(20, columns - 2)), - ), - ); - screenLines.push(""); - screenLines.push(paintUiText(ui, sanitizeScreenText(footer), "muted")); - renderFrame(screenLines); - }; - - render(); - return { - push: (line: string, tone = "normal") => { - lines.push({ line: sanitizeScreenText(line), tone }); - render(); - }, - finish: async (summaryLines?: Array<{ line: string; tone?: "normal" | "muted" | "success" | "warning" | "danger" | "accent" }>) => { - if (summaryLines && summaryLines.length > 0) { - lines.push({ line: "", tone: "normal" }); - for (const entry of summaryLines) { - lines.push({ line: sanitizeScreenText(entry.line), tone: entry.tone ?? "normal" }); - } - } - footer = "Done."; - render(); - const autoReturnMs = 2_000; - const { stdin } = process; - const wasRaw = stdin.isRaw ?? false; - const waitForAnyKey = async (message: string): Promise => { - footer = message; - render(); - await new Promise((resolve) => { - const onData = () => { - stdin.off("data", onData); - resolve(); - }; - try { - stdin.setRawMode(true); - } catch { - resolve(); - return; - } - stdin.resume(); - stdin.on("data", onData); - }); - }; - let paused = false; - await new Promise((resolve) => { - let finished = false; - let timer: ReturnType | null = null; - const endAt = Date.now() + autoReturnMs; - const onData = () => { - if (finished) return; - paused = true; - finished = true; - if (timer) clearInterval(timer); - stdin.off("data", onData); - resolve(); - }; - try { - stdin.setRawMode(true); - stdin.resume(); - stdin.on("data", onData); - } catch { - resolve(); - return; - } - timer = setInterval(() => { - const remaining = Math.max(0, endAt - Date.now()); - if (remaining <= 0) { - if (finished) return; - finished = true; - if (timer) { - clearInterval(timer); - } - stdin.off("data", onData); - resolve(); - return; - } - footer = `Returning in ${Math.ceil(remaining / 1000)}s... Press any key to pause.`; - render(); - }, 150); - }); - if (paused) { - await waitForAnyKey("Paused. Press any key to continue."); - } - try { - stdin.setRawMode(wasRaw); - stdin.pause(); - } catch { - // best effort restore - } - }, - }; + void ui; + void title; + void subtitle; + return null; }; const getStatusMarker = ( @@ -1468,90 +1311,11 @@ export const OpenAIOAuthPlugin: Plugin = async ({ client }: PluginInput) => { render: (progressText: string, footer?: string) => void; finish: (progressText: string, footer?: string) => Promise; } | null => { - if (!ui.v2Enabled || !supportsInteractiveMenus()) return null; - - const stripAnsi = (value: string): string => sanitizeScreenText(value); - let renderedLineCount = 0; - let hasRendered = false; - const truncate = (value: string, maxVisibleChars: number): string => { - const visible = stripAnsi(value); - if (visible.length <= maxVisibleChars) return value; - return `${visible.slice(0, Math.max(0, maxVisibleChars - 3))}...`; - }; - const renderFrame = (frameLines: string[]) => { - const previousLineCount = renderedLineCount; - const nextLineCount = Math.max(previousLineCount, frameLines.length); - - if (!hasRendered) { - process.stdout.write(ANSI.clearScreen + ANSI.moveTo(1, 1)); - } else if (previousLineCount > 0) { - process.stdout.write(ANSI.up(previousLineCount)); - } - - for (let i = 0; i < nextLineCount; i += 1) { - const line = frameLines[i] ?? ""; - process.stdout.write(`${ANSI.clearLine}${line}\n`); - } - - renderedLineCount = nextLineCount; - hasRendered = true; - }; - - const statusBadgeForRow = (row: CheckDashboardRow): string => { - switch (row.status) { - case "current": - return `${formatUiBadge(ui, "current", "accent")} ${formatUiBadge(ui, "active", "success")}`; - case "ok": - return formatUiBadge(ui, "ok", "success"); - case "warning": - return formatUiBadge(ui, "warning", "warning"); - case "danger": - return formatUiBadge(ui, "error", "danger"); - case "disabled": - return formatUiBadge(ui, "disabled", "danger"); - } - }; - - const spinnerFrames = ["-", "\\", "|", "/"]; - const render = (progressText: string, footer = "Running...") => { - const cols = process.stdout.columns ?? 120; - const spinner = spinnerFrames[Math.floor(Date.now() / 150) % spinnerFrames.length] ?? "-"; - const lines: string[] = []; - lines.push(...formatUiHeader(ui, "Accounts Dashboard")); - lines.push(paintUiText(ui, sanitizeScreenText(`${spinner} ${progressText}`), "muted")); - lines.push(""); - lines.push(paintUiText(ui, "Quick Actions", "muted")); - lines.push(` ${paintUiText(ui, "o", "muted")} ${paintUiText(ui, "Add New Account", "success")}`); - lines.push(` ${paintUiText(ui, "o", "muted")} ${paintUiText(ui, "Run Health Check", "success")}`); - lines.push(` ${paintUiText(ui, "o", "muted")} ${paintUiText(ui, "Pick Best Account", "success")}`); - lines.push(` ${paintUiText(ui, "o", "muted")} ${paintUiText(ui, "Auto-Repair Issues", "success")}`); - lines.push(` ${paintUiText(ui, "o", "muted")} ${paintUiText(ui, "Settings", "success")}`); - lines.push(""); - lines.push(paintUiText(ui, "Advanced Checks", "muted")); - lines.push(` ${paintUiText(ui, "o", "muted")} ${paintUiText(ui, "Refresh All Accounts", "success")}`); - lines.push(` ${paintUiText(ui, "o", "muted")} ${paintUiText(ui, "Check Problem Accounts", "warning")}`); - lines.push(""); - lines.push(paintUiText(ui, "Saved Accounts", "muted")); - for (const row of rows) { - const marker = row.index === selectedIndex ? ">" : "o"; - const primary = `${row.index + 1}. ${sanitizeScreenText(row.email ?? `Account ${row.index + 1}`)}`; - lines.push(` ${paintUiText(ui, marker, row.index === selectedIndex ? "accent" : "muted")} ${truncate(`${paintUiText(ui, primary, row.index === selectedIndex ? "accent" : "heading")} ${statusBadgeForRow(row)}`, Math.max(20, cols - 4))}`); - if (row.index === selectedIndex && row.detail) { - lines.push(` ${truncate(paintUiText(ui, `Limits: ${sanitizeScreenText(row.detail)}`, "muted"), Math.max(20, cols - 6))}`); - } - } - lines.push(""); - lines.push(paintUiText(ui, sanitizeScreenText(footer), "muted")); - renderFrame(lines); - }; - - return { - render, - finish: async (progressText: string, footer = "Done.") => { - render(progressText, footer); - await new Promise((resolve) => setTimeout(resolve, 1200)); - }, - }; + void ui; + void progressLabel; + void rows; + void selectedIndex; + return null; }; const normalizeAccountTags = (raw: string): string[] => { From 14b521ff3f8be03d65df6b188b7405497b6f72f2 Mon Sep 17 00:00:00 2001 From: ndycode Date: Sat, 7 Mar 2026 17:34:41 +0800 Subject: [PATCH 14/81] fix: move health check into dedicated results screen Replaces the dashboard-inline health check flow with a dedicated cleared screen that streams per-account results, matching the codex-multi-auth command-style flow more closely. Co-authored-by: Codex --- index.ts | 127 ++++++++++++++++++++++++++----------------------------- 1 file changed, 61 insertions(+), 66 deletions(-) diff --git a/index.ts b/index.ts index 0d01f488..63137252 100644 --- a/index.ts +++ b/index.ts @@ -157,6 +157,7 @@ import { buildTableHeader, buildTableRow, type TableOptions } from "./lib/table- import { setUiRuntimeOptions, type UiRuntimeOptions } from "./lib/ui/runtime.js"; import { paintUiText, formatUiBadge, formatUiHeader, formatUiItem, formatUiKeyValue, formatUiSection } from "./lib/ui/format.js"; import { confirm } from "./lib/ui/confirm.js"; +import { ANSI } from "./lib/ui/ansi.js"; import { buildBeginnerChecklist, buildBeginnerDoctorFindings, @@ -1235,10 +1236,42 @@ export const OpenAIOAuthPlugin: Plugin = async ({ client }: PluginInput) => { push: (line: string, tone?: "normal" | "muted" | "success" | "warning" | "danger" | "accent") => void; finish: (summaryLines?: Array<{ line: string; tone?: "normal" | "muted" | "success" | "warning" | "danger" | "accent" }>) => Promise; } | null => { - void ui; - void title; - void subtitle; - return null; + if (!ui.v2Enabled || !supportsInteractiveMenus()) { + return null; + } + + let initialized = false; + const ensureHeader = () => { + if (initialized) return; + process.stdout.write(ANSI.clearScreen + ANSI.moveTo(1, 1)); + for (const line of formatUiHeader(ui, title)) { + console.log(line); + } + if (subtitle) { + console.log(""); + console.log(paintUiText(ui, subtitle, "muted")); + } + console.log(""); + initialized = true; + }; + + ensureHeader(); + return { + push: (line: string, tone = "normal") => { + ensureHeader(); + console.log(paintUiText(ui, line, tone)); + }, + finish: (summaryLines) => { + ensureHeader(); + if (summaryLines && summaryLines.length > 0) { + console.log(""); + for (const entry of summaryLines) { + console.log(paintUiText(ui, entry.line, entry.tone ?? "normal")); + } + } + return Promise.resolve(); + }, + }; }; const getStatusMarker = ( @@ -1295,29 +1328,6 @@ export const OpenAIOAuthPlugin: Plugin = async ({ client }: PluginInput) => { return `Account ${index + 1} (${details.join(", ")})`; }; - type CheckDashboardRow = { - index: number; - email?: string; - status: "current" | "ok" | "warning" | "danger" | "disabled"; - detail?: string; - }; - - const createInlineCheckDashboardScreen = ( - ui: UiRuntimeOptions, - progressLabel: string, - rows: CheckDashboardRow[], - selectedIndex: number, - ): { - render: (progressText: string, footer?: string) => void; - finish: (progressText: string, footer?: string) => Promise; - } | null => { - void ui; - void progressLabel; - void rows; - void selectedIndex; - return null; - }; - const normalizeAccountTags = (raw: string): string[] => { return Array.from( new Set( @@ -3044,49 +3054,40 @@ while (attempted.size < Math.max(1, accountCount)) { : {}, } : { version: 3 as const, accounts: [], activeIndex: 0, activeIndexByFamily: {} }; - const activeIndex = resolveActiveIndex(workingStorage, "codex"); - const dashboardRows: CheckDashboardRow[] = workingStorage.accounts.map((account, index) => ({ - index, - email: account.email, - status: index === activeIndex ? "current" : account.enabled === false ? "disabled" : "ok", - detail: undefined, - })); - const dashboardScreen = createInlineCheckDashboardScreen( + const screen = createOperationScreen( ui, - deepProbe ? "Refreshing account limits..." : "Fetching account limits...", - dashboardRows, - activeIndex, + "Health check", + deepProbe + ? `Checking ${workingStorage.accounts.length} account(s) with full refresh + live validation` + : `Checking ${workingStorage.accounts.length} account(s) with quota validation`, ); const emit = ( index: number, detail: string, tone: "normal" | "muted" | "success" | "warning" | "danger" | "accent" = "normal", ) => { - const row = dashboardRows[index]; - if (row) { - row.detail = detail; - row.status = - row.status === "disabled" - ? "disabled" - : tone === "danger" - ? "danger" - : tone === "warning" - ? "warning" - : index === activeIndex - ? "current" - : "ok"; - } - if (dashboardScreen) { - dashboardScreen.render( - `${deepProbe ? "Refreshing" : "Fetching"} account limits... [${Math.min(index + 1, workingStorage.accounts.length)}/${workingStorage.accounts.length}]`, - ); + const account = workingStorage.accounts[index]; + const label = formatCommandAccountLabel(account, index); + const prefix = + tone === "danger" + ? getStatusMarker(ui, "error") + : tone === "warning" + ? getStatusMarker(ui, "warning") + : getStatusMarker(ui, "ok"); + const line = `${prefix} ${label} | ${detail}`; + if (screen) { + screen.push(line, tone); return; } - console.log(detail); + console.log(line); }; if (workingStorage.accounts.length === 0) { - console.log("No accounts to check."); + if (screen) { + screen.push("No accounts to check.", "warning"); + } else { + console.log("No accounts to check."); + } return; } @@ -3098,9 +3099,6 @@ while (attempted.size < Math.max(1, accountCount)) { let ok = 0; let disabled = 0; let errors = 0; - dashboardScreen?.render( - `${deepProbe ? "Refreshing" : "Fetching"} account limits... [0/${workingStorage.accounts.length}]`, - ); for (let i = 0; i < total; i += 1) { const account = workingStorage.accounts[i]; @@ -3322,11 +3320,8 @@ while (attempted.size < Math.max(1, accountCount)) { if (removeFromActive.size > 0) { summaryLines.push({ line: `Moved ${removeFromActive.size} account(s) to flagged pool (invalid refresh token).`, tone: "warning" as const }); } - if (dashboardScreen) { - await dashboardScreen.finish( - `${deepProbe ? "Refreshing" : "Fetching"} account limits... [${workingStorage.accounts.length}/${workingStorage.accounts.length}]`, - summaryLines.map((entry) => entry.line).join(" | "), - ); + if (screen) { + await screen.finish(summaryLines); return; } console.log(""); From 3e21205733f62bada6f7771bbcd5a6294b175860 Mon Sep 17 00:00:00 2001 From: ndycode Date: Sat, 7 Mar 2026 17:37:51 +0800 Subject: [PATCH 15/81] fix: clear full-screen menu before next view Ensures the TUI menu renderer tears down its previous frame before the next screen starts so command-style flows like health check open on a clean screen. Co-authored-by: Codex --- lib/ui/select.ts | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/lib/ui/select.ts b/lib/ui/select.ts index 2abcf6d3..3efb00c0 100644 --- a/lib/ui/select.ts +++ b/lib/ui/select.ts @@ -502,6 +502,15 @@ export async function select(items: MenuItem[], options: SelectOptions) clearInterval(refreshTimer); refreshTimer = null; } + if (options.clearScreen) { + stdout.write(ANSI.clearScreen + ANSI.moveTo(1, 1)); + } else if (renderedLines > 0) { + stdout.write(ANSI.up(renderedLines)); + for (let i = 0; i < renderedLines; i += 1) { + stdout.write(`${ANSI.clearLine}\n`); + } + stdout.write(ANSI.up(renderedLines)); + } stdout.write(ANSI.show); } catch { // best effort cleanup From 79728114a12750cfd39ea227ca25513c54cb92b5 Mon Sep 17 00:00:00 2001 From: ndycode Date: Sat, 7 Mar 2026 17:41:35 +0800 Subject: [PATCH 16/81] fix: open operation screens in alternate buffer Uses the alternate screen buffer for long-running operation views like health check so the dashboard is fully hidden while those screens run, matching the codex-multi-auth transition model more closely. Co-authored-by: Codex --- index.ts | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/index.ts b/index.ts index 63137252..5334089b 100644 --- a/index.ts +++ b/index.ts @@ -1243,7 +1243,7 @@ export const OpenAIOAuthPlugin: Plugin = async ({ client }: PluginInput) => { let initialized = false; const ensureHeader = () => { if (initialized) return; - process.stdout.write(ANSI.clearScreen + ANSI.moveTo(1, 1)); + process.stdout.write(ANSI.altScreenOn + ANSI.hide + ANSI.clearScreen + ANSI.moveTo(1, 1)); for (const line of formatUiHeader(ui, title)) { console.log(line); } @@ -1261,7 +1261,7 @@ export const OpenAIOAuthPlugin: Plugin = async ({ client }: PluginInput) => { ensureHeader(); console.log(paintUiText(ui, line, tone)); }, - finish: (summaryLines) => { + finish: async (summaryLines) => { ensureHeader(); if (summaryLines && summaryLines.length > 0) { console.log(""); @@ -1269,7 +1269,8 @@ export const OpenAIOAuthPlugin: Plugin = async ({ client }: PluginInput) => { console.log(paintUiText(ui, entry.line, entry.tone ?? "normal")); } } - return Promise.resolve(); + await new Promise((resolve) => setTimeout(resolve, 2_000)); + process.stdout.write(ANSI.altScreenOff + ANSI.show + ANSI.clearScreen + ANSI.moveTo(1, 1)); }, }; }; From daf8f4c08598bde641c82f4688d1f75599c33bb8 Mon Sep 17 00:00:00 2001 From: ndycode Date: Sat, 7 Mar 2026 18:24:45 +0800 Subject: [PATCH 17/81] fix: restore loading animation in operation screens Adds the stage spinner back inside the alternate-screen operation view so long-running flows keep the codex-multi-auth style loading animation without bleeding into the dashboard. Co-authored-by: Codex --- index.ts | 68 +++++++++++++++++++++++++++++++++++++++++++------------- 1 file changed, 53 insertions(+), 15 deletions(-) diff --git a/index.ts b/index.ts index 5334089b..4a6d490d 100644 --- a/index.ts +++ b/index.ts @@ -1240,35 +1240,73 @@ export const OpenAIOAuthPlugin: Plugin = async ({ client }: PluginInput) => { return null; } + const entries: Array<{ + line: string; + tone: "normal" | "muted" | "success" | "warning" | "danger" | "accent"; + }> = []; + const spinnerFrames = ["-", "\\", "|", "/"]; + let frame = 0; + let running = true; let initialized = false; - const ensureHeader = () => { - if (initialized) return; - process.stdout.write(ANSI.altScreenOn + ANSI.hide + ANSI.clearScreen + ANSI.moveTo(1, 1)); - for (const line of formatUiHeader(ui, title)) { - console.log(line); - } + let timer: NodeJS.Timeout | null = null; + + const render = () => { + const lines: string[] = []; + const maxVisibleLines = Math.max(8, (process.stdout.rows ?? 24) - 8); + const visibleEntries = entries.slice(-maxVisibleLines); + const spinner = running ? `${spinnerFrames[frame % spinnerFrames.length] ?? "-"} ` : "+ "; + const stageTone: "muted" | "accent" | "success" = running ? "accent" : "success"; + + lines.push(...formatUiHeader(ui, title)); + lines.push(""); if (subtitle) { - console.log(""); - console.log(paintUiText(ui, subtitle, "muted")); + lines.push(paintUiText(ui, `${spinner}${subtitle}`, stageTone)); + lines.push(""); + } + for (const entry of visibleEntries) { + lines.push(paintUiText(ui, entry.line, entry.tone)); } - console.log(""); + if (running) { + lines.push(""); + lines.push(paintUiText(ui, "Working...", "muted")); + } + + process.stdout.write(ANSI.clearScreen + ANSI.moveTo(1, 1) + lines.join("\n")); + frame += 1; + }; + + const ensureScreen = () => { + if (initialized) return; + process.stdout.write(ANSI.altScreenOn + ANSI.hide + ANSI.clearScreen + ANSI.moveTo(1, 1)); + render(); + timer = setInterval(() => { + if (!running) return; + render(); + }, 120); initialized = true; }; - ensureHeader(); + ensureScreen(); return { push: (line: string, tone = "normal") => { - ensureHeader(); - console.log(paintUiText(ui, line, tone)); + ensureScreen(); + entries.push({ line, tone }); + render(); }, finish: async (summaryLines) => { - ensureHeader(); + ensureScreen(); if (summaryLines && summaryLines.length > 0) { - console.log(""); + entries.push({ line: "", tone: "normal" }); for (const entry of summaryLines) { - console.log(paintUiText(ui, entry.line, entry.tone ?? "normal")); + entries.push({ line: entry.line, tone: entry.tone ?? "normal" }); } } + running = false; + if (timer) { + clearInterval(timer); + timer = null; + } + render(); await new Promise((resolve) => setTimeout(resolve, 2_000)); process.stdout.write(ANSI.altScreenOff + ANSI.show + ANSI.clearScreen + ANSI.moveTo(1, 1)); }, From e4ff2dc6ffacef31465e19a694dc7898234e8d32 Mon Sep 17 00:00:00 2001 From: ndycode Date: Sat, 7 Mar 2026 18:33:51 +0800 Subject: [PATCH 18/81] fix: preserve confirmed sync prune removals Ensures prune backups only protect the current removal attempt, so later preview/cancel paths do not roll back earlier confirmed account removals. Adds a regression covering the no-import retry path. Co-authored-by: Codex --- index.ts | 22 ++++----- test/index.test.ts | 120 ++++++++++++++++++++++++++++++++++++++++++++- 2 files changed, 129 insertions(+), 13 deletions(-) diff --git a/index.ts b/index.ts index 4a6d490d..9e6e4578 100644 --- a/index.ts +++ b/index.ts @@ -3616,6 +3616,12 @@ while (attempted.size < Math.max(1, accountCount)) { restore: () => Promise; } | null = null; + const restorePruneBackup = async (): Promise => { + const currentBackup = pruneBackup; + if (!currentBackup) return; + pruneBackup = null; + await currentBackup.restore(); + }; while (true) { try { const preview = await previewSyncFromCodexMultiAuth(process.cwd()); @@ -3629,7 +3635,7 @@ while (attempted.size < Math.max(1, accountCount)) { if (preview.imported <= 0) { if (pruneBackup) { try { - await pruneBackup.restore(); + await restorePruneBackup(); } catch (restoreError) { logWarn( `[${PLUGIN_NAME}] Failed to restore prune backup after zero-import preview: ${ @@ -3637,7 +3643,6 @@ while (attempted.size < Math.max(1, accountCount)) { }`, ); } - pruneBackup = null; } console.log("No new accounts to import.\n"); return; @@ -3647,9 +3652,7 @@ while (attempted.size < Math.max(1, accountCount)) { `Import ${preview.imported} new account(s) from codex-multi-auth?`, ); if (!confirmed) { - if (pruneBackup) { - await pruneBackup.restore(); - } + await restorePruneBackup(); console.log("\nSync cancelled.\n"); return; } @@ -3719,9 +3722,7 @@ while (attempted.size < Math.max(1, accountCount)) { `Remove ${indexesToRemove.length} selected account(s) and retry sync?`, ); if (!confirmed) { - if (pruneBackup) { - await pruneBackup.restore(); - } + await restorePruneBackup(); console.log("Sync cancelled.\n"); return; } @@ -3729,12 +3730,11 @@ while (attempted.size < Math.max(1, accountCount)) { pruneBackup = await createSyncPruneBackup(); } await removeAccountsForSync(indexesToRemove); + pruneBackup = null; continue; } const message = error instanceof Error ? error.message : String(error); - if (pruneBackup) { - await pruneBackup.restore(); - } + await restorePruneBackup(); console.log(`\nSync failed: ${message}\n`); return; } diff --git a/test/index.test.ts b/test/index.test.ts index 6e32fd20..11a2f758 100644 --- a/test/index.test.ts +++ b/test/index.test.ts @@ -1,4 +1,7 @@ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { promises as fs } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; vi.mock("@opencode-ai/plugin/tool", () => { const makeSchema = () => ({ @@ -80,6 +83,10 @@ vi.mock("../lib/cli.js", () => ({ promptCodexMultiAuthSyncPrune: vi.fn(async () => null), })); +vi.mock("../lib/ui/confirm.js", () => ({ + confirm: vi.fn(async () => true), +})); + vi.mock("../lib/config.js", () => ({ getCodexMode: () => true, getRequestTransformMode: () => "native", @@ -110,8 +117,8 @@ vi.mock("../lib/config.js", () => ({ getCodexTuiColorProfile: () => "ansi16", getCodexTuiGlyphMode: () => "ascii", getBeginnerSafeMode: () => false, - getSyncFromCodexMultiAuthEnabled: () => false, - loadPluginConfig: () => ({}), + getSyncFromCodexMultiAuthEnabled: vi.fn(() => false), + loadPluginConfig: vi.fn(() => ({})), setSyncFromCodexMultiAuthEnabled: vi.fn(), })); @@ -2771,6 +2778,115 @@ describe("OpenAIOAuthPlugin persistAccountPool", () => { expect(mockStorage.accounts).toHaveLength(1); expect(mockStorage.accounts[0]?.accountId).toBe("org-newer"); }); + + it("keeps previously confirmed prune removals when a later sync preview returns no imports", async () => { + const cliModule = await import("../lib/cli.js"); + const storageModule = await import("../lib/storage.js"); + const syncModule = await import("../lib/codex-multi-auth-sync.js"); + const configModule = await import("../lib/config.js"); + const confirmModule = await import("../lib/ui/confirm.js"); + + const tempDir = await fs.mkdtemp(join(tmpdir(), "oc-sync-prune-")); + try { + mockStorage.accounts = [ + { + accountId: "org-keep", + organizationId: "org-keep", + accountIdSource: "org", + email: "keep@example.com", + refreshToken: "refresh-keep", + }, + { + accountId: "org-prune", + organizationId: "org-prune", + accountIdSource: "org", + email: "prune@example.com", + refreshToken: "refresh-prune", + }, + ]; + mockStorage.activeIndex = 0; + mockStorage.activeIndexByFamily = {}; + + vi.mocked(cliModule.promptLoginMode) + .mockResolvedValueOnce({ mode: "experimental-sync-now" }) + .mockResolvedValueOnce({ mode: "cancel" }); + vi.mocked(cliModule.promptCodexMultiAuthSyncPrune) + .mockResolvedValueOnce([1]); + vi.mocked(configModule.getSyncFromCodexMultiAuthEnabled).mockReturnValue(true); + vi.mocked(confirmModule.confirm).mockResolvedValueOnce(true); + + vi.mocked(storageModule.createTimestampedBackupPath).mockImplementation((prefix?: string) => + join(tempDir, `${prefix ?? "codex-backup"}.json`), + ); + vi.mocked(storageModule.exportAccounts).mockImplementation(async (filePath: string) => { + await fs.writeFile( + filePath, + JSON.stringify({ + version: mockStorage.version, + activeIndex: mockStorage.activeIndex, + activeIndexByFamily: { ...mockStorage.activeIndexByFamily }, + accounts: mockStorage.accounts.map((account) => ({ ...account })), + }), + "utf8", + ); + }); + vi.mocked(storageModule.saveAccounts).mockImplementation(async (nextStorage) => { + mockStorage.version = nextStorage.version; + mockStorage.activeIndex = nextStorage.activeIndex; + mockStorage.activeIndexByFamily = { ...nextStorage.activeIndexByFamily }; + mockStorage.accounts = nextStorage.accounts.map((account) => ({ ...account })); + }); + + const capacityError = new syncModule.CodexMultiAuthSyncCapacityError({ + rootDir: tempDir, + accountsPath: join(tempDir, "openai-codex-accounts.json"), + scope: "global", + currentCount: 2, + sourceCount: 2, + sourceDedupedTotal: 3, + dedupedTotal: 3, + maxAccounts: 2, + needToRemove: 1, + importableNewAccounts: 1, + skippedOverlaps: 1, + suggestedRemovals: [ + { + index: 1, + email: "prune@example.com", + accountLabel: "Workspace prune", + isCurrentAccount: false, + score: 180, + reason: "disabled", + }, + ], + }); + + vi.mocked(syncModule.previewSyncFromCodexMultiAuth) + .mockRejectedValueOnce(capacityError) + .mockResolvedValueOnce({ + rootDir: tempDir, + accountsPath: join(tempDir, "openai-codex-accounts.json"), + scope: "global", + imported: 0, + skipped: 1, + total: 1, + }); + + const mockClient = createMockClient(); + const { OpenAIOAuthPlugin } = await import("../index.js"); + const plugin = (await OpenAIOAuthPlugin({ client: mockClient } as never)) as unknown as PluginType; + const autoMethod = plugin.auth.methods[0] as unknown as { + authorize: (inputs?: Record) => Promise<{ instructions: string }>; + }; + + const authResult = await autoMethod.authorize(); + expect(authResult.instructions).toBe("Authentication cancelled"); + expect(mockStorage.accounts).toHaveLength(1); + expect(mockStorage.accounts[0]?.accountId).toBe("org-keep"); + } finally { + await fs.rm(tempDir, { recursive: true, force: true }); + } + }); }); describe("OpenAIOAuthPlugin showToast error handling", () => { From e4a25464fc29a070f22e9180befce4e274decc5c Mon Sep 17 00:00:00 2001 From: ndycode Date: Sat, 7 Mar 2026 19:04:49 +0800 Subject: [PATCH 19/81] fix: harden operation screen and config writes Closes the empty health-check operation screen correctly, preserves config files until rename succeeds, and recovers stale config lock files with regression coverage. Co-authored-by: Codex --- index.ts | 1 + lib/config.ts | 50 +++++++++++++++++++++++++++++++++++--- test/plugin-config.test.ts | 40 +++++++++++++++++++++++++++--- 3 files changed, 84 insertions(+), 7 deletions(-) diff --git a/index.ts b/index.ts index 9e6e4578..396981f9 100644 --- a/index.ts +++ b/index.ts @@ -3124,6 +3124,7 @@ while (attempted.size < Math.max(1, accountCount)) { if (workingStorage.accounts.length === 0) { if (screen) { screen.push("No accounts to check.", "warning"); + await screen.finish(); } else { console.log("No accounts to check."); } diff --git a/lib/config.ts b/lib/config.ts index dc7946cf..7a80130d 100644 --- a/lib/config.ts +++ b/lib/config.ts @@ -144,11 +144,30 @@ export function savePluginConfigMutation( } const tempPath = `${CONFIG_PATH}.${process.pid}.${Date.now()}.tmp`; - writeFileSync(tempPath, `${JSON.stringify(next, null, 2)}\n`, "utf-8"); - if (existsSync(CONFIG_PATH)) { - unlinkSync(CONFIG_PATH); + writeFileSync(tempPath, `${JSON.stringify(next, null, 2)}\n`, { + encoding: "utf-8", + mode: 0o600, + }); + try { + renameSync(tempPath, CONFIG_PATH); + } catch (error) { + const code = (error as NodeJS.ErrnoException).code; + if ( + process.platform === "win32" && + (code === "EEXIST" || code === "EPERM") && + existsSync(CONFIG_PATH) + ) { + unlinkSync(CONFIG_PATH); + renameSync(tempPath, CONFIG_PATH); + return; + } + try { + unlinkSync(tempPath); + } catch { + // best effort temp cleanup + } + throw error; } - renameSync(tempPath, CONFIG_PATH); }); } @@ -164,6 +183,16 @@ function sleepSync(ms: number): void { Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms); } +function isProcessAlive(pid: number): boolean { + try { + process.kill(pid, 0); + return true; + } catch (error) { + const code = (error as NodeJS.ErrnoException).code; + return code === "EPERM"; + } +} + function withPluginConfigLock(fn: () => T): T { mkdirSync(dirname(CONFIG_PATH), { recursive: true }); const deadline = Date.now() + 2_000; @@ -176,6 +205,19 @@ function withPluginConfigLock(fn: () => T): T { if (code !== "EEXIST" || Date.now() >= deadline) { throw error; } + try { + const lockOwnerPid = Number.parseInt(readFileSync(CONFIG_LOCK_PATH, "utf-8").trim(), 10); + if ( + Number.isFinite(lockOwnerPid) && + lockOwnerPid !== process.pid && + !isProcessAlive(lockOwnerPid) + ) { + unlinkSync(CONFIG_LOCK_PATH); + continue; + } + } catch { + // best effort stale-lock recovery + } sleepSync(25); } } diff --git a/test/plugin-config.test.ts b/test/plugin-config.test.ts index 194f05b9..90e948b6 100644 --- a/test/plugin-config.test.ts +++ b/test/plugin-config.test.ts @@ -793,9 +793,6 @@ describe('Plugin Configuration', () => { expect(mockWriteFileSync).toHaveBeenCalledTimes(2); const [writtenPath, writtenContent] = mockWriteFileSync.mock.calls[1] ?? []; expect(String(writtenPath)).toContain('.tmp'); - expect(mockUnlinkSync).toHaveBeenCalledWith( - path.join(os.homedir(), '.opencode', 'openai-codex-auth-config.json'), - ); expect(mockRenameSync).toHaveBeenCalled(); expect(JSON.parse(String(writtenContent))).toEqual({ codexMode: false, @@ -806,6 +803,9 @@ describe('Plugin Configuration', () => { }, }, }); + expect(mockUnlinkSync).not.toHaveBeenCalledWith( + path.join(os.homedir(), '.opencode', 'openai-codex-auth-config.json'), + ); }); it('creates a new config file when enabling sync on a missing config', () => { @@ -847,6 +847,40 @@ describe('Plugin Configuration', () => { expect(() => setSyncFromCodexMultiAuthEnabled(true)).not.toThrow(); expect(mockRenameSync).toHaveBeenCalled(); }); + + it('recovers stale config lock files before mutating config', () => { + const configPath = path.join(os.homedir(), '.opencode', 'openai-codex-auth-config.json'); + const lockPath = `${configPath}.lock`; + const killSpy = vi.spyOn(process, 'kill').mockImplementation(() => { + const error = new Error('process not found') as NodeJS.ErrnoException; + error.code = 'ESRCH'; + throw error; + }); + mockExistsSync.mockReturnValue(true); + mockReadFileSync.mockImplementation((filePath: fs.PathOrFileDescriptor) => { + if (String(filePath) === lockPath) { + return '424242'; + } + return JSON.stringify({ codexMode: false }); + }); + mockWriteFileSync.mockImplementation((filePath: fs.PathOrFileDescriptor) => { + if (String(filePath) === lockPath && mockWriteFileSync.mock.calls.length === 1) { + const error = new Error('exists') as NodeJS.ErrnoException; + error.code = 'EEXIST'; + throw error; + } + return undefined; + }); + + try { + expect(() => setSyncFromCodexMultiAuthEnabled(true)).not.toThrow(); + expect(mockUnlinkSync).toHaveBeenCalledWith(lockPath); + expect(killSpy).toHaveBeenCalledWith(424242, 0); + expect(mockRenameSync).toHaveBeenCalled(); + } finally { + killSpy.mockRestore(); + } + }); }); }); From 58c824ac986cca7222033e90ffc56332aabeff5a Mon Sep 17 00:00:00 2001 From: ndycode Date: Sat, 7 Mar 2026 19:29:18 +0800 Subject: [PATCH 20/81] fix: address latest review findings Hardens operation screens, sync apply/removal flows, selector input handling, and source-index routing based on the latest PR review comments. Co-authored-by: Codex --- index.ts | 366 ++++++++++++++++++++++------------- lib/cli.ts | 12 +- lib/codex-multi-auth-sync.ts | 13 +- lib/ui/auth-menu.ts | 5 +- lib/ui/select.ts | 80 +++++++- test/plugin-config.test.ts | 1 + test/ui-select.test.ts | 26 ++- 7 files changed, 340 insertions(+), 163 deletions(-) diff --git a/index.ts b/index.ts index 396981f9..f38dd5ab 100644 --- a/index.ts +++ b/index.ts @@ -1228,6 +1228,12 @@ export const OpenAIOAuthPlugin: Plugin = async ({ client }: PluginInput) => { return applyUiRuntimeFromConfig(loadPluginConfig()); }; + // biome-ignore lint/suspicious/noControlCharactersInRegex: matching ANSI escape codes + const ANSI_STYLE_REGEX = new RegExp("\\x1b\\[[0-9;]*m", "g"); + const SCREEN_CONTROL_CHAR_REGEX = new RegExp("[\\u0000-\\u0008\\u000b\\u000c\\u000e-\\u001f\\u007f]", "g"); + const sanitizeScreenText = (value: string): string => + value.replace(ANSI_STYLE_REGEX, "").replace(SCREEN_CONTROL_CHAR_REGEX, "").trim(); + const createOperationScreen = ( ui: UiRuntimeOptions, title: string, @@ -1235,6 +1241,7 @@ export const OpenAIOAuthPlugin: Plugin = async ({ client }: PluginInput) => { ): { push: (line: string, tone?: "normal" | "muted" | "success" | "warning" | "danger" | "accent") => void; finish: (summaryLines?: Array<{ line: string; tone?: "normal" | "muted" | "success" | "warning" | "danger" | "accent" }>) => Promise; + abort: () => void; } | null => { if (!ui.v2Enabled || !supportsInteractiveMenus()) { return null; @@ -1249,6 +1256,18 @@ export const OpenAIOAuthPlugin: Plugin = async ({ client }: PluginInput) => { let running = true; let initialized = false; let timer: NodeJS.Timeout | null = null; + let closed = false; + + const dispose = () => { + if (closed) return; + closed = true; + running = false; + if (timer) { + clearInterval(timer); + timer = null; + } + process.stdout.write(ANSI.altScreenOff + ANSI.show + ANSI.clearScreen + ANSI.moveTo(1, 1)); + }; const render = () => { const lines: string[] = []; @@ -1257,14 +1276,14 @@ export const OpenAIOAuthPlugin: Plugin = async ({ client }: PluginInput) => { const spinner = running ? `${spinnerFrames[frame % spinnerFrames.length] ?? "-"} ` : "+ "; const stageTone: "muted" | "accent" | "success" = running ? "accent" : "success"; - lines.push(...formatUiHeader(ui, title)); + lines.push(...formatUiHeader(ui, sanitizeScreenText(title))); lines.push(""); if (subtitle) { - lines.push(paintUiText(ui, `${spinner}${subtitle}`, stageTone)); + lines.push(paintUiText(ui, `${spinner}${sanitizeScreenText(subtitle)}`, stageTone)); lines.push(""); } for (const entry of visibleEntries) { - lines.push(paintUiText(ui, entry.line, entry.tone)); + lines.push(paintUiText(ui, sanitizeScreenText(entry.line), entry.tone)); } if (running) { lines.push(""); @@ -1290,7 +1309,7 @@ export const OpenAIOAuthPlugin: Plugin = async ({ client }: PluginInput) => { return { push: (line: string, tone = "normal") => { ensureScreen(); - entries.push({ line, tone }); + entries.push({ line: sanitizeScreenText(line), tone }); render(); }, finish: async (summaryLines) => { @@ -1298,7 +1317,7 @@ export const OpenAIOAuthPlugin: Plugin = async ({ client }: PluginInput) => { if (summaryLines && summaryLines.length > 0) { entries.push({ line: "", tone: "normal" }); for (const entry of summaryLines) { - entries.push({ line: entry.line, tone: entry.tone ?? "normal" }); + entries.push({ line: sanitizeScreenText(entry.line), tone: entry.tone ?? "normal" }); } } running = false; @@ -1308,8 +1327,9 @@ export const OpenAIOAuthPlugin: Plugin = async ({ client }: PluginInput) => { } render(); await new Promise((resolve) => setTimeout(resolve, 2_000)); - process.stdout.write(ANSI.altScreenOff + ANSI.show + ANSI.clearScreen + ANSI.moveTo(1, 1)); + dispose(); }, + abort: dispose, }; }; @@ -1367,6 +1387,26 @@ export const OpenAIOAuthPlugin: Plugin = async ({ client }: PluginInput) => { return `Account ${index + 1} (${details.join(", ")})`; }; + const hasUniqueEmailInAccounts = ( + accounts: Array<{ email?: string }>, + email: string | undefined, + ): boolean => { + const normalizedEmail = sanitizeEmail(email); + if (!normalizedEmail) return false; + return accounts.filter((account) => sanitizeEmail(account.email) === normalizedEmail).length <= 1; + }; + + const canHydrateCachedTokenForAccount = ( + accounts: Array<{ email?: string }>, + account: { email?: string; accountId?: string }, + tokenAccountId: string | undefined, + ): boolean => { + if (hasUniqueEmailInAccounts(accounts, account.email)) { + return true; + } + return Boolean(tokenAccountId && account.accountId && tokenAccountId === account.accountId); + }; + const normalizeAccountTags = (raw: string): string[] => { return Array.from( new Set( @@ -3100,6 +3140,7 @@ while (attempted.size < Math.max(1, accountCount)) { ? `Checking ${workingStorage.accounts.length} account(s) with full refresh + live validation` : `Checking ${workingStorage.accounts.length} account(s) with quota validation`, ); + let screenFinished = false; const emit = ( index: number, detail: string, @@ -3121,35 +3162,37 @@ while (attempted.size < Math.max(1, accountCount)) { console.log(line); }; - if (workingStorage.accounts.length === 0) { - if (screen) { - screen.push("No accounts to check.", "warning"); - await screen.finish(); - } else { - console.log("No accounts to check."); + try { + if (workingStorage.accounts.length === 0) { + if (screen) { + screen.push("No accounts to check.", "warning"); + await screen.finish(); + screenFinished = true; + } else { + console.log("No accounts to check."); + } + return; } - return; - } - const flaggedStorage = await loadFlaggedAccounts(); - let storageChanged = false; - let flaggedChanged = false; - const removeFromActive = new Set(); - const total = workingStorage.accounts.length; - let ok = 0; - let disabled = 0; - let errors = 0; - - for (let i = 0; i < total; i += 1) { - const account = workingStorage.accounts[i]; - if (!account) continue; - if (account.enabled === false) { - disabled += 1; - emit(i, "disabled", "warning"); - continue; - } + const flaggedStorage = await loadFlaggedAccounts(); + let storageChanged = false; + let flaggedChanged = false; + const removeFromActive = new Set(); + const total = workingStorage.accounts.length; + let ok = 0; + let disabled = 0; + let errors = 0; + + for (let i = 0; i < total; i += 1) { + const account = workingStorage.accounts[i]; + if (!account) continue; + if (account.enabled === false) { + disabled += 1; + emit(i, "disabled", "warning"); + continue; + } - try { + try { // If we already have a valid cached access token, don't force-refresh. // This avoids flagging accounts where the refresh token has been burned // but the access token is still valid (same behavior as Codex CLI). @@ -3183,8 +3226,14 @@ while (attempted.size < Math.max(1, accountCount)) { // instead of forcing a refresh. if (!accessToken) { const cached = await lookupCodexCliTokensByEmail(account.email); + const cachedTokenAccountId = cached ? extractAccountId(cached.accessToken) : undefined; if ( cached && + canHydrateCachedTokenForAccount( + workingStorage.accounts, + account, + cachedTokenAccountId, + ) && (typeof cached.expiresAt !== "number" || !Number.isFinite(cached.expiresAt) || cached.expiresAt > nowMs) @@ -3213,7 +3262,7 @@ while (attempted.size < Math.max(1, accountCount)) { storageChanged = true; } - tokenAccountId = extractAccountId(cached.accessToken); + tokenAccountId = cachedTokenAccountId; if ( tokenAccountId && shouldUpdateAccountIdFromToken(account.accountIdSource, account.accountId) && @@ -3330,45 +3379,51 @@ while (attempted.size < Math.max(1, accountCount)) { const message = error instanceof Error ? error.message : String(error); emit(i, `error: ${message.slice(0, 160)}`, "danger"); } - } catch (error) { - errors += 1; - const message = error instanceof Error ? error.message : String(error); - emit(i, `error: ${message.slice(0, 120)}`, "danger"); + } catch (error) { + errors += 1; + const message = error instanceof Error ? error.message : String(error); + emit(i, `error: ${message.slice(0, 120)}`, "danger"); + } } - } - if (removeFromActive.size > 0) { - workingStorage.accounts = workingStorage.accounts.filter( - (account) => !removeFromActive.has(account.refreshToken), - ); - clampActiveIndices(workingStorage); - storageChanged = true; - } + if (removeFromActive.size > 0) { + workingStorage.accounts = workingStorage.accounts.filter( + (account) => !removeFromActive.has(account.refreshToken), + ); + clampActiveIndices(workingStorage); + storageChanged = true; + } - if (storageChanged) { - await saveAccounts(workingStorage); - invalidateAccountManagerCache(); - } - if (flaggedChanged) { - await saveFlaggedAccounts(flaggedStorage); - } + if (storageChanged) { + await saveAccounts(workingStorage); + invalidateAccountManagerCache(); + } + if (flaggedChanged) { + await saveFlaggedAccounts(flaggedStorage); + } - const summaryLines: Array<{ - line: string; - tone?: "normal" | "muted" | "success" | "warning" | "danger" | "accent"; - }> = [{ line: `Results: ${ok} ok, ${errors} error, ${disabled} disabled`, tone: errors > 0 ? "warning" : "success" }]; - if (removeFromActive.size > 0) { - summaryLines.push({ line: `Moved ${removeFromActive.size} account(s) to flagged pool (invalid refresh token).`, tone: "warning" as const }); - } - if (screen) { - await screen.finish(summaryLines); - return; - } - console.log(""); - for (const line of summaryLines) { - console.log(line.line); + const summaryLines: Array<{ + line: string; + tone?: "normal" | "muted" | "success" | "warning" | "danger" | "accent"; + }> = [{ line: `Results: ${ok} ok, ${errors} error, ${disabled} disabled`, tone: errors > 0 ? "warning" : "success" }]; + if (removeFromActive.size > 0) { + summaryLines.push({ line: `Moved ${removeFromActive.size} account(s) to flagged pool (invalid refresh token).`, tone: "warning" as const }); + } + if (screen) { + await screen.finish(summaryLines); + screenFinished = true; + return; + } + console.log(""); + for (const line of summaryLines) { + console.log(line.line); + } + console.log(""); + } finally { + if (screen && !screenFinished) { + screen.abort(); + } } - console.log(""); }; const verifyFlaggedAccounts = async (): Promise => { @@ -3388,37 +3443,48 @@ while (attempted.size < Math.max(1, accountCount)) { } console.log(line); }; - const flaggedStorage = await loadFlaggedAccounts(); - if (flaggedStorage.accounts.length === 0) { - emit("No flagged accounts to verify."); - await screen?.finish(); - return; - } + let screenFinished = false; + try { + const flaggedStorage = await loadFlaggedAccounts(); + if (flaggedStorage.accounts.length === 0) { + emit("No flagged accounts to verify."); + if (screen) { + await screen.finish(); + screenFinished = true; + } + return; + } - emit(`Checking ${flaggedStorage.accounts.length} problem account(s)...`, "muted"); - const remaining: FlaggedAccountMetadataV1[] = []; - const restored: TokenSuccessWithAccount[] = []; - const flaggedLabelWidth = Math.min( - 72, - Math.max( - 18, - ...flaggedStorage.accounts.map((flagged, index) => - (flagged.email ?? flagged.accountLabel ?? `Flagged ${index + 1}`).length, + emit(`Checking ${flaggedStorage.accounts.length} problem account(s)...`, "muted"); + const remaining: FlaggedAccountMetadataV1[] = []; + const restored: TokenSuccessWithAccount[] = []; + const flaggedLabelWidth = Math.min( + 72, + Math.max( + 18, + ...flaggedStorage.accounts.map((flagged, index) => + (flagged.email ?? flagged.accountLabel ?? `Flagged ${index + 1}`).length, + ), ), - ), - ); - const padFlaggedLabel = (value: string): string => - value.length >= flaggedLabelWidth ? value : `${value}${" ".repeat(flaggedLabelWidth - value.length)}`; + ); + const padFlaggedLabel = (value: string): string => + value.length >= flaggedLabelWidth ? value : `${value}${" ".repeat(flaggedLabelWidth - value.length)}`; - for (let i = 0; i < flaggedStorage.accounts.length; i += 1) { - const flagged = flaggedStorage.accounts[i]; - if (!flagged) continue; - const label = padFlaggedLabel(flagged.email ?? flagged.accountLabel ?? `Flagged ${i + 1}`); + for (let i = 0; i < flaggedStorage.accounts.length; i += 1) { + const flagged = flaggedStorage.accounts[i]; + if (!flagged) continue; + const label = padFlaggedLabel(flagged.email ?? flagged.accountLabel ?? `Flagged ${i + 1}`); try { const cached = await lookupCodexCliTokensByEmail(flagged.email); const now = Date.now(); + const cachedTokenAccountId = cached ? extractAccountId(cached.accessToken) : undefined; if ( cached && + canHydrateCachedTokenForAccount( + flaggedStorage.accounts, + flagged, + cachedTokenAccountId, + ) && typeof cached.expiresAt === "number" && Number.isFinite(cached.expiresAt) && cached.expiresAt > now @@ -3471,42 +3537,48 @@ while (attempted.size < Math.max(1, accountCount)) { } restored.push(...resolved.variantsForPersistence); emit(`${getStatusMarker(ui, "ok")} ${label} | restored`, "success"); - } catch (error) { - const message = error instanceof Error ? error.message : String(error); - emit( - `${getStatusMarker(ui, "error")} ${label} | error: ${message.slice(0, 120)}`, - "danger", - ); - remaining.push({ - ...flagged, - lastError: message, - }); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + emit( + `${getStatusMarker(ui, "error")} ${label} | error: ${message.slice(0, 120)}`, + "danger", + ); + remaining.push({ + ...flagged, + lastError: message, + }); + } } - } - if (restored.length > 0) { - await persistAccountPool(restored, false); - invalidateAccountManagerCache(); - } + if (restored.length > 0) { + await persistAccountPool(restored, false); + invalidateAccountManagerCache(); + } - await saveFlaggedAccounts({ - version: 1, - accounts: remaining, - }); + await saveFlaggedAccounts({ + version: 1, + accounts: remaining, + }); - const summaryLines: Array<{ - line: string; - tone?: "normal" | "muted" | "success" | "warning" | "danger" | "accent"; - }> = [{ line: `Results: ${restored.length} restored, ${remaining.length} still flagged`, tone: remaining.length > 0 ? "warning" : "success" }]; - if (screen) { - await screen.finish(summaryLines); - return; - } - console.log(""); - for (const line of summaryLines) { - console.log(line.line); + const summaryLines: Array<{ + line: string; + tone?: "normal" | "muted" | "success" | "warning" | "danger" | "accent"; + }> = [{ line: `Results: ${restored.length} restored, ${remaining.length} still flagged`, tone: remaining.length > 0 ? "warning" : "success" }]; + if (screen) { + await screen.finish(summaryLines); + screenFinished = true; + return; + } + console.log(""); + for (const line of summaryLines) { + console.log(line.line); + } + console.log(""); + } finally { + if (screen && !screenFinished) { + screen.abort(); + } } - console.log(""); }; const toggleCodexMultiAuthSyncSetting = (): void => { @@ -3552,7 +3624,9 @@ while (attempted.size < Math.max(1, accountCount)) { }; }; - const removeAccountsForSync = async (indexes: number[]): Promise => { + const removeAccountsForSync = async ( + targetRefreshTokens: string[], + ): Promise => { const currentStorage = (await loadAccounts()) ?? ({ @@ -3562,16 +3636,21 @@ while (attempted.size < Math.max(1, accountCount)) { activeIndexByFamily: {}, } satisfies AccountStorageV3); const currentFlaggedStorage = await loadFlaggedAccounts(); - const descending = [...indexes].sort((left, right) => right - left); - const removedTargets = descending - .map((index) => ({ index, account: currentStorage.accounts[index] })) - .filter((entry) => entry.account); + const refreshTokenSet = new Set( + targetRefreshTokens.filter((token) => typeof token === "string" && token.length > 0), + ); + const removedTargets = currentStorage.accounts + .map((account, index) => ({ index, account })) + .filter((entry) => entry.account && refreshTokenSet.has(entry.account.refreshToken)); if (removedTargets.length === 0) { return; } - for (const { index } of removedTargets) { - currentStorage.accounts.splice(index, 1); + if (removedTargets.length !== refreshTokenSet.size) { + throw new Error("Selected accounts changed before removal. Re-run sync and confirm again."); } + currentStorage.accounts = currentStorage.accounts.filter( + (account) => !refreshTokenSet.has(account.refreshToken), + ); clampActiveIndices(currentStorage); await saveAccounts(currentStorage); const removedRefreshTokens = new Set( @@ -3590,7 +3669,10 @@ while (attempted.size < Math.max(1, accountCount)) { console.log(`\nRemoved ${removedTargets.length} account(s): ${removedLabels}\n`); }; - const buildSyncRemovalPreview = async (indexes: number[]): Promise => { + const buildSyncRemovalPlan = async (indexes: number[]): Promise<{ + previewLines: string[]; + refreshTokens: string[]; + }> => { const currentStorage = (await loadAccounts()) ?? ({ @@ -3599,15 +3681,29 @@ while (attempted.size < Math.max(1, accountCount)) { activeIndex: 0, activeIndexByFamily: {}, } satisfies AccountStorageV3); - return [...indexes] + const candidates = [...indexes] .sort((left, right) => left - right) .map((index) => { const account = currentStorage.accounts[index]; - if (!account) return `Account ${index + 1}`; + if (!account) { + return { + previewLine: `Account ${index + 1}`, + refreshToken: undefined, + }; + } const label = account.email ?? account.accountLabel ?? `Account ${index + 1}`; const currentSuffix = index === currentStorage.activeIndex ? " | current" : ""; - return `${index + 1}. ${label}${currentSuffix}`; + return { + previewLine: `${index + 1}. ${label}${currentSuffix}`, + refreshToken: account.refreshToken, + }; }); + return { + previewLines: candidates.map((candidate) => candidate.previewLine), + refreshTokens: candidates + .map((candidate) => candidate.refreshToken) + .filter((token): token is string => typeof token === "string" && token.length > 0), + }; }; let pruneBackup: @@ -3713,9 +3809,9 @@ while (attempted.size < Math.max(1, accountCount)) { console.log("Sync cancelled.\n"); return; } - const previewLines = await buildSyncRemovalPreview(indexesToRemove); + const removalPlan = await buildSyncRemovalPlan(indexesToRemove); console.log("Dry run removal:"); - for (const line of previewLines) { + for (const line of removalPlan.previewLines) { console.log(` ${line}`); } console.log(""); @@ -3730,7 +3826,7 @@ while (attempted.size < Math.max(1, accountCount)) { if (!pruneBackup) { pruneBackup = await createSyncPruneBackup(); } - await removeAccountsForSync(indexesToRemove); + await removeAccountsForSync(removalPlan.refreshTokens); pruneBackup = null; continue; } diff --git a/lib/cli.ts b/lib/cli.ts index 79025aba..d3febea3 100644 --- a/lib/cli.ts +++ b/lib/cli.ts @@ -338,7 +338,9 @@ export async function promptLoginMode( case "select-account": { const accountAction = await showAccountDetails(action.account); if (accountAction === "delete") { - return { mode: "manage", deleteAccountIndex: action.account.index }; + const index = resolveAccountSourceIndex(action.account); + if (index >= 0) return { mode: "manage", deleteAccountIndex: index }; + continue; } if (accountAction === "set-current") { const index = resolveAccountSourceIndex(action.account); @@ -346,10 +348,14 @@ export async function promptLoginMode( continue; } if (accountAction === "refresh") { - return { mode: "manage", refreshAccountIndex: action.account.index }; + const index = resolveAccountSourceIndex(action.account); + if (index >= 0) return { mode: "manage", refreshAccountIndex: index }; + continue; } if (accountAction === "toggle") { - return { mode: "manage", toggleAccountIndex: action.account.index }; + const index = resolveAccountSourceIndex(action.account); + if (index >= 0) return { mode: "manage", toggleAccountIndex: index }; + continue; } continue; } diff --git a/lib/codex-multi-auth-sync.ts b/lib/codex-multi-auth-sync.ts index b6ecc4bb..fd1a81eb 100644 --- a/lib/codex-multi-auth-sync.ts +++ b/lib/codex-multi-auth-sync.ts @@ -496,18 +496,25 @@ export async function syncFromCodexMultiAuth( projectPath = process.cwd(), ): Promise { const resolved = await loadPreparedCodexMultiAuthSourceStorage(projectPath); + const currentStorage = await withAccountStorageTransaction((current) => Promise.resolve(current)); + const finalStorage = filterSourceAccountsAgainstExistingEmails( + resolved.storage, + currentStorage?.accounts ?? [], + ); let result: ImportAccountsResult; try { result = await withNormalizedImportFile( - resolved.storage, + finalStorage, (filePath) => importAccounts(filePath, { preImportBackupPrefix: "codex-multi-auth-sync-backup", backupMode: "required", }), ); } catch (error) { - const message = error instanceof Error ? error.message : String(error); - if (message.includes("exceed maximum")) { + if ( + error instanceof CodexMultiAuthSyncCapacityError || + (error instanceof Error && /exceed(?: the)? maximum/i.test(error.message)) + ) { await assertSyncWithinCapacity(resolved); } throw error; diff --git a/lib/ui/auth-menu.ts b/lib/ui/auth-menu.ts index 96a9135c..37d74ee9 100644 --- a/lib/ui/auth-menu.ts +++ b/lib/ui/auth-menu.ts @@ -544,10 +544,7 @@ export async function showSyncPruneMenu( if (input === " ") { const current = items[context.cursor]; if (current?.value.type === "toggle") { - const candidate = current.value.candidate; - if (selected.has(candidate.index)) selected.delete(candidate.index); - else selected.add(candidate.index); - context.requestRerender(); + return current.value; } return undefined; } diff --git a/lib/ui/select.ts b/lib/ui/select.ts index 3efb00c0..b45412ff 100644 --- a/lib/ui/select.ts +++ b/lib/ui/select.ts @@ -191,10 +191,16 @@ export function coalesceTerminalInput( if (nextPending) { const base = nextPending.value; - if (nextPending.hasEscape && (base === "\x1b[" || base === "[") && canCompleteCsi(nextInput)) { + if (nextPending.hasEscape && base === "\x1b[" && canCompleteCsi(nextInput)) { return { normalizedInput: `\x1b[${nextInput}`, pending: null }; } - if (nextPending.hasEscape && (base === "\x1bO" || base === "O") && CSI_FINAL_KEYS.has(nextInput)) { + if (nextPending.hasEscape && /^\x1b\[\d+$/.test(base) && canCompleteCsi(nextInput)) { + return { normalizedInput: `${base}${nextInput}`, pending: null }; + } + if (nextPending.hasEscape && (base === "\x1b[" || /^\x1b\[\d+$/.test(base)) && /^\d+$/.test(nextInput)) { + return { normalizedInput: null, pending: { value: `${base}${nextInput}`, hasEscape: true } }; + } + if (nextPending.hasEscape && base === "\x1bO" && CSI_FINAL_KEYS.has(nextInput)) { return { normalizedInput: `\x1bO${nextInput}`, pending: null }; } if (base === "\x1b" && (nextInput === "[" || nextInput === "O")) { @@ -214,7 +220,7 @@ export function coalesceTerminalInput( return { normalizedInput: null, pending: { value: nextInput, hasEscape: true } }; } if (nextInput === "[" || nextInput === "O") { - return { normalizedInput: null, pending: { value: nextInput, hasEscape: false } }; + return { normalizedInput: nextInput, pending: null }; } return { normalizedInput: nextInput, pending: nextPending }; @@ -374,16 +380,68 @@ export async function select(items: MenuItem[], options: SelectOptions) linesWritten += 1; }; + const itemRowCost = (item: MenuItem, selected: boolean): number => { + if (item.separator || item.kind === "heading") { + return 1; + } + let cost = 1; + if (item.hint) { + const hintLines = item.hint.split("\n").length; + if (selected) { + cost += Math.min(3, hintLines); + } else if (options.showHintsForUnselected ?? true) { + cost += Math.min(2, hintLines); + } + } + return cost; + }; + const subtitleLines = subtitleText ? 2 : 0; const fixedLines = 2 + subtitleLines + 2; - const maxVisibleItems = Math.max(1, Math.min(items.length, rows - fixedLines - 1)); + const availableItemRows = Math.max(1, rows - fixedLines); let windowStart = 0; let windowEnd = items.length; - if (items.length > maxVisibleItems) { - windowStart = cursor - Math.floor(maxVisibleItems / 2); - windowStart = Math.max(0, Math.min(windowStart, items.length - maxVisibleItems)); - windowEnd = windowStart + maxVisibleItems; + const totalRenderedRows = items.reduce( + (total, item, index) => total + itemRowCost(item, index === cursor), + 0, + ); + if (totalRenderedRows > availableItemRows) { + windowStart = cursor; + windowEnd = cursor + 1; + let usedRows = itemRowCost(items[cursor] as MenuItem, true); + let up = cursor - 1; + let down = cursor + 1; + + while (true) { + const upCost = + up >= 0 ? itemRowCost(items[up] as MenuItem, false) : Number.POSITIVE_INFINITY; + const downCost = + down < items.length + ? itemRowCost(items[down] as MenuItem, false) + : Number.POSITIVE_INFINITY; + const preferUp = upCost <= downCost; + + if (preferUp && up >= 0 && usedRows + upCost <= availableItemRows) { + usedRows += upCost; + windowStart = up; + up -= 1; + continue; + } + if (down < items.length && usedRows + downCost <= availableItemRows) { + usedRows += downCost; + windowEnd = down + 1; + down += 1; + continue; + } + if (up >= 0 && usedRows + upCost <= availableItemRows) { + usedRows += upCost; + windowStart = up; + up -= 1; + continue; + } + break; + } } const visibleItems = items.slice(windowStart, windowEnd); @@ -463,8 +521,10 @@ export async function select(items: MenuItem[], options: SelectOptions) } const windowHint = items.length > visibleItems.length ? ` (${windowStart + 1}-${windowEnd}/${items.length})` : ""; - const backLabel = "Q Back"; - const helpText = options.help ?? `↑↓ Move | Enter Select | ${backLabel}${windowHint}`; + const backLabel = options.allowEscape === false ? "" : "Q Back"; + const helpText = + options.help ?? + `↑↓ Move | Enter Select${backLabel ? ` | ${backLabel}` : ""}${windowHint}`; writeLine(` ${muted}${truncateAnsi(helpText, Math.max(1, columns - 2))}${reset}`); writeLine(`${border}+${reset}`); diff --git a/test/plugin-config.test.ts b/test/plugin-config.test.ts index 90e948b6..4d0fa718 100644 --- a/test/plugin-config.test.ts +++ b/test/plugin-config.test.ts @@ -791,6 +791,7 @@ describe('Plugin Configuration', () => { { recursive: true }, ); expect(mockWriteFileSync).toHaveBeenCalledTimes(2); + // calls[0] is the lock file write, calls[1] is the temp config write const [writtenPath, writtenContent] = mockWriteFileSync.mock.calls[1] ?? []; expect(String(writtenPath)).toContain('.tmp'); expect(mockRenameSync).toHaveBeenCalled(); diff --git a/test/ui-select.test.ts b/test/ui-select.test.ts index 4b14a24f..34b81921 100644 --- a/test/ui-select.test.ts +++ b/test/ui-select.test.ts @@ -5,15 +5,9 @@ describe("ui-select", () => { it("reconstructs orphan bracket arrow chunks", () => { const first = coalesceTerminalInput("[", null); expect(first).toEqual({ - normalizedInput: null, - pending: { value: "[", hasEscape: false }, + normalizedInput: "[", + pending: null, }); - - const second = coalesceTerminalInput("B", first.pending as PendingInputSequence); - expect(second).toEqual({ - normalizedInput: "[B", - pending: null, - }); }); it("reconstructs escape-plus-bracket chunks", () => { @@ -44,6 +38,22 @@ describe("ui-select", () => { }); }); + it("keeps split CSI numeric tails pending until the final byte arrives", () => { + const first = coalesceTerminalInput("\u001b", null); + const second = coalesceTerminalInput("[", first.pending as PendingInputSequence); + const third = coalesceTerminalInput("1", second.pending as PendingInputSequence); + expect(third).toEqual({ + normalizedInput: null, + pending: { value: "\u001b[1", hasEscape: true }, + }); + + const fourth = coalesceTerminalInput("~", third.pending as PendingInputSequence); + expect(fourth).toEqual({ + normalizedInput: "\u001b[1~", + pending: null, + }); + }); + it("tokenizes packed escape and control chunks", () => { expect(tokenizeTerminalInput("\u001b[B\u0003")).toEqual(["\u001b[B", "\u0003"]); }); From d3f53e8bab822990ede751dd838907907b824154 Mon Sep 17 00:00:00 2001 From: ndycode Date: Sat, 7 Mar 2026 19:40:26 +0800 Subject: [PATCH 21/81] fix: close remaining review findings Addresses the latest PR review comments across sync pruning, cached token restoration, selector input handling, and related tests. Co-authored-by: Codex --- index.ts | 87 +++++++++++++++++++++++++++++++++------------- test/index.test.ts | 9 +++-- 2 files changed, 68 insertions(+), 28 deletions(-) diff --git a/index.ts b/index.ts index f38dd5ab..3579df00 100644 --- a/index.ts +++ b/index.ts @@ -1407,6 +1407,16 @@ export const OpenAIOAuthPlugin: Plugin = async ({ client }: PluginInput) => { return Boolean(tokenAccountId && account.accountId && tokenAccountId === account.accountId); }; + type SyncRemovalTarget = { + refreshToken: string; + organizationId?: string; + accountId?: string; + }; + + const getSyncRemovalTargetKey = (target: SyncRemovalTarget): string => { + return `${target.organizationId ?? ""}|${target.accountId ?? ""}|${target.refreshToken}`; + }; + const normalizeAccountTags = (raw: string): string[] => { return Array.from( new Set( @@ -3394,13 +3404,13 @@ while (attempted.size < Math.max(1, accountCount)) { storageChanged = true; } + if (flaggedChanged) { + await saveFlaggedAccounts(flaggedStorage); + } if (storageChanged) { await saveAccounts(workingStorage); invalidateAccountManagerCache(); } - if (flaggedChanged) { - await saveFlaggedAccounts(flaggedStorage); - } const summaryLines: Array<{ line: string; @@ -3446,6 +3456,11 @@ while (attempted.size < Math.max(1, accountCount)) { let screenFinished = false; try { const flaggedStorage = await loadFlaggedAccounts(); + const activeStorage = await loadAccounts(); + const restoreContext = [ + ...(activeStorage?.accounts ?? []), + ...flaggedStorage.accounts, + ]; if (flaggedStorage.accounts.length === 0) { emit("No flagged accounts to verify."); if (screen) { @@ -3481,7 +3496,7 @@ while (attempted.size < Math.max(1, accountCount)) { if ( cached && canHydrateCachedTokenForAccount( - flaggedStorage.accounts, + restoreContext, flagged, cachedTokenAccountId, ) && @@ -3625,7 +3640,7 @@ while (attempted.size < Math.max(1, accountCount)) { }; const removeAccountsForSync = async ( - targetRefreshTokens: string[], + targets: SyncRemovalTarget[], ): Promise => { const currentStorage = (await loadAccounts()) ?? @@ -3636,20 +3651,38 @@ while (attempted.size < Math.max(1, accountCount)) { activeIndexByFamily: {}, } satisfies AccountStorageV3); const currentFlaggedStorage = await loadFlaggedAccounts(); - const refreshTokenSet = new Set( - targetRefreshTokens.filter((token) => typeof token === "string" && token.length > 0), + const targetKeySet = new Set( + targets + .filter((target) => typeof target.refreshToken === "string" && target.refreshToken.length > 0) + .map((target) => getSyncRemovalTargetKey(target)), ); const removedTargets = currentStorage.accounts .map((account, index) => ({ index, account })) - .filter((entry) => entry.account && refreshTokenSet.has(entry.account.refreshToken)); + .filter((entry) => + entry.account && + targetKeySet.has( + getSyncRemovalTargetKey({ + refreshToken: entry.account.refreshToken, + organizationId: entry.account.organizationId, + accountId: entry.account.accountId, + }), + ), + ); if (removedTargets.length === 0) { return; } - if (removedTargets.length !== refreshTokenSet.size) { + if (removedTargets.length !== targetKeySet.size) { throw new Error("Selected accounts changed before removal. Re-run sync and confirm again."); } currentStorage.accounts = currentStorage.accounts.filter( - (account) => !refreshTokenSet.has(account.refreshToken), + (account) => + !targetKeySet.has( + getSyncRemovalTargetKey({ + refreshToken: account.refreshToken, + organizationId: account.organizationId, + accountId: account.accountId, + }), + ), ); clampActiveIndices(currentStorage); await saveAccounts(currentStorage); @@ -3671,7 +3704,7 @@ while (attempted.size < Math.max(1, accountCount)) { const buildSyncRemovalPlan = async (indexes: number[]): Promise<{ previewLines: string[]; - refreshTokens: string[]; + targets: SyncRemovalTarget[]; }> => { const currentStorage = (await loadAccounts()) ?? @@ -3681,7 +3714,10 @@ while (attempted.size < Math.max(1, accountCount)) { activeIndex: 0, activeIndexByFamily: {}, } satisfies AccountStorageV3); - const candidates = [...indexes] + const candidates: Array<{ + previewLine: string; + target?: SyncRemovalTarget; + }> = [...indexes] .sort((left, right) => left - right) .map((index) => { const account = currentStorage.accounts[index]; @@ -3689,20 +3725,24 @@ while (attempted.size < Math.max(1, accountCount)) { return { previewLine: `Account ${index + 1}`, refreshToken: undefined, - }; - } - const label = account.email ?? account.accountLabel ?? `Account ${index + 1}`; - const currentSuffix = index === currentStorage.activeIndex ? " | current" : ""; + }; + } + const label = account.email ?? account.accountLabel ?? `Account ${index + 1}`; + const currentSuffix = index === currentStorage.activeIndex ? " | current" : ""; return { previewLine: `${index + 1}. ${label}${currentSuffix}`, - refreshToken: account.refreshToken, + target: { + refreshToken: account.refreshToken, + organizationId: account.organizationId, + accountId: account.accountId, + } satisfies SyncRemovalTarget, }; }); return { previewLines: candidates.map((candidate) => candidate.previewLine), - refreshTokens: candidates - .map((candidate) => candidate.refreshToken) - .filter((token): token is string => typeof token === "string" && token.length > 0), + targets: candidates + .map((candidate) => candidate.target) + .filter((target): target is SyncRemovalTarget => target !== undefined), }; }; @@ -3823,11 +3863,8 @@ while (attempted.size < Math.max(1, accountCount)) { console.log("Sync cancelled.\n"); return; } - if (!pruneBackup) { - pruneBackup = await createSyncPruneBackup(); - } - await removeAccountsForSync(removalPlan.refreshTokens); - pruneBackup = null; + pruneBackup = await createSyncPruneBackup(); + await removeAccountsForSync(removalPlan.targets); continue; } const message = error instanceof Error ? error.message : String(error); diff --git a/test/index.test.ts b/test/index.test.ts index 11a2f758..278eab9e 100644 --- a/test/index.test.ts +++ b/test/index.test.ts @@ -2779,7 +2779,7 @@ describe("OpenAIOAuthPlugin persistAccountPool", () => { expect(mockStorage.accounts[0]?.accountId).toBe("org-newer"); }); - it("keeps previously confirmed prune removals when a later sync preview returns no imports", async () => { + it("restores pruned accounts when sync does not commit after a prune retry", async () => { const cliModule = await import("../lib/cli.js"); const storageModule = await import("../lib/storage.js"); const syncModule = await import("../lib/codex-multi-auth-sync.js"); @@ -2881,8 +2881,11 @@ describe("OpenAIOAuthPlugin persistAccountPool", () => { const authResult = await autoMethod.authorize(); expect(authResult.instructions).toBe("Authentication cancelled"); - expect(mockStorage.accounts).toHaveLength(1); - expect(mockStorage.accounts[0]?.accountId).toBe("org-keep"); + expect(mockStorage.accounts).toHaveLength(2); + expect(mockStorage.accounts.map((account) => account.accountId)).toEqual([ + "org-keep", + "org-prune", + ]); } finally { await fs.rm(tempDir, { recursive: true, force: true }); } From 1a7a7395f8ff572cb8e3220a1a7c31e1925148d5 Mon Sep 17 00:00:00 2001 From: ndycode Date: Sat, 7 Mar 2026 20:02:30 +0800 Subject: [PATCH 22/81] fix: address latest codex review findings Handles sync-toggle write failures, keeps prune backups alive until restore succeeds, and precomputes email counts for cached-token ambiguity checks. Co-authored-by: Codex --- index.ts | 53 ++++++++++++++++++++++++++++++++--------------------- 1 file changed, 32 insertions(+), 21 deletions(-) diff --git a/index.ts b/index.ts index 3579df00..f25d8d91 100644 --- a/index.ts +++ b/index.ts @@ -1387,21 +1387,25 @@ export const OpenAIOAuthPlugin: Plugin = async ({ client }: PluginInput) => { return `Account ${index + 1} (${details.join(", ")})`; }; - const hasUniqueEmailInAccounts = ( + const buildEmailCountMap = ( accounts: Array<{ email?: string }>, - email: string | undefined, - ): boolean => { - const normalizedEmail = sanitizeEmail(email); - if (!normalizedEmail) return false; - return accounts.filter((account) => sanitizeEmail(account.email) === normalizedEmail).length <= 1; + ): Map => { + const counts = new Map(); + for (const account of accounts) { + const normalizedEmail = sanitizeEmail(account.email); + if (!normalizedEmail) continue; + counts.set(normalizedEmail, (counts.get(normalizedEmail) ?? 0) + 1); + } + return counts; }; const canHydrateCachedTokenForAccount = ( - accounts: Array<{ email?: string }>, + emailCounts: Map, account: { email?: string; accountId?: string }, tokenAccountId: string | undefined, ): boolean => { - if (hasUniqueEmailInAccounts(accounts, account.email)) { + const normalizedEmail = sanitizeEmail(account.email); + if (normalizedEmail && (emailCounts.get(normalizedEmail) ?? 0) <= 1) { return true; } return Boolean(tokenAccountId && account.accountId && tokenAccountId === account.accountId); @@ -3150,6 +3154,7 @@ while (attempted.size < Math.max(1, accountCount)) { ? `Checking ${workingStorage.accounts.length} account(s) with full refresh + live validation` : `Checking ${workingStorage.accounts.length} account(s) with quota validation`, ); + const emailCounts = buildEmailCountMap(workingStorage.accounts); let screenFinished = false; const emit = ( index: number, @@ -3238,12 +3243,12 @@ while (attempted.size < Math.max(1, accountCount)) { const cached = await lookupCodexCliTokensByEmail(account.email); const cachedTokenAccountId = cached ? extractAccountId(cached.accessToken) : undefined; if ( - cached && - canHydrateCachedTokenForAccount( - workingStorage.accounts, - account, - cachedTokenAccountId, - ) && + cached && + canHydrateCachedTokenForAccount( + emailCounts, + account, + cachedTokenAccountId, + ) && (typeof cached.expiresAt !== "number" || !Number.isFinite(cached.expiresAt) || cached.expiresAt > nowMs) @@ -3461,6 +3466,7 @@ while (attempted.size < Math.max(1, accountCount)) { ...(activeStorage?.accounts ?? []), ...flaggedStorage.accounts, ]; + const restoreEmailCounts = buildEmailCountMap(restoreContext); if (flaggedStorage.accounts.length === 0) { emit("No flagged accounts to verify."); if (screen) { @@ -3496,7 +3502,7 @@ while (attempted.size < Math.max(1, accountCount)) { if ( cached && canHydrateCachedTokenForAccount( - restoreContext, + restoreEmailCounts, flagged, cachedTokenAccountId, ) && @@ -3597,11 +3603,16 @@ while (attempted.size < Math.max(1, accountCount)) { }; const toggleCodexMultiAuthSyncSetting = (): void => { - const currentConfig = loadPluginConfig(); - const enabled = getSyncFromCodexMultiAuthEnabled(currentConfig); - setSyncFromCodexMultiAuthEnabled(!enabled); - const nextLabel = !enabled ? "enabled" : "disabled"; - console.log(`\nSync from codex-multi-auth ${nextLabel}.\n`); + try { + const currentConfig = loadPluginConfig(); + const enabled = getSyncFromCodexMultiAuthEnabled(currentConfig); + setSyncFromCodexMultiAuthEnabled(!enabled); + const nextLabel = !enabled ? "enabled" : "disabled"; + console.log(`\nSync from codex-multi-auth ${nextLabel}.\n`); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + console.log(`\nFailed to update sync setting: ${message}\n`); + } }; const runCodexMultiAuthSync = async (): Promise => { @@ -3756,8 +3767,8 @@ while (attempted.size < Math.max(1, accountCount)) { const restorePruneBackup = async (): Promise => { const currentBackup = pruneBackup; if (!currentBackup) return; - pruneBackup = null; await currentBackup.restore(); + pruneBackup = null; }; while (true) { try { From f71cad913c1df820ea0724fbfca2fafb7e9cd8d9 Mon Sep 17 00:00:00 2001 From: ndycode Date: Sat, 7 Mar 2026 20:16:34 +0800 Subject: [PATCH 23/81] fix: harden windows sync and config races Moves sync staging into a safer user temp area, rechecks overlap filtering inside import apply, strengthens config lock recovery, and expands CSI tilde token handling with regressions. Co-authored-by: Codex --- lib/codex-multi-auth-sync.ts | 49 ++++++++++++++++++++++-------- lib/config.ts | 36 +++++++++++++++++++--- lib/storage.ts | 11 +++++-- lib/ui/select.ts | 37 +++++++++++++++------- test/codex-multi-auth-sync.test.ts | 1 + test/ui-select.test.ts | 4 +++ 6 files changed, 109 insertions(+), 29 deletions(-) diff --git a/lib/codex-multi-auth-sync.ts b/lib/codex-multi-auth-sync.ts index fd1a81eb..e5cfb0ea 100644 --- a/lib/codex-multi-auth-sync.ts +++ b/lib/codex-multi-auth-sync.ts @@ -109,19 +109,35 @@ async function withNormalizedImportFile( storage: AccountStorageV3, handler: (filePath: string) => Promise, ): Promise { - const tempDir = await fs.mkdtemp(join(tmpdir(), "oc-chatgpt-multi-auth-sync-")); try { + const secureTempRoot = join(getResolvedUserHomeDir(), ".opencode", "tmp"); + await fs.mkdir(secureTempRoot, { recursive: true, mode: 0o700 }).catch(() => undefined); + const tempDir = await fs.mkdtemp(join(secureTempRoot, "oc-chatgpt-multi-auth-sync-")); await fs.chmod(tempDir, 0o700).catch(() => undefined); + const tempPath = join(tempDir, "accounts.json"); + await fs.writeFile(tempPath, `${JSON.stringify(storage, null, 2)}\n`, { + encoding: "utf-8", + mode: 0o600, + flag: "wx", + }); + try { + return await handler(tempPath); + } finally { + await fs.rm(tempDir, { recursive: true, force: true }).catch(() => undefined); + } } catch { - // best effort on platforms that ignore chmod + // fall back to the process temp directory if the secure path is unavailable } - const tempPath = join(tempDir, "accounts.json"); - await fs.writeFile(tempPath, `${JSON.stringify(storage, null, 2)}\n`, { - encoding: "utf-8", - mode: 0o600, - flag: "wx", - }); + + const tempDir = await fs.mkdtemp(join(tmpdir(), "oc-chatgpt-multi-auth-sync-")); try { + await fs.chmod(tempDir, 0o700).catch(() => undefined); + const tempPath = join(tempDir, "accounts.json"); + await fs.writeFile(tempPath, `${JSON.stringify(storage, null, 2)}\n`, { + encoding: "utf-8", + mode: 0o600, + flag: "wx", + }); return await handler(tempPath); } finally { await fs.rm(tempDir, { recursive: true, force: true }).catch(() => undefined); @@ -505,10 +521,19 @@ export async function syncFromCodexMultiAuth( try { result = await withNormalizedImportFile( finalStorage, - (filePath) => importAccounts(filePath, { - preImportBackupPrefix: "codex-multi-auth-sync-backup", - backupMode: "required", - }), + (filePath) => + importAccounts( + filePath, + { + preImportBackupPrefix: "codex-multi-auth-sync-backup", + backupMode: "required", + }, + (normalizedStorage, existing) => + filterSourceAccountsAgainstExistingEmails( + normalizedStorage, + existing?.accounts ?? [], + ), + ), ); } catch (error) { if ( diff --git a/lib/config.ts b/lib/config.ts index 7a80130d..e28a0ec6 100644 --- a/lib/config.ts +++ b/lib/config.ts @@ -157,9 +157,31 @@ export function savePluginConfigMutation( (code === "EEXIST" || code === "EPERM") && existsSync(CONFIG_PATH) ) { - unlinkSync(CONFIG_PATH); - renameSync(tempPath, CONFIG_PATH); - return; + const backupPath = `${CONFIG_PATH}.${process.pid}.${Date.now()}.bak`; + renameSync(CONFIG_PATH, backupPath); + try { + renameSync(tempPath, CONFIG_PATH); + try { + unlinkSync(backupPath); + } catch { + // best effort backup cleanup + } + return; + } catch (retryError) { + try { + if (!existsSync(CONFIG_PATH)) { + renameSync(backupPath, CONFIG_PATH); + } + } catch { + // best effort config restore + } + try { + unlinkSync(tempPath); + } catch { + // best effort temp cleanup + } + throw retryError; + } } try { unlinkSync(tempPath); @@ -212,7 +234,13 @@ function withPluginConfigLock(fn: () => T): T { lockOwnerPid !== process.pid && !isProcessAlive(lockOwnerPid) ) { - unlinkSync(CONFIG_LOCK_PATH); + const staleLockPath = `${CONFIG_LOCK_PATH}.${lockOwnerPid}.${process.pid}.${Date.now()}.stale`; + renameSync(CONFIG_LOCK_PATH, staleLockPath); + try { + unlinkSync(staleLockPath); + } catch { + // best effort stale-lock cleanup + } continue; } } catch { diff --git a/lib/storage.ts b/lib/storage.ts index 1175d2c6..4a8aa13f 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 { @@ -1362,6 +1367,7 @@ export async function exportAccounts(filePath: string, force = true): Promise { const { resolvedPath, normalized } = await readAndNormalizeImportFile(filePath); const backupMode = options.backupMode ?? "none"; @@ -1376,6 +1382,7 @@ export async function importAccounts( backupError, } = await withAccountStorageTransaction(async (existing, persist) => { + const preparedNormalized = prepare ? prepare(normalized, existing) : normalized; const existingStorage: AccountStorageV3 = existing ?? ({ @@ -1411,7 +1418,7 @@ export async function importAccounts( } } - const merged = [...existingAccounts, ...normalized.accounts]; + const merged = [...existingAccounts, ...preparedNormalized.accounts]; const hasFiniteAccountLimit = Number.isFinite(ACCOUNT_LIMITS.MAX_ACCOUNTS); if (hasFiniteAccountLimit && merged.length > ACCOUNT_LIMITS.MAX_ACCOUNTS) { @@ -1460,7 +1467,7 @@ export async function importAccounts( await persist(newStorage); const imported = deduplicatedAccounts.length - existingAccounts.length; - const skipped = normalized.accounts.length - imported; + const skipped = preparedNormalized.accounts.length - imported; return { imported, total: deduplicatedAccounts.length, diff --git a/lib/ui/select.ts b/lib/ui/select.ts index b45412ff..b9981c6d 100644 --- a/lib/ui/select.ts +++ b/lib/ui/select.ts @@ -52,7 +52,7 @@ const ANSI_REGEX = new RegExp("\\x1b\\[[0-9;]*m", "g"); // biome-ignore lint/suspicious/noControlCharactersInRegex: matching ANSI escape codes const ANSI_LEADING_REGEX = new RegExp("^\\x1b\\[[0-9;]*m"); const CSI_FINAL_KEYS = new Set(["A", "B", "C", "D", "H", "F"]); -const CSI_TILDE_PATTERN = /^(1|4|7|8)~$/; +const CSI_TILDE_PATTERN = /^\d+~$/; export interface PendingInputSequence { value: string; @@ -239,16 +239,31 @@ export function tokenizeTerminalInput(rawInput: string): string[] { const next = rawInput[index + 1]; const third = rawInput[index + 2]; - const fourth = rawInput[index + 3]; - if (next === "[" && third && CSI_FINAL_KEYS.has(third)) { - tokens.push(rawInput.slice(index, index + 3)); - index += 3; - continue; - } - if (next === "[" && third && fourth && CSI_TILDE_PATTERN.test(`${third}${fourth}`)) { - tokens.push(rawInput.slice(index, index + 4)); - index += 4; - continue; + if (next === "[") { + let cursor = index + 2; + let consumed = false; + while (cursor < rawInput.length) { + const current = rawInput.charAt(cursor); + if (CSI_FINAL_KEYS.has(current)) { + tokens.push(rawInput.slice(index, cursor + 1)); + index = cursor + 1; + consumed = true; + break; + } + if (current === "~" && CSI_TILDE_PATTERN.test(rawInput.slice(index + 2, cursor + 1))) { + tokens.push(rawInput.slice(index, cursor + 1)); + index = cursor + 1; + consumed = true; + break; + } + if (!/[0-9;]/.test(current)) { + break; + } + cursor += 1; + } + if (consumed) { + continue; + } } if (next === "O" && third && CSI_FINAL_KEYS.has(third)) { tokens.push(rawInput.slice(index, index + 3)); diff --git a/test/codex-multi-auth-sync.test.ts b/test/codex-multi-auth-sync.test.ts index 9a3e84b3..97ed0f74 100644 --- a/test/codex-multi-auth-sync.test.ts +++ b/test/codex-multi-auth-sync.test.ts @@ -205,6 +205,7 @@ describe("codex-multi-auth sync", () => { preImportBackupPrefix: "codex-multi-auth-sync-backup", backupMode: "required", }, + expect.any(Function), ); }); diff --git a/test/ui-select.test.ts b/test/ui-select.test.ts index 34b81921..04276f10 100644 --- a/test/ui-select.test.ts +++ b/test/ui-select.test.ts @@ -57,4 +57,8 @@ describe("ui-select", () => { it("tokenizes packed escape and control chunks", () => { expect(tokenizeTerminalInput("\u001b[B\u0003")).toEqual(["\u001b[B", "\u0003"]); }); + + it("tokenizes CSI tilde sequences without splitting numeric hotkeys", () => { + expect(tokenizeTerminalInput("\u001b[5~1")).toEqual(["\u001b[5~", "1"]); + }); }); From dcc01d203c2a9b066e161c4dd7384dbfb6d3b4c5 Mon Sep 17 00:00:00 2001 From: ndycode Date: Sat, 7 Mar 2026 20:19:16 +0800 Subject: [PATCH 24/81] fix: close latest index review findings Handles zero-import rollback failures explicitly and guards the auto-repair dashboard action so it cannot unwind the auth flow. Co-authored-by: Codex --- index.ts | 103 ++++++++++++++++++++++++++++++------------------------- 1 file changed, 56 insertions(+), 47 deletions(-) diff --git a/index.ts b/index.ts index f25d8d91..7bda663c 100644 --- a/index.ts +++ b/index.ts @@ -3785,10 +3785,13 @@ while (attempted.size < Math.max(1, accountCount)) { try { await restorePruneBackup(); } catch (restoreError) { + const message = + restoreError instanceof Error ? restoreError.message : String(restoreError); logWarn( - `[${PLUGIN_NAME}] Failed to restore prune backup after zero-import preview: ${ - restoreError instanceof Error ? restoreError.message : String(restoreError) - }`, + `[${PLUGIN_NAME}] Failed to restore prune backup after zero-import preview: ${message}`, + ); + throw new Error( + `Failed to restore previously pruned accounts after zero-import preview: ${message}`, ); } } @@ -3956,54 +3959,60 @@ while (attempted.size < Math.max(1, accountCount)) { }; const runAutoRepairFromDashboard = async (): Promise => { - const initialStorage = await loadAccounts(); - if (!initialStorage || initialStorage.accounts.length === 0) { - console.log("\nNo accounts available.\n"); - return; - } - const appliedFixes: string[] = []; - const fixErrors: string[] = []; - const cleanupResult = await cleanupCodexMultiAuthSyncedOverlaps(); - if (cleanupResult.removed > 0) { - appliedFixes.push(`Removed ${cleanupResult.removed} synced overlap(s).`); - } - const storage = await loadAccounts(); - if (!storage || storage.accounts.length === 0) { - console.log("\nNo accounts available after cleanup.\n"); - return; - } + try { + const initialStorage = await loadAccounts(); + if (!initialStorage || initialStorage.accounts.length === 0) { + console.log("\nNo accounts available.\n"); + return; + } + const appliedFixes: string[] = []; + const fixErrors: string[] = []; + const cleanupResult = await cleanupCodexMultiAuthSyncedOverlaps(); + if (cleanupResult.removed > 0) { + appliedFixes.push(`Removed ${cleanupResult.removed} synced overlap(s).`); + } + const storage = await loadAccounts(); + if (!storage || storage.accounts.length === 0) { + console.log("\nNo accounts available after cleanup.\n"); + return; + } - let changedByRefresh = false; - let refreshedCount = 0; - for (const account of storage.accounts) { - try { - const refreshResult = await queuedRefresh(account.refreshToken); - if (refreshResult.type === "success") { - account.refreshToken = refreshResult.refresh; - account.accessToken = refreshResult.access; - account.expiresAt = refreshResult.expires; - changedByRefresh = true; - refreshedCount += 1; + let changedByRefresh = false; + let refreshedCount = 0; + for (const account of storage.accounts) { + try { + const refreshResult = await queuedRefresh(account.refreshToken); + if (refreshResult.type === "success") { + account.refreshToken = refreshResult.refresh; + account.accessToken = refreshResult.access; + account.expiresAt = refreshResult.expires; + changedByRefresh = true; + refreshedCount += 1; + } + } catch (error) { + fixErrors.push(error instanceof Error ? error.message : String(error)); } - } catch (error) { - fixErrors.push(error instanceof Error ? error.message : String(error)); } + + if (changedByRefresh) { + await saveAccounts(storage); + appliedFixes.push(`Refreshed ${refreshedCount} account token(s).`); + } + await verifyFlaggedAccounts(); + await pickBestAccountFromDashboard(); + console.log(""); + console.log("Auto-repair complete."); + for (const entry of appliedFixes) { + console.log(`- ${entry}`); + } + for (const entry of fixErrors) { + console.log(`- warning: ${entry}`); + } + console.log(""); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + console.log(`\nAuto-repair failed: ${message}\n`); } - if (changedByRefresh) { - await saveAccounts(storage); - appliedFixes.push(`Refreshed ${refreshedCount} account token(s).`); - } - await verifyFlaggedAccounts(); - await pickBestAccountFromDashboard(); - console.log(""); - console.log("Auto-repair complete."); - for (const entry of appliedFixes) { - console.log(`- ${entry}`); - } - for (const entry of fixErrors) { - console.log(`- warning: ${entry}`); - } - console.log(""); }; if (!explicitLoginMode) { From bbbdc6a7257d42357d6cc0f8b16ddaa811bff8ad Mon Sep 17 00:00:00 2001 From: ndycode Date: Sat, 7 Mar 2026 20:36:12 +0800 Subject: [PATCH 25/81] fix: close remaining sync and config review findings Prevents temp-handler retries, preserves malformed config files on toggle errors, and reports prepare-filtered skips correctly with regression coverage. Co-authored-by: Codex --- lib/codex-multi-auth-sync.ts | 35 +++++---------------- lib/config.ts | 2 +- lib/storage.ts | 6 +++- test/codex-multi-auth-sync.test.ts | 49 ++++++++++++++++++++++++++++++ test/plugin-config.test.ts | 6 ++-- 5 files changed, 66 insertions(+), 32 deletions(-) diff --git a/lib/codex-multi-auth-sync.ts b/lib/codex-multi-auth-sync.ts index e5cfb0ea..177831d4 100644 --- a/lib/codex-multi-auth-sync.ts +++ b/lib/codex-multi-auth-sync.ts @@ -1,5 +1,5 @@ import { existsSync, readFileSync, promises as fs } from "node:fs"; -import { homedir, tmpdir } from "node:os"; +import { homedir } from "node:os"; import { join, win32 } from "node:path"; import { ACCOUNT_LIMITS } from "./constants.js"; import { @@ -109,10 +109,7 @@ async function withNormalizedImportFile( storage: AccountStorageV3, handler: (filePath: string) => Promise, ): Promise { - try { - const secureTempRoot = join(getResolvedUserHomeDir(), ".opencode", "tmp"); - await fs.mkdir(secureTempRoot, { recursive: true, mode: 0o700 }).catch(() => undefined); - const tempDir = await fs.mkdtemp(join(secureTempRoot, "oc-chatgpt-multi-auth-sync-")); + const runWithTempDir = async (tempDir: string): Promise => { await fs.chmod(tempDir, 0o700).catch(() => undefined); const tempPath = join(tempDir, "accounts.json"); await fs.writeFile(tempPath, `${JSON.stringify(storage, null, 2)}\n`, { @@ -125,23 +122,12 @@ async function withNormalizedImportFile( } finally { await fs.rm(tempDir, { recursive: true, force: true }).catch(() => undefined); } - } catch { - // fall back to the process temp directory if the secure path is unavailable - } + }; - const tempDir = await fs.mkdtemp(join(tmpdir(), "oc-chatgpt-multi-auth-sync-")); - try { - await fs.chmod(tempDir, 0o700).catch(() => undefined); - const tempPath = join(tempDir, "accounts.json"); - await fs.writeFile(tempPath, `${JSON.stringify(storage, null, 2)}\n`, { - encoding: "utf-8", - mode: 0o600, - flag: "wx", - }); - return await handler(tempPath); - } finally { - await fs.rm(tempDir, { recursive: true, force: true }).catch(() => undefined); - } + const secureTempRoot = join(getResolvedUserHomeDir(), ".opencode", "tmp"); + await fs.mkdir(secureTempRoot, { recursive: true, mode: 0o700 }); + const tempDir = await fs.mkdtemp(join(secureTempRoot, "oc-chatgpt-multi-auth-sync-")); + return runWithTempDir(tempDir); } function deduplicateAccountsForSync(storage: AccountStorageV3): AccountStorageV3 { @@ -512,15 +498,10 @@ export async function syncFromCodexMultiAuth( projectPath = process.cwd(), ): Promise { const resolved = await loadPreparedCodexMultiAuthSourceStorage(projectPath); - const currentStorage = await withAccountStorageTransaction((current) => Promise.resolve(current)); - const finalStorage = filterSourceAccountsAgainstExistingEmails( - resolved.storage, - currentStorage?.accounts ?? [], - ); let result: ImportAccountsResult; try { result = await withNormalizedImportFile( - finalStorage, + resolved.storage, (filePath) => importAccounts( filePath, diff --git a/lib/config.ts b/lib/config.ts index e28a0ec6..f5660d75 100644 --- a/lib/config.ts +++ b/lib/config.ts @@ -360,7 +360,7 @@ export function setSyncFromCodexMultiAuthEnabled(enabled: boolean): void { experimental.syncFromCodexMultiAuth = syncSettings; current.experimental = experimental; return current; - }, { recoverInvalidCurrent: true }); + }); } export function getCodexTuiColorProfile( diff --git a/lib/storage.ts b/lib/storage.ts index 4a8aa13f..fc9329f9 100644 --- a/lib/storage.ts +++ b/lib/storage.ts @@ -1383,6 +1383,10 @@ export async function importAccounts( } = 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 ?? ({ @@ -1467,7 +1471,7 @@ export async function importAccounts( await persist(newStorage); const imported = deduplicatedAccounts.length - existingAccounts.length; - const skipped = preparedNormalized.accounts.length - imported; + const skipped = skippedByPrepare + (preparedNormalized.accounts.length - imported); return { imported, total: deduplicatedAccounts.length, diff --git a/test/codex-multi-auth-sync.test.ts b/test/codex-multi-auth-sync.test.ts index 97ed0f74..2de68d14 100644 --- a/test/codex-multi-auth-sync.test.ts +++ b/test/codex-multi-auth-sync.test.ts @@ -1,5 +1,6 @@ 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"; @@ -209,6 +210,54 @@ describe("codex-multi-auth sync", () => { ); }); + 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); + mockReadFileSync.mockReturnValue( + JSON.stringify({ + version: 3, + activeIndex: 0, + accounts: [{ refreshToken: "sync-refresh", addedAt: 1, lastUsed: 1 }], + }), + ); + + const storageModule = await import("../lib/storage.js"); + vi.mocked(storageModule.previewImportAccounts).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.previewImportAccounts)).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); + mockReadFileSync.mockReturnValue( + JSON.stringify({ + version: 3, + activeIndex: 0, + accounts: [{ refreshToken: "sync-refresh", addedAt: 1, lastUsed: 1 }], + }), + ); + + const mkdtempSpy = vi.spyOn(fs.promises, "mkdtemp").mockRejectedValueOnce(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(vi.mocked(storageModule.previewImportAccounts)).not.toHaveBeenCalledWith( + expect.stringContaining(os.tmpdir()), + ); + } finally { + mkdtempSpy.mockRestore(); + } + }); + it("skips source accounts whose emails already exist locally during sync", async () => { const rootDir = join(process.cwd(), ".tmp-codex-multi-auth"); process.env.CODEX_MULTI_AUTH_DIR = rootDir; diff --git a/test/plugin-config.test.ts b/test/plugin-config.test.ts index 4d0fa718..7cfa5ea4 100644 --- a/test/plugin-config.test.ts +++ b/test/plugin-config.test.ts @@ -841,12 +841,12 @@ describe('Plugin Configuration', () => { ); }); - it('recovers malformed config when toggling sync setting', () => { + it('throws when toggling sync setting on malformed config to preserve existing settings', () => { mockExistsSync.mockReturnValue(true); mockReadFileSync.mockReturnValue('invalid json'); - expect(() => setSyncFromCodexMultiAuthEnabled(true)).not.toThrow(); - expect(mockRenameSync).toHaveBeenCalled(); + expect(() => setSyncFromCodexMultiAuthEnabled(true)).toThrow(); + expect(mockRenameSync).not.toHaveBeenCalled(); }); it('recovers stale config lock files before mutating config', () => { From 26cfe320bb9b36815b12d573698b2cfe91172f66 Mon Sep 17 00:00:00 2001 From: ndycode Date: Sat, 7 Mar 2026 20:50:33 +0800 Subject: [PATCH 26/81] test: cover packed ss3 selector sequences Adds a regression for packed SS3 arrow tokenization so the selector keeps complete coverage of Windows terminal escape handling. Co-authored-by: Codex --- test/ui-select.test.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/test/ui-select.test.ts b/test/ui-select.test.ts index 04276f10..997af0c9 100644 --- a/test/ui-select.test.ts +++ b/test/ui-select.test.ts @@ -61,4 +61,8 @@ describe("ui-select", () => { it("tokenizes CSI tilde sequences without splitting numeric hotkeys", () => { expect(tokenizeTerminalInput("\u001b[5~1")).toEqual(["\u001b[5~", "1"]); }); + + it("tokenizes packed SS3 arrow sequences", () => { + expect(tokenizeTerminalInput("\u001bOA\u001bOB")).toEqual(["\u001bOA", "\u001bOB"]); + }); }); From edc589663657ede9e0736a5f5400f4c66f4fe293 Mon Sep 17 00:00:00 2001 From: ndycode Date: Sat, 7 Mar 2026 20:58:57 +0800 Subject: [PATCH 27/81] fix: close remaining sync config review findings Prevents temp-handler retries, refuses malformed config rewrites on toggle, and reports prepare-filtered skips with regression coverage. Co-authored-by: Codex --- lib/codex-multi-auth-sync.ts | 73 +++++++++++++-- test/codex-multi-auth-sync.test.ts | 146 ++++++++++++++++++++++++++++- 2 files changed, 212 insertions(+), 7 deletions(-) diff --git a/lib/codex-multi-auth-sync.ts b/lib/codex-multi-auth-sync.ts index 177831d4..9ee1a15a 100644 --- a/lib/codex-multi-auth-sync.ts +++ b/lib/codex-multi-auth-sync.ts @@ -19,6 +19,7 @@ const EXTERNAL_ACCOUNT_FILE_NAMES = [ "openai-codex-accounts.json", "codex-accounts.json", ]; +const SYNC_ACCOUNT_TAG = "codex-multi-auth-sync"; export interface CodexMultiAuthResolvedSource { rootDir: string; @@ -153,10 +154,11 @@ function selectNewestByTimestamp(); - for (const account of accounts) { + for (const account of deduplicatedInput) { const normalizedEmail = normalizeIdentity(account.email); if (!normalizedEmail) { deduplicated.push(account); @@ -359,10 +361,46 @@ function getCodexHomeDir(): string { return fromEnv.length > 0 ? fromEnv : join(getResolvedUserHomeDir(), ".codex"); } +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, "\\"); + if (normalized.startsWith("\\\\") || normalized.startsWith("\\?\\") || normalized.startsWith("\\.\\")) { + throw new Error("CODEX_MULTI_AUTH_DIR must use a local absolute path on Windows"); + } + if (!/^[a-zA-Z]:\\/.test(normalized)) { + 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 fromEnv; + return validateCodexMultiAuthRootDir(fromEnv); } const userHome = getResolvedUserHomeDir(); @@ -501,7 +539,7 @@ export async function syncFromCodexMultiAuth( let result: ImportAccountsResult; try { result = await withNormalizedImportFile( - resolved.storage, + tagSyncedAccounts(resolved.storage), (filePath) => importAccounts( filePath, @@ -521,7 +559,7 @@ export async function syncFromCodexMultiAuth( error instanceof CodexMultiAuthSyncCapacityError || (error instanceof Error && /exceed(?: the)? maximum/i.test(error.message)) ) { - await assertSyncWithinCapacity(resolved); + await assertSyncWithinCapacity(await loadPreparedCodexMultiAuthSourceStorage(projectPath)); } throw error; } @@ -547,8 +585,27 @@ export async function cleanupCodexMultiAuthSyncedOverlaps(): Promise + Array.isArray(account.accountTags) && account.accountTags.includes(SYNC_ACCOUNT_TAG), + ); + if (syncedAccounts.length === 0) { + return { + 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 { before, after: before, @@ -556,6 +613,10 @@ export async function cleanupCodexMultiAuthSyncedOverlaps(): Promise { ); }); + 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("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; @@ -356,6 +365,56 @@ describe("codex-multi-auth sync", () => { }); }); + 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); + mockReadFileSync.mockReturnValue( + 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.previewImportAccounts).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; @@ -394,8 +453,37 @@ describe("codex-multi-auth sync", () => { expect(() => loadCodexMultiAuthSourceStorage(process.cwd())).toThrow(/Invalid JSON/); }); - it("cleans up existing overlaps by normalizing org-scoped identities first", async () => { + 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) => { const record = value as { version: 3; @@ -417,6 +505,62 @@ describe("codex-multi-auth sync", () => { }); }); + it("limits overlap cleanup to accounts tagged from codex-multi-auth sync", async () => { + const storageModule = await import("../lib/storage.js"); + vi.mocked(storageModule.withAccountStorageTransaction).mockImplementationOnce(async (handler) => + handler( + { + 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("does not block preview when account limit is unlimited", async () => { const rootDir = join(process.cwd(), ".tmp-codex-multi-auth"); process.env.CODEX_MULTI_AUTH_DIR = rootDir; From 238785faf1f0b1d832081b316d22a9e1e83aaa26 Mon Sep 17 00:00:00 2001 From: ndycode Date: Sat, 7 Mar 2026 21:06:05 +0800 Subject: [PATCH 28/81] fix: move best-account dashboard action into dedicated view Routes the forecast dashboard action through the same dedicated operation flow as the other dashboard actions and adds regression coverage. Co-authored-by: Codex --- index.ts | 103 +++++++++++++++++++++++++++++++++------------ test/index.test.ts | 41 ++++++++++++++++++ 2 files changed, 116 insertions(+), 28 deletions(-) diff --git a/index.ts b/index.ts index 7bda663c..5e0d22f9 100644 --- a/index.ts +++ b/index.ts @@ -3927,35 +3927,82 @@ while (attempted.size < Math.max(1, accountCount)) { }; const pickBestAccountFromDashboard = async (): Promise => { - const storage = await loadAccounts(); - if (!storage || storage.accounts.length === 0) { - console.log("\nNo accounts available.\n"); - return; - } - const now = Date.now(); - const managerForFix = await AccountManager.loadFromDisk(); - cachedAccountManager = managerForFix; - const explainability = managerForFix.getSelectionExplainability("codex", undefined, now); - const eligible = explainability - .filter((entry) => entry.eligible) - .sort((a, b) => { - if (b.healthScore !== a.healthScore) return b.healthScore - a.healthScore; - return b.tokensAvailable - a.tokensAvailable; - }); - const best = eligible[0]; - if (!best) { - console.log("\nNo eligible account available.\n"); - return; - } - storage.activeIndex = best.index; - storage.activeIndexByFamily = storage.activeIndexByFamily ?? {}; - for (const family of MODEL_FAMILIES) { - storage.activeIndexByFamily[family] = best.index; + const ui = resolveUiRuntime(); + const screen = createOperationScreen(ui, "Best Account", "Comparing accounts"); + let screenFinished = false; + try { + const storage = await loadAccounts(); + if (!storage || storage.accounts.length === 0) { + if (screen) { + screen.push("No accounts available.", "warning"); + await screen.finish(); + screenFinished = true; + } else { + console.log("\nNo accounts available.\n"); + } + return; + } + + const now = Date.now(); + const managerForFix = await AccountManager.loadFromDisk(); + cachedAccountManager = managerForFix; + const explainability = managerForFix.getSelectionExplainability("codex", undefined, now); + const eligible = explainability + .filter((entry) => entry.eligible) + .sort((a, b) => { + if (b.healthScore !== a.healthScore) return b.healthScore - a.healthScore; + return b.tokensAvailable - a.tokensAvailable; + }); + const best = eligible[0]; + if (!best) { + if (screen) { + screen.push(`Compared ${explainability.length} account(s).`, "muted"); + screen.push("No eligible account available.", "warning"); + await screen.finish(); + screenFinished = true; + } else { + console.log("\nNo eligible account available.\n"); + } + return; + } + + storage.activeIndex = best.index; + storage.activeIndexByFamily = storage.activeIndexByFamily ?? {}; + for (const family of MODEL_FAMILIES) { + storage.activeIndexByFamily[family] = best.index; + } + await saveAccounts(storage); + invalidateAccountManagerCache(); + const account = storage.accounts[best.index]; + const selectedLabel = formatCommandAccountLabel(account, best.index); + + if (screen) { + screen.push(`Compared ${explainability.length} account(s); ${eligible.length} eligible.`, "muted"); + screen.push(`${getStatusMarker(ui, "ok")} ${selectedLabel}`, "success"); + if (best.reasons.length > 0) { + screen.push(`Why: ${best.reasons.slice(0, 3).join("; ")}`, "muted"); + } + screen.push(`Health ${best.healthScore}; tokens ${best.tokensAvailable}`, "muted"); + await screen.finish([{ line: "Best account selected.", tone: "success" }]); + screenFinished = true; + return; + } + + console.log(`\nSelected best account: ${account?.email ?? `Account ${best.index + 1}`}\n`); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + if (screen) { + screen.push(`Failed to pick best account: ${message}`, "danger"); + await screen.finish(); + screenFinished = true; + return; + } + console.log(`\nFailed to pick best account: ${message}\n`); + } finally { + if (screen && !screenFinished) { + screen.abort(); + } } - await saveAccounts(storage); - invalidateAccountManagerCache(); - const account = storage.accounts[best.index]; - console.log(`\nSelected best account: ${account?.email ?? `Account ${best.index + 1}`}\n`); }; const runAutoRepairFromDashboard = async (): Promise => { diff --git a/test/index.test.ts b/test/index.test.ts index 278eab9e..74e3c3a3 100644 --- a/test/index.test.ts +++ b/test/index.test.ts @@ -2779,6 +2779,47 @@ describe("OpenAIOAuthPlugin persistAccountPool", () => { expect(mockStorage.accounts[0]?.accountId).toBe("org-newer"); }); + it("runs best-account selection from the dashboard forecast action", async () => { + const cliModule = await import("../lib/cli.js"); + const storageModule = await import("../lib/storage.js"); + + mockStorage.accounts = [ + { + accountId: "org-primary", + organizationId: "org-primary", + accountIdSource: "org", + email: "primary@example.com", + refreshToken: "refresh-primary", + }, + ]; + mockStorage.activeIndex = 0; + mockStorage.activeIndexByFamily = {}; + + vi.mocked(cliModule.promptLoginMode) + .mockResolvedValueOnce({ mode: "forecast" }) + .mockResolvedValueOnce({ mode: "cancel" }); + + vi.mocked(storageModule.saveAccounts).mockImplementation(async (nextStorage) => { + mockStorage.version = nextStorage.version; + mockStorage.activeIndex = nextStorage.activeIndex; + mockStorage.activeIndexByFamily = { ...nextStorage.activeIndexByFamily }; + mockStorage.accounts = nextStorage.accounts.map((account) => ({ ...account })); + }); + + const mockClient = createMockClient(); + const { OpenAIOAuthPlugin } = await import("../index.js"); + const plugin = (await OpenAIOAuthPlugin({ client: mockClient } as never)) as unknown as PluginType; + const autoMethod = plugin.auth.methods[0] as unknown as { + authorize: (inputs?: Record) => Promise<{ instructions: string }>; + }; + + const authResult = await autoMethod.authorize(); + expect(authResult.instructions).toBe("Authentication cancelled"); + expect(vi.mocked(storageModule.saveAccounts)).toHaveBeenCalled(); + expect(mockStorage.activeIndex).toBe(0); + expect(mockStorage.activeIndexByFamily.codex).toBe(0); + }); + it("restores pruned accounts when sync does not commit after a prune retry", async () => { const cliModule = await import("../lib/cli.js"); const storageModule = await import("../lib/storage.js"); From b1380733ebcdc5ff6dcb11d9fee2034dd9d6fc63 Mon Sep 17 00:00:00 2001 From: ndycode Date: Sat, 7 Mar 2026 21:52:29 +0800 Subject: [PATCH 29/81] fix: align auth dashboard flow with codex panels - move settings into its own dashboard section and category hub - make action screens use codex-style return/pause behavior - keep auto-repair inside one shared panel instead of nesting flows Co-authored-by: Codex --- index.ts | 345 +++++++++++++++++++++++++++++++++++------ lib/ui/auth-menu.ts | 105 ++++++++++--- lib/ui/copy.ts | 6 +- test/auth-menu.test.ts | 18 ++- 4 files changed, 395 insertions(+), 79 deletions(-) diff --git a/index.ts b/index.ts index 5e0d22f9..58f87a0d 100644 --- a/index.ts +++ b/index.ts @@ -25,6 +25,7 @@ import { tool } from "@opencode-ai/plugin/tool"; import { promises as fsPromises } from "node:fs"; +import { createInterface } from "node:readline/promises"; import type { Plugin, PluginInput } from "@opencode-ai/plugin"; import type { Auth } from "@opencode-ai/sdk"; import { @@ -1233,27 +1234,200 @@ export const OpenAIOAuthPlugin: Plugin = async ({ client }: PluginInput) => { const SCREEN_CONTROL_CHAR_REGEX = new RegExp("[\\u0000-\\u0008\\u000b\\u000c\\u000e-\\u001f\\u007f]", "g"); const sanitizeScreenText = (value: string): string => value.replace(ANSI_STYLE_REGEX, "").replace(SCREEN_CONTROL_CHAR_REGEX, "").trim(); + type OperationTone = "normal" | "muted" | "success" | "warning" | "danger" | "accent"; + + const styleOperationText = ( + ui: UiRuntimeOptions, + text: string, + tone: OperationTone, + ): string => { + if (ui.v2Enabled) { + const mappedTone = + tone === "accent" ? "accent" : tone === "normal" ? "normal" : tone; + return paintUiText(ui, text, mappedTone); + } + const ansiCode = + tone === "accent" + ? ANSI.cyan + : tone === "success" + ? ANSI.green + : tone === "warning" + ? ANSI.yellow + : tone === "danger" + ? ANSI.red + : tone === "muted" + ? ANSI.dim + : ""; + return ansiCode ? `${ansiCode}${text}${ANSI.reset}` : text; + }; + + const isAbortError = (error: unknown): boolean => { + if (!(error instanceof Error)) return false; + const maybe = error as Error & { code?: string }; + return maybe.name === "AbortError" || maybe.code === "ABORT_ERR"; + }; + + const waitForMenuReturn = async ( + ui: UiRuntimeOptions, + options: { + promptText?: string; + autoReturnMs?: number; + pauseOnAnyKey?: boolean; + } = {}, + ): Promise => { + if (!process.stdin.isTTY || !process.stdout.isTTY) { + return; + } + + const promptText = options.promptText ?? "Press Enter to return to the dashboard."; + const autoReturnMs = options.autoReturnMs ?? 0; + const pauseOnAnyKey = options.pauseOnAnyKey ?? true; + + try { + let chunk: Buffer | string | null; + do { + chunk = process.stdin.read(); + } while (chunk !== null); + } catch { + // best effort drain + } + + const writeInlineStatus = (message: string) => { + process.stdout.write(`\r${ANSI.clearLine}${styleOperationText(ui, message, "muted")}`); + }; + const clearInlineStatus = () => { + process.stdout.write(`\r${ANSI.clearLine}`); + }; + + if (autoReturnMs > 0) { + if (!pauseOnAnyKey) { + await new Promise((resolve) => setTimeout(resolve, autoReturnMs)); + return; + } + + const wasRaw = process.stdin.isRaw ?? false; + const endAt = Date.now() + autoReturnMs; + let lastShownSeconds: number | null = null; + const renderCountdown = () => { + const remainingMs = Math.max(0, endAt - Date.now()); + const remainingSeconds = Math.max(1, Math.ceil(remainingMs / 1000)); + if (lastShownSeconds === remainingSeconds) return; + lastShownSeconds = remainingSeconds; + writeInlineStatus(`Returning to dashboard in ${remainingSeconds}s. Press any key to pause.`); + }; + + renderCountdown(); + const pinned = await new Promise((resolve) => { + let done = false; + const interval = setInterval(renderCountdown, 80); + let timeout: NodeJS.Timeout | null = setTimeout(() => { + timeout = null; + if (!done) { + done = true; + cleanup(); + resolve(false); + } + }, autoReturnMs); + const onData = () => { + if (done) return; + done = true; + cleanup(); + resolve(true); + }; + const cleanup = () => { + clearInterval(interval); + if (timeout) { + clearTimeout(timeout); + timeout = null; + } + process.stdin.removeListener("data", onData); + try { + process.stdin.setRawMode(wasRaw); + } catch { + // best effort restore + } + }; + + try { + process.stdin.setRawMode(true); + } catch { + // best effort + } + process.stdin.on("data", onData); + process.stdin.resume(); + }); + + clearInlineStatus(); + if (!pinned) { + return; + } + + writeInlineStatus("Paused. Press any key to return."); + await new Promise((resolve) => { + const wasRaw = process.stdin.isRaw ?? false; + const onData = () => { + cleanup(); + resolve(); + }; + const cleanup = () => { + process.stdin.removeListener("data", onData); + try { + process.stdin.setRawMode(wasRaw); + } catch { + // best effort restore + } + }; + + try { + process.stdin.setRawMode(true); + } catch { + // best effort fallback + } + process.stdin.on("data", onData); + process.stdin.resume(); + }); + clearInlineStatus(); + return; + } + + const rl = createInterface({ + input: process.stdin, + output: process.stdout, + }); + try { + process.stdout.write(`\r${ANSI.clearLine}`); + await rl.question(`${styleOperationText(ui, promptText, "muted")} `); + } catch (error) { + if (!isAbortError(error)) { + throw error; + } + } finally { + rl.close(); + clearInlineStatus(); + } + }; const createOperationScreen = ( ui: UiRuntimeOptions, title: string, subtitle?: string, ): { - push: (line: string, tone?: "normal" | "muted" | "success" | "warning" | "danger" | "accent") => void; - finish: (summaryLines?: Array<{ line: string; tone?: "normal" | "muted" | "success" | "warning" | "danger" | "accent" }>) => Promise; + push: (line: string, tone?: OperationTone) => void; + finish: ( + summaryLines?: Array<{ line: string; tone?: OperationTone }>, + options?: { failed?: boolean }, + ) => Promise; abort: () => void; } | null => { - if (!ui.v2Enabled || !supportsInteractiveMenus()) { + if (!supportsInteractiveMenus()) { return null; } - const entries: Array<{ - line: string; - tone: "normal" | "muted" | "success" | "warning" | "danger" | "accent"; - }> = []; + const entries: Array<{ line: string; tone: OperationTone }> = []; const spinnerFrames = ["-", "\\", "|", "/"]; let frame = 0; let running = true; + let failed = false; let initialized = false; let timer: NodeJS.Timeout | null = null; let closed = false; @@ -1273,23 +1447,29 @@ export const OpenAIOAuthPlugin: Plugin = async ({ client }: PluginInput) => { const lines: string[] = []; const maxVisibleLines = Math.max(8, (process.stdout.rows ?? 24) - 8); const visibleEntries = entries.slice(-maxVisibleLines); - const spinner = running ? `${spinnerFrames[frame % spinnerFrames.length] ?? "-"} ` : "+ "; - const stageTone: "muted" | "accent" | "success" = running ? "accent" : "success"; - - lines.push(...formatUiHeader(ui, sanitizeScreenText(title))); + const spinner = running + ? `${spinnerFrames[frame % spinnerFrames.length] ?? "-"} ` + : failed + ? "x " + : "+ "; + const stageTone: OperationTone = failed ? "danger" : running ? "accent" : "success"; + const stageText = running + ? `${spinner}${sanitizeScreenText(subtitle ?? "Working")}` + : failed + ? "Action failed" + : "Done"; + + lines.push(styleOperationText(ui, sanitizeScreenText(title), "accent")); + lines.push(styleOperationText(ui, stageText, stageTone)); lines.push(""); - if (subtitle) { - lines.push(paintUiText(ui, `${spinner}${sanitizeScreenText(subtitle)}`, stageTone)); - lines.push(""); - } for (const entry of visibleEntries) { - lines.push(paintUiText(ui, sanitizeScreenText(entry.line), entry.tone)); + lines.push(styleOperationText(ui, sanitizeScreenText(entry.line), entry.tone)); } - if (running) { + for (let i = visibleEntries.length; i < maxVisibleLines; i += 1) { lines.push(""); - lines.push(paintUiText(ui, "Working...", "muted")); } - + lines.push(""); + if (running) lines.push(styleOperationText(ui, "Working...", "muted")); process.stdout.write(ANSI.clearScreen + ANSI.moveTo(1, 1) + lines.join("\n")); frame += 1; }; @@ -1312,7 +1492,7 @@ export const OpenAIOAuthPlugin: Plugin = async ({ client }: PluginInput) => { entries.push({ line: sanitizeScreenText(line), tone }); render(); }, - finish: async (summaryLines) => { + finish: async (summaryLines, options) => { ensureScreen(); if (summaryLines && summaryLines.length > 0) { entries.push({ line: "", tone: "normal" }); @@ -1320,18 +1500,22 @@ export const OpenAIOAuthPlugin: Plugin = async ({ client }: PluginInput) => { entries.push({ line: sanitizeScreenText(entry.line), tone: entry.tone ?? "normal" }); } } + failed = options?.failed === true; running = false; if (timer) { clearInterval(timer); timer = null; } render(); - await new Promise((resolve) => setTimeout(resolve, 2_000)); + await waitForMenuReturn(ui, failed + ? { promptText: "Press Enter to return to the dashboard." } + : { autoReturnMs: 2_000, pauseOnAnyKey: true }); dispose(); }, abort: dispose, }; }; + type DashboardOperationScreen = NonNullable>; const getStatusMarker = ( ui: UiRuntimeOptions, @@ -3441,16 +3625,20 @@ while (attempted.size < Math.max(1, accountCount)) { } }; - const verifyFlaggedAccounts = async (): Promise => { + const verifyFlaggedAccounts = async ( + screenOverride?: DashboardOperationScreen | null, + ): Promise => { const ui = resolveUiRuntime(); - const screen = createOperationScreen( - ui, - "Check Problem Accounts", - "Checking flagged accounts and attempting restore", - ); + const screen = + screenOverride ?? + createOperationScreen( + ui, + "Check Problem Accounts", + "Checking flagged accounts and attempting restore", + ); const emit = ( line: string, - tone: "normal" | "muted" | "success" | "warning" | "danger" | "accent" = "normal", + tone: OperationTone = "normal", ) => { if (screen) { screen.push(line, tone); @@ -3468,8 +3656,8 @@ while (attempted.size < Math.max(1, accountCount)) { ]; const restoreEmailCounts = buildEmailCountMap(restoreContext); if (flaggedStorage.accounts.length === 0) { - emit("No flagged accounts to verify."); - if (screen) { + emit("No flagged accounts to verify."); + if (screen && !screenOverride) { await screen.finish(); screenFinished = true; } @@ -3585,7 +3773,7 @@ while (attempted.size < Math.max(1, accountCount)) { line: string; tone?: "normal" | "muted" | "success" | "warning" | "danger" | "accent"; }> = [{ line: `Results: ${restored.length} restored, ${remaining.length} still flagged`, tone: remaining.length > 0 ? "warning" : "success" }]; - if (screen) { + if (screen && !screenOverride) { await screen.finish(summaryLines); screenFinished = true; return; @@ -3596,7 +3784,7 @@ while (attempted.size < Math.max(1, accountCount)) { } console.log(""); } finally { - if (screen && !screenFinished) { + if (screen && !screenFinished && !screenOverride) { screen.abort(); } } @@ -3926,16 +4114,22 @@ while (attempted.size < Math.max(1, accountCount)) { } }; - const pickBestAccountFromDashboard = async (): Promise => { + const pickBestAccountFromDashboard = async ( + screenOverride?: DashboardOperationScreen | null, + ): Promise => { const ui = resolveUiRuntime(); - const screen = createOperationScreen(ui, "Best Account", "Comparing accounts"); + const screen = + screenOverride ?? + createOperationScreen(ui, "Best Account", "Comparing accounts"); let screenFinished = false; try { const storage = await loadAccounts(); if (!storage || storage.accounts.length === 0) { if (screen) { screen.push("No accounts available.", "warning"); - await screen.finish(); + if (!screenOverride) { + await screen.finish(); + } screenFinished = true; } else { console.log("\nNo accounts available.\n"); @@ -3958,7 +4152,9 @@ while (attempted.size < Math.max(1, accountCount)) { if (screen) { screen.push(`Compared ${explainability.length} account(s).`, "muted"); screen.push("No eligible account available.", "warning"); - await screen.finish(); + if (!screenOverride) { + await screen.finish(); + } screenFinished = true; } else { console.log("\nNo eligible account available.\n"); @@ -3979,11 +4175,16 @@ while (attempted.size < Math.max(1, accountCount)) { if (screen) { screen.push(`Compared ${explainability.length} account(s); ${eligible.length} eligible.`, "muted"); screen.push(`${getStatusMarker(ui, "ok")} ${selectedLabel}`, "success"); + screen.push( + `Availability ready | risk low | health ${best.healthScore} | tokens ${best.tokensAvailable}`, + "muted", + ); if (best.reasons.length > 0) { screen.push(`Why: ${best.reasons.slice(0, 3).join("; ")}`, "muted"); } - screen.push(`Health ${best.healthScore}; tokens ${best.tokensAvailable}`, "muted"); - await screen.finish([{ line: "Best account selected.", tone: "success" }]); + if (!screenOverride) { + await screen.finish([{ line: "Best account selected.", tone: "success" }]); + } screenFinished = true; return; } @@ -3993,23 +4194,46 @@ while (attempted.size < Math.max(1, accountCount)) { const message = error instanceof Error ? error.message : String(error); if (screen) { screen.push(`Failed to pick best account: ${message}`, "danger"); - await screen.finish(); + if (!screenOverride) { + await screen.finish(undefined, { failed: true }); + } screenFinished = true; return; } console.log(`\nFailed to pick best account: ${message}\n`); } finally { - if (screen && !screenFinished) { + if (screen && !screenFinished && !screenOverride) { screen.abort(); } } }; const runAutoRepairFromDashboard = async (): Promise => { + const ui = resolveUiRuntime(); + const screen = createOperationScreen( + ui, + "Auto-Fix", + "Checking and fixing common issues", + ); + let screenFinished = false; + const emit = ( + line: string, + tone: OperationTone = "normal", + ) => { + if (screen) { + screen.push(line, tone); + return; + } + console.log(line); + }; try { const initialStorage = await loadAccounts(); if (!initialStorage || initialStorage.accounts.length === 0) { - console.log("\nNo accounts available.\n"); + emit("No accounts available.", "warning"); + if (screen) { + await screen.finish(); + screenFinished = true; + } return; } const appliedFixes: string[] = []; @@ -4017,10 +4241,15 @@ while (attempted.size < Math.max(1, accountCount)) { const cleanupResult = await cleanupCodexMultiAuthSyncedOverlaps(); if (cleanupResult.removed > 0) { appliedFixes.push(`Removed ${cleanupResult.removed} synced overlap(s).`); + emit(`Removed ${cleanupResult.removed} synced overlap(s).`, "success"); } const storage = await loadAccounts(); if (!storage || storage.accounts.length === 0) { - console.log("\nNo accounts available after cleanup.\n"); + emit("No accounts available after cleanup.", "warning"); + if (screen) { + await screen.finish(); + screenFinished = true; + } return; } @@ -4044,21 +4273,37 @@ while (attempted.size < Math.max(1, accountCount)) { if (changedByRefresh) { await saveAccounts(storage); appliedFixes.push(`Refreshed ${refreshedCount} account token(s).`); + emit(`Refreshed ${refreshedCount} account token(s).`, "success"); } - await verifyFlaggedAccounts(); - await pickBestAccountFromDashboard(); - console.log(""); - console.log("Auto-repair complete."); + await verifyFlaggedAccounts(screen); + await pickBestAccountFromDashboard(screen); + emit(""); + emit("Auto-repair complete.", "success"); for (const entry of appliedFixes) { - console.log(`- ${entry}`); + emit(`- ${entry}`, "muted"); } for (const entry of fixErrors) { - console.log(`- warning: ${entry}`); + emit(`- warning: ${entry}`, "warning"); + } + if (screen) { + await screen.finish(); + screenFinished = true; + } else { + console.log(""); } - console.log(""); } catch (error) { const message = error instanceof Error ? error.message : String(error); - console.log(`\nAuto-repair failed: ${message}\n`); + if (screen) { + screen.push(`Auto-repair failed: ${message}`, "danger"); + await screen.finish(undefined, { failed: true }); + screenFinished = true; + } else { + console.log(`\nAuto-repair failed: ${message}\n`); + } + } finally { + if (screen && !screenFinished) { + screen.abort(); + } } }; diff --git a/lib/ui/auth-menu.ts b/lib/ui/auth-menu.ts index 37d74ee9..26a1141a 100644 --- a/lib/ui/auth-menu.ts +++ b/lib/ui/auth-menu.ts @@ -61,6 +61,8 @@ export type SettingsAction = | "back" | "cancel"; +type SettingsHubAction = "sync" | "maintenance" | "back" | "cancel"; + // biome-ignore lint/suspicious/noControlCharactersInRegex: matching ANSI escape codes const ANSI_CSI_REGEX = new RegExp("\\x1b\\[[0-?]*[ -/]*[@-~]", "g"); const CONTROL_CHAR_REGEX = new RegExp("[\\u0000-\\u001f\\u007f]", "g"); @@ -256,12 +258,14 @@ export async function showAuthMenu( { label: UI_COPY.mainMenu.checkAccounts, value: { type: "check" }, color: "green" }, { label: UI_COPY.mainMenu.bestAccount, value: { type: "forecast" }, color: "green" }, { label: UI_COPY.mainMenu.fixIssues, value: { type: "fix" }, color: "green" }, - { label: UI_COPY.mainMenu.settings, value: { type: "settings" }, color: "green" }, { label: "", value: { type: "cancel" }, separator: true }, { label: UI_COPY.mainMenu.moreChecks, value: { type: "cancel" }, kind: "heading" }, { label: UI_COPY.mainMenu.refreshChecks, value: { type: "deep-check" }, color: "green" }, { label: verifyLabel, value: { type: "verify-flagged" }, color: flaggedCount > 0 ? "red" : "yellow" }, { label: "", value: { type: "cancel" }, separator: true }, + { label: UI_COPY.mainMenu.settingsSection, value: { type: "cancel" }, kind: "heading" }, + { label: UI_COPY.mainMenu.settings, value: { type: "settings" }, color: "green" }, + { label: "", value: { type: "cancel" }, separator: true }, { label: UI_COPY.mainMenu.accounts, value: { type: "cancel" }, kind: "heading" }, ]; @@ -366,27 +370,22 @@ export async function showSettingsMenu( syncFromCodexMultiAuthEnabled: boolean, ): Promise { const ui = getUiRuntimeOptions(); - const syncBadge = syncFromCodexMultiAuthEnabled - ? formatUiBadge(ui, "enabled", "success") - : formatUiBadge(ui, "disabled", "danger"); - const syncLabel = ui.v2Enabled - ? `${UI_COPY.settings.syncToggle} ${syncBadge}` - : `${UI_COPY.settings.syncToggle} ${syncFromCodexMultiAuthEnabled ? `${ANSI.green}[enabled]${ANSI.reset}` : `${ANSI.red}[disabled]${ANSI.reset}`}`; - - const action = await select( - [ - { label: UI_COPY.settings.syncHeading, value: "cancel", kind: "heading" }, - { label: syncLabel, value: "toggle-sync", color: syncFromCodexMultiAuthEnabled ? "green" : "yellow" }, - { label: UI_COPY.settings.syncNow, value: "sync-now", color: "cyan" }, - { label: "", value: "cancel", separator: true }, - { label: UI_COPY.settings.maintenanceHeading, value: "cancel", kind: "heading" }, - { label: UI_COPY.settings.cleanupDuplicateEmails, value: "cleanup-duplicate-emails", color: "yellow" }, - { label: UI_COPY.settings.cleanupOverlaps, value: "cleanup-overlaps", color: "yellow" }, + let focus: SettingsHubAction = "sync"; + + while (true) { + const hubItems: MenuItem[] = [ + { label: UI_COPY.settings.sectionTitle, value: "cancel", kind: "heading" }, + { label: UI_COPY.settings.syncCategory, value: "sync", color: "green" }, + { label: UI_COPY.settings.maintenanceCategory, value: "maintenance", color: "green" }, { label: "", value: "cancel", separator: true }, { label: UI_COPY.settings.navigationHeading, value: "cancel", kind: "heading" }, - { label: UI_COPY.settings.back, value: "back" }, - ], - { + { label: UI_COPY.settings.back, value: "back", color: "red" }, + ]; + const initialCursor = hubItems.findIndex((item) => { + if (item.separator || item.disabled || item.kind === "heading") return false; + return item.value === focus; + }); + const action = await select(hubItems, { message: UI_COPY.settings.title, subtitle: UI_COPY.settings.subtitle, help: UI_COPY.settings.help, @@ -394,10 +393,70 @@ export async function showSettingsMenu( selectedEmphasis: "minimal", focusStyle: "row-invert", theme: ui.theme, - }, - ); + initialCursor: initialCursor >= 0 ? initialCursor : undefined, + }); - return action ?? "cancel"; + if (!action || action === "cancel" || action === "back") { + return action ?? "cancel"; + } + + if (action === "sync") { + const syncBadge = syncFromCodexMultiAuthEnabled + ? formatUiBadge(ui, "enabled", "success") + : formatUiBadge(ui, "disabled", "danger"); + const syncLabel = ui.v2Enabled + ? `${UI_COPY.settings.syncToggle} ${syncBadge}` + : `${UI_COPY.settings.syncToggle} ${syncFromCodexMultiAuthEnabled ? `${ANSI.green}[enabled]${ANSI.reset}` : `${ANSI.red}[disabled]${ANSI.reset}`}`; + const syncAction = await select( + [ + { label: UI_COPY.settings.syncHeading, value: "cancel", kind: "heading" }, + { label: syncLabel, value: "toggle-sync", color: syncFromCodexMultiAuthEnabled ? "green" : "yellow" }, + { label: UI_COPY.settings.syncNow, value: "sync-now", color: "cyan" }, + { label: "", value: "cancel", separator: true }, + { label: UI_COPY.settings.navigationHeading, value: "cancel", kind: "heading" }, + { label: UI_COPY.settings.back, value: "back" }, + ], + { + message: UI_COPY.settings.title, + subtitle: UI_COPY.settings.syncCategory, + help: UI_COPY.settings.help, + clearScreen: true, + selectedEmphasis: "minimal", + focusStyle: "row-invert", + theme: ui.theme, + }, + ); + if (syncAction && syncAction !== "back" && syncAction !== "cancel") { + return syncAction; + } + focus = "sync"; + continue; + } + + const maintenanceAction = await select( + [ + { label: UI_COPY.settings.maintenanceHeading, value: "cancel", kind: "heading" }, + { label: UI_COPY.settings.cleanupDuplicateEmails, value: "cleanup-duplicate-emails", color: "yellow" }, + { label: UI_COPY.settings.cleanupOverlaps, value: "cleanup-overlaps", color: "yellow" }, + { label: "", value: "cancel", separator: true }, + { label: UI_COPY.settings.navigationHeading, value: "cancel", kind: "heading" }, + { label: UI_COPY.settings.back, value: "back" }, + ], + { + message: UI_COPY.settings.title, + subtitle: UI_COPY.settings.maintenanceCategory, + help: UI_COPY.settings.help, + clearScreen: true, + selectedEmphasis: "minimal", + focusStyle: "row-invert", + theme: ui.theme, + }, + ); + if (maintenanceAction && maintenanceAction !== "back" && maintenanceAction !== "cancel") { + return maintenanceAction; + } + focus = "maintenance"; + } } export async function showAccountDetails(account: AccountInfo): Promise { diff --git a/lib/ui/copy.ts b/lib/ui/copy.ts index 39d3d48e..27ddde54 100644 --- a/lib/ui/copy.ts +++ b/lib/ui/copy.ts @@ -11,6 +11,7 @@ export const UI_COPY = { moreChecks: "Advanced Checks", refreshChecks: "Refresh All Accounts", checkFlagged: "Check Problem Accounts", + settingsSection: "Settings", accounts: "Saved Accounts", noSearchMatches: "No accounts match your search", dangerZone: "Danger Zone", @@ -29,8 +30,11 @@ export const UI_COPY = { }, settings: { title: "Settings", - subtitle: "Organized controls for sync, maintenance, and future tools", + subtitle: "Organized settings categories for sync, maintenance, and future tools", help: "↑↓ Move | Enter Select | Q Back", + sectionTitle: "Categories", + syncCategory: "Sync", + maintenanceCategory: "Maintenance", syncHeading: "Sync", maintenanceHeading: "Maintenance", navigationHeading: "Navigation", diff --git a/test/auth-menu.test.ts b/test/auth-menu.test.ts index 472cc1f6..35f07770 100644 --- a/test/auth-menu.test.ts +++ b/test/auth-menu.test.ts @@ -88,22 +88,30 @@ describe("auth-menu", () => { expect(firstCall).toBeDefined(); const items = firstCall?.[0] as Array<{ label: string; value?: { type?: string } }>; expect(items.some((item) => item.value?.type === "settings")).toBe(true); + expect(items.some((item) => item.label === "Settings" && item.kind === "heading")).toBe(true); }); - it("renders sync toggle state in settings menu", async () => { - vi.mocked(select).mockResolvedValueOnce("cancel"); + it("renders settings hub categories before sync actions", async () => { + vi.mocked(select) + .mockResolvedValueOnce("sync") + .mockResolvedValueOnce("cancel"); await showSettingsMenu(true); const firstCall = vi.mocked(select).mock.calls[0]; expect(firstCall).toBeDefined(); - const items = firstCall?.[0] as Array<{ label: string; value?: string }>; + const hubItems = firstCall?.[0] as Array<{ label: string; value?: string; kind?: string }>; + expect(hubItems.some((item) => item.label === "Categories")).toBe(true); + expect(hubItems.some((item) => item.value === "sync")).toBe(true); + expect(hubItems.some((item) => item.value === "maintenance")).toBe(true); + + const secondCall = vi.mocked(select).mock.calls[1]; + expect(secondCall).toBeDefined(); + const items = secondCall?.[0] as Array<{ label: string; value?: string }>; const toggleItem = items.find((item) => item.value === "toggle-sync"); expect(toggleItem?.label).toContain("Sync from codex-multi-auth"); expect(toggleItem?.label).toContain("[enabled]"); expect(items.some((item) => item.label === "Sync")).toBe(true); - expect(items.some((item) => item.label === "Maintenance")).toBe(true); - expect(items.some((item) => item.value === "cleanup-duplicate-emails")).toBe(true); expect(items.some((item) => item.label === "Navigation")).toBe(true); }); From 03d48f59903417cfa8221cc6fab9bdc8b7225f17 Mon Sep 17 00:00:00 2001 From: ndycode Date: Sat, 7 Mar 2026 22:01:12 +0800 Subject: [PATCH 30/81] fix: harden windows sync and config edge cases - retry transient EPERM lock acquisition on Windows - clamp import accounting to avoid negative imported results - warn when secure temp cleanup leaves token staging behind - add Windows lock retry regression coverage Co-authored-by: Codex --- index.ts | 23 +++++++-- lib/codex-multi-auth-sync.ts | 6 ++- lib/config.ts | 38 +++++++------- lib/storage.ts | 4 +- test/codex-multi-auth-sync.test.ts | 34 +++++++++++++ test/plugin-config.race.test.ts | 80 ++++++++++++++++++++++++++++++ test/storage.test.ts | 29 +++++++++++ 7 files changed, 191 insertions(+), 23 deletions(-) create mode 100644 test/plugin-config.race.test.ts diff --git a/index.ts b/index.ts index 58f87a0d..4c4967b3 100644 --- a/index.ts +++ b/index.ts @@ -3482,8 +3482,18 @@ while (attempted.size < Math.max(1, accountCount)) { refreshResult.message ?? refreshResult.reason ?? "refresh failed"; emit(i, `error: ${message}`, "danger"); if (deepProbe && isFlaggableFailure(refreshResult)) { + const flaggedKey = getSyncRemovalTargetKey({ + refreshToken: account.refreshToken, + organizationId: account.organizationId, + accountId: account.accountId, + }); const existingIndex = flaggedStorage.accounts.findIndex( - (flagged) => flagged.refreshToken === account.refreshToken, + (flagged) => + getSyncRemovalTargetKey({ + refreshToken: flagged.refreshToken, + organizationId: flagged.organizationId, + accountId: flagged.accountId, + }) === flaggedKey, ); const flaggedRecord: FlaggedAccountMetadataV1 = { ...account, @@ -3496,7 +3506,7 @@ while (attempted.size < Math.max(1, accountCount)) { } else { flaggedStorage.accounts.push(flaggedRecord); } - removeFromActive.add(account.refreshToken); + removeFromActive.add(flaggedKey); flaggedChanged = true; } continue; @@ -3587,7 +3597,14 @@ while (attempted.size < Math.max(1, accountCount)) { if (removeFromActive.size > 0) { workingStorage.accounts = workingStorage.accounts.filter( - (account) => !removeFromActive.has(account.refreshToken), + (account) => + !removeFromActive.has( + getSyncRemovalTargetKey({ + refreshToken: account.refreshToken, + organizationId: account.organizationId, + accountId: account.accountId, + }), + ), ); clampActiveIndices(workingStorage); storageChanged = true; diff --git a/lib/codex-multi-auth-sync.ts b/lib/codex-multi-auth-sync.ts index 9ee1a15a..0b80902a 100644 --- a/lib/codex-multi-auth-sync.ts +++ b/lib/codex-multi-auth-sync.ts @@ -2,6 +2,7 @@ import { existsSync, readFileSync, 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, @@ -121,7 +122,10 @@ async function withNormalizedImportFile( try { return await handler(tempPath); } finally { - await fs.rm(tempDir, { recursive: true, force: true }).catch(() => undefined); + await fs.rm(tempDir, { recursive: true, force: true }).catch((error: unknown) => { + const message = error instanceof Error ? error.message : String(error); + logWarn(`Failed to remove temporary codex sync directory ${tempDir}: ${message}`); + }); } }; diff --git a/lib/config.ts b/lib/config.ts index f5660d75..10a58fcd 100644 --- a/lib/config.ts +++ b/lib/config.ts @@ -224,27 +224,31 @@ function withPluginConfigLock(fn: () => T): T { break; } catch (error) { const code = (error as NodeJS.ErrnoException).code; - if (code !== "EEXIST" || Date.now() >= deadline) { + const retryableLockError = + code === "EEXIST" || (process.platform === "win32" && code === "EPERM"); + if (!retryableLockError || Date.now() >= deadline) { throw error; } - try { - const lockOwnerPid = Number.parseInt(readFileSync(CONFIG_LOCK_PATH, "utf-8").trim(), 10); - if ( - Number.isFinite(lockOwnerPid) && - lockOwnerPid !== process.pid && - !isProcessAlive(lockOwnerPid) - ) { - const staleLockPath = `${CONFIG_LOCK_PATH}.${lockOwnerPid}.${process.pid}.${Date.now()}.stale`; - renameSync(CONFIG_LOCK_PATH, staleLockPath); - try { - unlinkSync(staleLockPath); - } catch { - // best effort stale-lock cleanup + if (code === "EEXIST") { + try { + const lockOwnerPid = Number.parseInt(readFileSync(CONFIG_LOCK_PATH, "utf-8").trim(), 10); + if ( + Number.isFinite(lockOwnerPid) && + lockOwnerPid !== process.pid && + !isProcessAlive(lockOwnerPid) + ) { + const staleLockPath = `${CONFIG_LOCK_PATH}.${lockOwnerPid}.${process.pid}.${Date.now()}.stale`; + renameSync(CONFIG_LOCK_PATH, staleLockPath); + try { + unlinkSync(staleLockPath); + } catch { + // best effort stale-lock cleanup + } + continue; } - continue; + } catch { + // best effort stale-lock recovery } - } catch { - // best effort stale-lock recovery } sleepSync(25); } diff --git a/lib/storage.ts b/lib/storage.ts index fc9329f9..b007c67d 100644 --- a/lib/storage.ts +++ b/lib/storage.ts @@ -1470,8 +1470,8 @@ export async function importAccounts( await persist(newStorage); - const imported = deduplicatedAccounts.length - existingAccounts.length; - const skipped = skippedByPrepare + (preparedNormalized.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/test/codex-multi-auth-sync.test.ts b/test/codex-multi-auth-sync.test.ts index 2a9c6082..4d6c9918 100644 --- a/test/codex-multi-auth-sync.test.ts +++ b/test/codex-multi-auth-sync.test.ts @@ -4,6 +4,10 @@ import * as os from "node:os"; import { join } from "node:path"; import { findProjectRoot, getProjectStorageKey } from "../lib/storage/paths.js"; +vi.mock("../lib/logger.js", () => ({ + logWarn: vi.fn(), +})); + vi.mock("node:fs", async () => { const actual = await vi.importActual("node:fs"); return { @@ -267,6 +271,36 @@ describe("codex-multi-auth sync", () => { } }); + it("logs a warning when secure temp cleanup fails", 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); + mockReadFileSync.mockReturnValue( + JSON.stringify({ + version: 3, + activeIndex: 0, + accounts: [{ refreshToken: "sync-refresh", addedAt: 1, lastUsed: 1 }], + }), + ); + + const rmSpy = vi.spyOn(fs.promises, "rm").mockRejectedValueOnce(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, + }); + expect(vi.mocked(loggerModule.logWarn)).toHaveBeenCalledWith( + expect.stringContaining("Failed to remove temporary codex sync directory"), + ); + } finally { + rmSpy.mockRestore(); + } + }); + it("skips source accounts whose emails already exist locally during sync", async () => { const rootDir = join(process.cwd(), ".tmp-codex-multi-auth"); process.env.CODEX_MULTI_AUTH_DIR = rootDir; diff --git a/test/plugin-config.race.test.ts b/test/plugin-config.race.test.ts new file mode 100644 index 00000000..c348d527 --- /dev/null +++ b/test/plugin-config.race.test.ts @@ -0,0 +1,80 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import * as fs from "node:fs"; +import * as logger from "../lib/logger.js"; + +vi.mock("node:fs", async () => { + const actual = await vi.importActual("node:fs"); + return { + ...actual, + existsSync: vi.fn(), + readFileSync: vi.fn(), + mkdirSync: vi.fn(), + renameSync: vi.fn(), + unlinkSync: vi.fn(), + writeFileSync: vi.fn(), + }; +}); + +vi.mock("../lib/logger.js", async () => { + const actual = await vi.importActual("../lib/logger.js"); + return { + ...actual, + logWarn: vi.fn(), + }; +}); + +describe("plugin config lock retry", () => { + const mockExistsSync = vi.mocked(fs.existsSync); + const mockReadFileSync = vi.mocked(fs.readFileSync); + const mockMkdirSync = vi.mocked(fs.mkdirSync); + const mockRenameSync = vi.mocked(fs.renameSync); + const mockUnlinkSync = vi.mocked(fs.unlinkSync); + const mockWriteFileSync = vi.mocked(fs.writeFileSync); + const originalPlatform = process.platform; + + beforeEach(() => { + vi.resetModules(); + vi.clearAllMocks(); + mockExistsSync.mockReturnValue(false); + mockReadFileSync.mockReturnValue("{}"); + mockMkdirSync.mockImplementation(() => undefined); + mockRenameSync.mockImplementation(() => undefined); + mockUnlinkSync.mockImplementation(() => undefined); + }); + + afterEach(() => { + vi.restoreAllMocks(); + Object.defineProperty(process, "platform", { value: originalPlatform }); + }); + + it("retries transient EPERM when taking the lock on Windows", async () => { + Object.defineProperty(process, "platform", { value: "win32" }); + + let lockAttempts = 0; + mockWriteFileSync.mockImplementation((filePath) => { + const path = String(filePath); + if (path.endsWith(".lock")) { + lockAttempts += 1; + if (lockAttempts === 1) { + const error = new Error("lock busy") as NodeJS.ErrnoException; + error.code = "EPERM"; + throw error; + } + } + return undefined; + }); + + const { savePluginConfigMutation } = await import("../lib/config.js"); + + expect(() => + savePluginConfigMutation((current) => ({ + ...current, + experimental: { syncFromCodexMultiAuth: { enabled: true } }, + })), + ).not.toThrow(); + + expect(lockAttempts).toBeGreaterThanOrEqual(2); + expect(mockWriteFileSync).toHaveBeenCalled(); + expect(vi.mocked(logger.logWarn)).not.toHaveBeenCalled(); + }); +}); diff --git a/test/storage.test.ts b/test/storage.test.ts index 06afbd90..447a1be4 100644 --- a/test/storage.test.ts +++ b/test/storage.test.ts @@ -686,6 +686,35 @@ describe("storage", () => { }); }); + it("never reports a negative imported count when dedupe shrinks existing storage", async () => { + const { importAccounts } = await import("../lib/storage.js"); + + await saveAccounts({ + version: 3, + activeIndex: 0, + accounts: [ + { accountId: "existing-a", refreshToken: "shared-refresh", email: "shared@example.com", addedAt: 1, lastUsed: 1 }, + { accountId: "existing-b", refreshToken: "shared-refresh", email: "shared@example.com", addedAt: 2, lastUsed: 2 }, + ], + }); + + await fs.writeFile( + exportPath, + JSON.stringify({ + version: 3, + activeIndex: 0, + accounts: [ + { accountId: "existing-b", refreshToken: "shared-refresh", email: "shared@example.com", addedAt: 3, lastUsed: 3 }, + ], + }), + ); + + await expect(importAccounts(exportPath)).resolves.toMatchObject({ + imported: 0, + skipped: 1, + }); + }); + it("should fail export when no accounts exist", async () => { const { exportAccounts } = await import("../lib/storage.js"); setStoragePathDirect(testStoragePath); From fe52053a65b2d1cdee46983a132938e054b2b989 Mon Sep 17 00:00:00 2001 From: ndycode Date: Sat, 7 Mar 2026 22:14:11 +0800 Subject: [PATCH 31/81] fix: remove remaining windows sync review blockers - load codex sync sources asynchronously and expose finite sync cap override - drop redundant per-keystroke chmod and redact capture script output - add dedicated storage race coverage for Windows rename retry Co-authored-by: Codex --- lib/codex-multi-auth-sync.ts | 85 ++++++++++++----------- lib/ui/select.ts | 3 +- scripts/capture-tui-input.js | 29 +++++++- test/codex-multi-auth-sync.test.ts | 108 +++++++++++++++++++++++++---- test/storage.race.test.ts | 58 ++++++++++++++++ 5 files changed, 226 insertions(+), 57 deletions(-) create mode 100644 test/storage.race.test.ts diff --git a/lib/codex-multi-auth-sync.ts b/lib/codex-multi-auth-sync.ts index 0b80902a..4cd51d62 100644 --- a/lib/codex-multi-auth-sync.ts +++ b/lib/codex-multi-auth-sync.ts @@ -1,4 +1,4 @@ -import { existsSync, readFileSync, promises as fs } from "node:fs"; +import { existsSync, promises as fs } from "node:fs"; import { homedir } from "node:os"; import { join, win32 } from "node:path"; import { ACCOUNT_LIMITS } from "./constants.js"; @@ -21,6 +21,7 @@ const EXTERNAL_ACCOUNT_FILE_NAMES = [ "codex-accounts.json", ]; const SYNC_ACCOUNT_TAG = "codex-multi-auth-sync"; +const SYNC_MAX_ACCOUNTS_OVERRIDE_ENV = "CODEX_AUTH_SYNC_MAX_ACCOUNTS"; export interface CodexMultiAuthResolvedSource { rootDir: string; @@ -481,11 +482,23 @@ export function resolveCodexMultiAuthAccountsSource(projectPath = process.cwd()) ); } -export function loadCodexMultiAuthSourceStorage( +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; + } + return ACCOUNT_LIMITS.MAX_ACCOUNTS; +} + +export async function loadCodexMultiAuthSourceStorage( projectPath = process.cwd(), -): CodexMultiAuthResolvedSource & { storage: AccountStorageV3 } { +): Promise { const resolved = resolveCodexMultiAuthAccountsSource(projectPath); - const raw = readFileSync(resolved.accountsPath, "utf-8"); + const raw = await fs.readFile(resolved.accountsPath, "utf-8"); let parsed: unknown; try { parsed = JSON.parse(raw) as unknown; @@ -507,7 +520,7 @@ export function loadCodexMultiAuthSourceStorage( async function loadPreparedCodexMultiAuthSourceStorage( projectPath = process.cwd(), ): Promise { - const resolved = loadCodexMultiAuthSourceStorage(projectPath); + const resolved = await loadCodexMultiAuthSourceStorage(projectPath); const currentStorage = await withAccountStorageTransaction((current) => Promise.resolve(current)); const preparedStorage = filterSourceAccountsAgainstExistingEmails( resolved.storage, @@ -540,33 +553,22 @@ export async function syncFromCodexMultiAuth( projectPath = process.cwd(), ): Promise { const resolved = await loadPreparedCodexMultiAuthSourceStorage(projectPath); - let result: ImportAccountsResult; - try { - result = await withNormalizedImportFile( - tagSyncedAccounts(resolved.storage), - (filePath) => - importAccounts( - filePath, - { - preImportBackupPrefix: "codex-multi-auth-sync-backup", - backupMode: "required", - }, - (normalizedStorage, existing) => - filterSourceAccountsAgainstExistingEmails( - normalizedStorage, - existing?.accounts ?? [], - ), - ), - ); - } catch (error) { - if ( - error instanceof CodexMultiAuthSyncCapacityError || - (error instanceof Error && /exceed(?: the)? maximum/i.test(error.message)) - ) { - await assertSyncWithinCapacity(await loadPreparedCodexMultiAuthSourceStorage(projectPath)); - } - throw error; - } + const result: ImportAccountsResult = await withNormalizedImportFile( + tagSyncedAccounts(resolved.storage), + (filePath) => + importAccounts( + filePath, + { + preImportBackupPrefix: "codex-multi-auth-sync-backup", + backupMode: "required", + }, + (normalizedStorage, existing) => + filterSourceAccountsAgainstExistingEmails( + normalizedStorage, + existing?.accounts ?? [], + ), + ), + ); return { rootDir: resolved.rootDir, accountsPath: resolved.accountsPath, @@ -655,7 +657,10 @@ export async function cleanupCodexMultiAuthSyncedOverlaps(): Promise { - if (!Number.isFinite(ACCOUNT_LIMITS.MAX_ACCOUNTS)) { + // Unlimited remains the default, but a finite override keeps the sync prune/capacity + // path testable and available for operators who intentionally enforce a soft cap. + const maxAccounts = getSyncCapacityLimit(); + if (!Number.isFinite(maxAccounts)) { return; } const details = await withAccountStorageTransaction((current) => { @@ -667,7 +672,7 @@ async function assertSyncWithinCapacity( }; const sourceDedupedTotal = buildMergedDedupedAccounts([], resolved.storage.accounts).length; const mergedAccounts = buildMergedDedupedAccounts(existing.accounts, resolved.storage.accounts); - if (mergedAccounts.length <= ACCOUNT_LIMITS.MAX_ACCOUNTS) { + if (mergedAccounts.length <= maxAccounts) { return Promise.resolve(null); } @@ -676,7 +681,7 @@ async function assertSyncWithinCapacity( const dedupedTotal = mergedAccounts.length; const importableNewAccounts = Math.max(0, dedupedTotal - currentCount); const skippedOverlaps = Math.max(0, sourceCount - importableNewAccounts); - if (sourceDedupedTotal > ACCOUNT_LIMITS.MAX_ACCOUNTS) { + if (sourceDedupedTotal > maxAccounts) { return Promise.resolve({ rootDir: resolved.rootDir, accountsPath: resolved.accountsPath, @@ -685,8 +690,8 @@ async function assertSyncWithinCapacity( sourceCount, sourceDedupedTotal, dedupedTotal: sourceDedupedTotal, - maxAccounts: ACCOUNT_LIMITS.MAX_ACCOUNTS, - needToRemove: sourceDedupedTotal - ACCOUNT_LIMITS.MAX_ACCOUNTS, + maxAccounts: maxAccounts, + needToRemove: sourceDedupedTotal - maxAccounts, importableNewAccounts: sourceDedupedTotal, skippedOverlaps: Math.max(0, sourceCount - sourceDedupedTotal), suggestedRemovals: [], @@ -722,7 +727,7 @@ async function assertSyncWithinCapacity( } return left.index - right.index; }) - .slice(0, Math.max(5, dedupedTotal - ACCOUNT_LIMITS.MAX_ACCOUNTS)) + .slice(0, Math.max(5, dedupedTotal - maxAccounts)) .map(({ index, email, accountLabel, isCurrentAccount, score, reason }) => ({ index, email, @@ -740,8 +745,8 @@ async function assertSyncWithinCapacity( sourceCount, sourceDedupedTotal, dedupedTotal, - maxAccounts: ACCOUNT_LIMITS.MAX_ACCOUNTS, - needToRemove: dedupedTotal - ACCOUNT_LIMITS.MAX_ACCOUNTS, + maxAccounts: maxAccounts, + needToRemove: dedupedTotal - maxAccounts, importableNewAccounts, skippedOverlaps, suggestedRemovals, diff --git a/lib/ui/select.ts b/lib/ui/select.ts index b9981c6d..86e087d1 100644 --- a/lib/ui/select.ts +++ b/lib/ui/select.ts @@ -1,6 +1,6 @@ import { ANSI, isTTY, parseKey } from "./ansi.js"; import type { UiTheme } from "./theme.js"; -import { appendFileSync, chmodSync, mkdirSync } from "node:fs"; +import { appendFileSync, mkdirSync } from "node:fs"; import { join } from "node:path"; export interface MenuItem { @@ -72,7 +72,6 @@ function writeTuiAudit(event: Record): void { `${JSON.stringify(sanitizeAuditValue("event", { ts: new Date().toISOString(), ...event }))}\n`, { encoding: "utf8", mode: 0o600 }, ); - chmodSync(logPath, 0o600); } catch { // best effort audit logging only } diff --git a/scripts/capture-tui-input.js b/scripts/capture-tui-input.js index 4960af0e..815087ed 100644 --- a/scripts/capture-tui-input.js +++ b/scripts/capture-tui-input.js @@ -48,9 +48,36 @@ const ESCAPE_TIMEOUT_MS = 50; mkdirSync(dirname(output), { recursive: true }); const logEvent = (event) => { - appendFileSync(output, `${JSON.stringify({ ts: new Date().toISOString(), ...event })}\n`, "utf8"); + appendFileSync(output, `${JSON.stringify(sanitizeAuditValue("event", { ts: new Date().toISOString(), ...event }))}\n`, { + encoding: "utf8", + mode: 0o600, + }); }; +function sanitizeAuditValue(key, value) { + if (typeof value === "string") { + if (["utf8", "bytesHex", "token", "normalizedInput", "pending", "token"].includes(key)) { + return `[redacted:${value.length}]`; + } + if (value.includes("@")) { + return "[redacted-email]"; + } + return value; + } + if (Array.isArray(value)) { + return value.map((entry) => sanitizeAuditValue(key, entry)); + } + if (value && typeof value === "object") { + return Object.fromEntries( + Object.entries(value).map(([entryKey, entryValue]) => [ + entryKey, + sanitizeAuditValue(entryKey, entryValue), + ]), + ); + } + return value; +} + if (!process.stdin.isTTY || !process.stdout.isTTY) { console.error("capture-tui-input requires a TTY"); process.exit(1); diff --git a/test/codex-multi-auth-sync.test.ts b/test/codex-multi-auth-sync.test.ts index 4d6c9918..ddd9c2be 100644 --- a/test/codex-multi-auth-sync.test.ts +++ b/test/codex-multi-auth-sync.test.ts @@ -60,17 +60,33 @@ vi.mock("../lib/storage.js", () => ({ describe("codex-multi-auth sync", () => { const mockExistsSync = vi.mocked(fs.existsSync); - const mockReadFileSync = vi.mocked(fs.readFileSync); + 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, + ); + }); + }; beforeEach(() => { vi.resetModules(); vi.clearAllMocks(); + mockReadFile.mockReset(); + mockReadFile.mockImplementation((path, options) => + originalReadFile(path as Parameters[0], options as never), + ); delete process.env.CODEX_MULTI_AUTH_DIR; delete process.env.CODEX_HOME; }); @@ -80,6 +96,7 @@ describe("codex-multi-auth sync", () => { process.env.CODEX_HOME = originalEnv.CODEX_HOME; process.env.USERPROFILE = originalEnv.USERPROFILE; 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 () => { @@ -178,7 +195,7 @@ describe("codex-multi-auth sync", () => { process.env.CODEX_MULTI_AUTH_DIR = rootDir; const globalPath = join(rootDir, "openai-codex-accounts.json"); mockExistsSync.mockImplementation((candidate) => String(candidate) === globalPath); - mockReadFileSync.mockReturnValue( + mockSourceStorageFile(globalPath, JSON.stringify({ version: 3, activeIndex: 0, @@ -228,7 +245,7 @@ describe("codex-multi-auth sync", () => { process.env.CODEX_MULTI_AUTH_DIR = rootDir; const globalPath = join(rootDir, "openai-codex-accounts.json"); mockExistsSync.mockImplementation((candidate) => String(candidate) === globalPath); - mockReadFileSync.mockReturnValue( + mockSourceStorageFile(globalPath, JSON.stringify({ version: 3, activeIndex: 0, @@ -249,7 +266,7 @@ describe("codex-multi-auth sync", () => { process.env.CODEX_MULTI_AUTH_DIR = rootDir; const globalPath = join(rootDir, "openai-codex-accounts.json"); mockExistsSync.mockImplementation((candidate) => String(candidate) === globalPath); - mockReadFileSync.mockReturnValue( + mockSourceStorageFile(globalPath, JSON.stringify({ version: 3, activeIndex: 0, @@ -276,7 +293,7 @@ describe("codex-multi-auth sync", () => { process.env.CODEX_MULTI_AUTH_DIR = rootDir; const globalPath = join(rootDir, "openai-codex-accounts.json"); mockExistsSync.mockImplementation((candidate) => String(candidate) === globalPath); - mockReadFileSync.mockReturnValue( + mockSourceStorageFile(globalPath, JSON.stringify({ version: 3, activeIndex: 0, @@ -306,7 +323,7 @@ describe("codex-multi-auth sync", () => { process.env.CODEX_MULTI_AUTH_DIR = rootDir; const globalPath = join(rootDir, "openai-codex-accounts.json"); mockExistsSync.mockImplementation((candidate) => String(candidate) === globalPath); - mockReadFileSync.mockReturnValue( + mockSourceStorageFile(globalPath, JSON.stringify({ version: 3, activeIndex: 0, @@ -404,7 +421,7 @@ describe("codex-multi-auth sync", () => { process.env.CODEX_MULTI_AUTH_DIR = rootDir; const globalPath = join(rootDir, "openai-codex-accounts.json"); mockExistsSync.mockImplementation((candidate) => String(candidate) === globalPath); - mockReadFileSync.mockReturnValue( + mockSourceStorageFile(globalPath, JSON.stringify({ version: 3, activeIndex: 0, @@ -454,7 +471,7 @@ describe("codex-multi-auth sync", () => { process.env.CODEX_MULTI_AUTH_DIR = rootDir; const globalPath = join(rootDir, "openai-codex-accounts.json"); mockExistsSync.mockImplementation((candidate) => String(candidate) === globalPath); - mockReadFileSync.mockReturnValue( + mockSourceStorageFile(globalPath, JSON.stringify({ version: 3, activeIndex: 0, @@ -471,7 +488,7 @@ describe("codex-multi-auth sync", () => { ); const { loadCodexMultiAuthSourceStorage } = await import("../lib/codex-multi-auth-sync.js"); - const resolved = loadCodexMultiAuthSourceStorage(process.cwd()); + const resolved = await loadCodexMultiAuthSourceStorage(process.cwd()); expect(resolved.storage.accounts[0]?.organizationId).toBe("org-example123"); }); @@ -481,10 +498,73 @@ describe("codex-multi-auth sync", () => { process.env.CODEX_MULTI_AUTH_DIR = rootDir; const globalPath = join(rootDir, "openai-codex-accounts.json"); mockExistsSync.mockImplementation((candidate) => String(candidate) === globalPath); - mockReadFileSync.mockReturnValue("not valid json"); + mockSourceStorageFile(globalPath, "not valid json"); const { loadCodexMultiAuthSourceStorage } = await import("../lib/codex-multi-auth-sync.js"); - expect(() => loadCodexMultiAuthSourceStorage(process.cwd())).toThrow(/Invalid JSON/); + 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.withAccountStorageTransaction).mockImplementationOnce(async (handler) => + handler( + { + 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, + }, + ], + }, + vi.fn(async () => {}), + ), + ); + + const { previewSyncFromCodexMultiAuth, CodexMultiAuthSyncCapacityError } = await import("../lib/codex-multi-auth-sync.js"); + await expect(previewSyncFromCodexMultiAuth(process.cwd())).rejects.toBeInstanceOf( + CodexMultiAuthSyncCapacityError, + ); }); it("cleans up tagged synced overlaps by normalizing org-scoped identities first", async () => { @@ -600,7 +680,7 @@ describe("codex-multi-auth sync", () => { process.env.CODEX_MULTI_AUTH_DIR = rootDir; const globalPath = join(rootDir, "openai-codex-accounts.json"); mockExistsSync.mockImplementation((candidate) => String(candidate) === globalPath); - mockReadFileSync.mockReturnValue( + mockSourceStorageFile(globalPath, JSON.stringify({ version: 3, activeIndex: 0, @@ -664,7 +744,7 @@ describe("codex-multi-auth sync", () => { process.env.CODEX_MULTI_AUTH_DIR = rootDir; const globalPath = join(rootDir, "openai-codex-accounts.json"); mockExistsSync.mockImplementation((candidate) => String(candidate) === globalPath); - mockReadFileSync.mockReturnValue( + mockSourceStorageFile(globalPath, JSON.stringify({ version: 3, activeIndex: 0, @@ -719,7 +799,7 @@ describe("codex-multi-auth sync", () => { process.env.CODEX_MULTI_AUTH_DIR = rootDir; const globalPath = join(rootDir, "openai-codex-accounts.json"); mockExistsSync.mockImplementation((candidate) => String(candidate) === globalPath); - mockReadFileSync.mockReturnValue( + mockSourceStorageFile(globalPath, JSON.stringify({ version: 3, activeIndex: 0, diff --git a/test/storage.race.test.ts b/test/storage.race.test.ts new file mode 100644 index 00000000..8848ecb2 --- /dev/null +++ b/test/storage.race.test.ts @@ -0,0 +1,58 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { promises as fs } from "node:fs"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; + +describe("storage race paths", () => { + let testDir: string; + let exportPath: string; + + beforeEach(async () => { + testDir = await fs.mkdtemp(join(tmpdir(), "storage-race-")); + exportPath = join(testDir, "import.json"); + }); + + afterEach(async () => { + const storageModule = await import("../lib/storage.js"); + storageModule.setStoragePathDirect(null); + await fs.rm(testDir, { recursive: true, force: true }); + vi.restoreAllMocks(); + }); + + it("retries a transient EBUSY during import commit rename", async () => { + const storageModule = await import("../lib/storage.js"); + const originalRename = fs.rename.bind(fs); + let renameAttempts = 0; + + storageModule.setStoragePathDirect(join(testDir, "accounts.json")); + await fs.writeFile( + exportPath, + JSON.stringify({ + version: 3, + activeIndex: 0, + accounts: [{ accountId: "race-import", refreshToken: "race-refresh", addedAt: 1, lastUsed: 1 }], + }), + "utf8", + ); + + vi.spyOn(fs, "rename").mockImplementation(async (source, destination) => { + if (String(destination).endsWith("accounts.json")) { + renameAttempts += 1; + if (renameAttempts === 1) { + const error = new Error("busy") as NodeJS.ErrnoException; + error.code = "EBUSY"; + throw error; + } + } + return originalRename(source, destination); + }); + + const result = await storageModule.importAccounts(exportPath); + const loaded = await storageModule.loadAccounts(); + + expect(result.imported).toBe(1); + expect(renameAttempts).toBeGreaterThanOrEqual(2); + expect(loaded?.accounts).toHaveLength(1); + expect(loaded?.accounts[0]?.accountId).toBe("race-import"); + }); +}); From ade9fcb33940971326b3a0f699b2b4cfd9e1764d Mon Sep 17 00:00:00 2001 From: ndycode Date: Sat, 7 Mar 2026 22:25:16 +0800 Subject: [PATCH 32/81] fix: close latest sync pruning review gaps - fail prune plan when selected accounts changed before confirmation - load external sync storage asynchronously and keep cap path explicitly reachable - remove redundant audit chmod and add storage race coverage Co-authored-by: Codex --- index.ts | 33 +++++++++++------- test/index.test.ts | 86 ++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 107 insertions(+), 12 deletions(-) diff --git a/index.ts b/index.ts index 4c4967b3..0c200974 100644 --- a/index.ts +++ b/index.ts @@ -3932,19 +3932,18 @@ while (attempted.size < Math.max(1, accountCount)) { } satisfies AccountStorageV3); const candidates: Array<{ previewLine: string; - target?: SyncRemovalTarget; + target: SyncRemovalTarget; }> = [...indexes] .sort((left, right) => left - right) .map((index) => { const account = currentStorage.accounts[index]; if (!account) { - return { - previewLine: `Account ${index + 1}`, - refreshToken: undefined, - }; - } - const label = account.email ?? account.accountLabel ?? `Account ${index + 1}`; - const currentSuffix = index === currentStorage.activeIndex ? " | current" : ""; + 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: { @@ -3956,9 +3955,7 @@ while (attempted.size < Math.max(1, accountCount)) { }); return { previewLines: candidates.map((candidate) => candidate.previewLine), - targets: candidates - .map((candidate) => candidate.target) - .filter((target): target is SyncRemovalTarget => target !== undefined), + targets: candidates.map((candidate) => candidate.target), }; }; @@ -4068,7 +4065,19 @@ while (attempted.size < Math.max(1, accountCount)) { console.log("Sync cancelled.\n"); return; } - const removalPlan = await buildSyncRemovalPlan(indexesToRemove); + let removalPlan: { + previewLines: string[]; + targets: SyncRemovalTarget[]; + }; + try { + removalPlan = await buildSyncRemovalPlan(indexesToRemove); + } catch (planError) { + const message = + planError instanceof Error ? planError.message : String(planError); + await restorePruneBackup(); + console.log(`\nSync failed: ${message}\n`); + return; + } console.log("Dry run removal:"); for (const line of removalPlan.previewLines) { console.log(` ${line}`); diff --git a/test/index.test.ts b/test/index.test.ts index 74e3c3a3..8dfd9736 100644 --- a/test/index.test.ts +++ b/test/index.test.ts @@ -2931,6 +2931,92 @@ describe("OpenAIOAuthPlugin persistAccountPool", () => { await fs.rm(tempDir, { recursive: true, force: true }); } }); + + it("aborts sync prune when a selected account disappears before confirmation", async () => { + const cliModule = await import("../lib/cli.js"); + const storageModule = await import("../lib/storage.js"); + const syncModule = await import("../lib/codex-multi-auth-sync.js"); + const configModule = await import("../lib/config.js"); + const confirmModule = await import("../lib/ui/confirm.js"); + + const tempDir = await fs.mkdtemp(join(tmpdir(), "oc-sync-prune-missing-")); + try { + mockStorage.accounts = [ + { + accountId: "org-keep", + organizationId: "org-keep", + accountIdSource: "org", + email: "keep@example.com", + refreshToken: "refresh-keep", + }, + ]; + mockStorage.activeIndex = 0; + mockStorage.activeIndexByFamily = {}; + + vi.mocked(cliModule.promptLoginMode) + .mockResolvedValueOnce({ mode: "experimental-sync-now" }) + .mockResolvedValueOnce({ mode: "cancel" }); + vi.mocked(cliModule.promptCodexMultiAuthSyncPrune).mockResolvedValueOnce([1]); + vi.mocked(configModule.getSyncFromCodexMultiAuthEnabled).mockReturnValue(true); + vi.mocked(confirmModule.confirm).mockResolvedValueOnce(true); + + vi.mocked(storageModule.createTimestampedBackupPath).mockImplementation((prefix?: string) => + join(tempDir, `${prefix ?? "codex-backup"}.json`), + ); + vi.mocked(storageModule.exportAccounts).mockImplementation(async (filePath: string) => { + await fs.writeFile( + filePath, + JSON.stringify({ + version: mockStorage.version, + activeIndex: mockStorage.activeIndex, + activeIndexByFamily: { ...mockStorage.activeIndexByFamily }, + accounts: mockStorage.accounts.map((account) => ({ ...account })), + }), + "utf8", + ); + }); + + const capacityError = new syncModule.CodexMultiAuthSyncCapacityError({ + rootDir: tempDir, + accountsPath: join(tempDir, "openai-codex-accounts.json"), + scope: "global", + currentCount: 1, + sourceCount: 2, + sourceDedupedTotal: 3, + dedupedTotal: 3, + maxAccounts: 2, + needToRemove: 1, + importableNewAccounts: 2, + skippedOverlaps: 0, + suggestedRemovals: [ + { + index: 1, + email: "missing@example.com", + accountLabel: "Workspace missing", + isCurrentAccount: false, + score: 180, + reason: "disabled", + }, + ], + }); + + vi.mocked(syncModule.previewSyncFromCodexMultiAuth).mockRejectedValueOnce(capacityError); + + const mockClient = createMockClient(); + const { OpenAIOAuthPlugin } = await import("../index.js"); + const plugin = (await OpenAIOAuthPlugin({ client: mockClient } as never)) as unknown as PluginType; + const autoMethod = plugin.auth.methods[0] as unknown as { + authorize: (inputs?: Record) => Promise<{ instructions: string }>; + }; + + const authResult = await autoMethod.authorize(); + expect(authResult.instructions).toBe("Authentication cancelled"); + expect(mockStorage.accounts).toHaveLength(1); + expect(vi.mocked(syncModule.syncFromCodexMultiAuth)).not.toHaveBeenCalled(); + } finally { + await fs.rm(tempDir, { recursive: true, force: true }); + } + }); }); describe("OpenAIOAuthPlugin showToast error handling", () => { From daca52a554e73f5af33baca6417384b468aad803 Mon Sep 17 00:00:00 2001 From: ndycode Date: Sat, 7 Mar 2026 22:35:36 +0800 Subject: [PATCH 33/81] fix: preserve active pointers during sync cleanup - remap active indices after synced overlap cleanup reorder - fail closed when temp sync staging cannot be cleaned up - tighten capacity details for source-over-capacity cases Co-authored-by: Codex --- lib/codex-multi-auth-sync.ts | 86 +++++++++++++++++++++++++++--- test/codex-multi-auth-sync.test.ts | 80 +++++++++++++++++++++++++-- 2 files changed, 155 insertions(+), 11 deletions(-) diff --git a/lib/codex-multi-auth-sync.ts b/lib/codex-multi-auth-sync.ts index 4cd51d62..05d2c884 100644 --- a/lib/codex-multi-auth-sync.ts +++ b/lib/codex-multi-auth-sync.ts @@ -120,14 +120,26 @@ async function withNormalizedImportFile( mode: 0o600, flag: "wx", }); + let result: T; try { - return await handler(tempPath); - } finally { - await fs.rm(tempDir, { recursive: true, force: true }).catch((error: unknown) => { - const message = error instanceof Error ? error.message : String(error); + result = await handler(tempPath); + } catch (error) { + try { + await fs.rm(tempDir, { recursive: true, force: true }); + } catch (cleanupError) { + const message = cleanupError instanceof Error ? cleanupError.message : String(cleanupError); logWarn(`Failed to remove temporary codex sync directory ${tempDir}: ${message}`); - }); + } + throw error; + } + try { + await fs.rm(tempDir, { recursive: true, force: true }); + } catch (cleanupError) { + const message = cleanupError instanceof Error ? cleanupError.message : String(cleanupError); + logWarn(`Failed to remove temporary codex sync directory ${tempDir}: ${message}`); + throw new Error(`Failed to remove temporary codex sync directory ${tempDir}: ${message}`); } + return result; }; const secureTempRoot = join(getResolvedUserHomeDir(), ".opencode", "tmp"); @@ -233,6 +245,52 @@ function normalizeIdentity(value: string | undefined): string | undefined { 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 = normalizeIdentity(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) { @@ -623,6 +681,22 @@ export async function cleanupCodexMultiAuthSyncedOverlaps(): Promise { + 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); @@ -692,7 +766,7 @@ async function assertSyncWithinCapacity( dedupedTotal: sourceDedupedTotal, maxAccounts: maxAccounts, needToRemove: sourceDedupedTotal - maxAccounts, - importableNewAccounts: sourceDedupedTotal, + importableNewAccounts: 0, skippedOverlaps: Math.max(0, sourceCount - sourceDedupedTotal), suggestedRemovals: [], } satisfies CodexMultiAuthSyncCapacityDetails); diff --git a/test/codex-multi-auth-sync.test.ts b/test/codex-multi-auth-sync.test.ts index ddd9c2be..6f8926b4 100644 --- a/test/codex-multi-auth-sync.test.ts +++ b/test/codex-multi-auth-sync.test.ts @@ -288,7 +288,7 @@ describe("codex-multi-auth sync", () => { } }); - it("logs a warning when secure temp cleanup fails", async () => { + it("fails closed and logs a warning when secure temp cleanup fails", 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"); @@ -306,10 +306,9 @@ describe("codex-multi-auth sync", () => { try { const { previewSyncFromCodexMultiAuth } = await import("../lib/codex-multi-auth-sync.js"); - await expect(previewSyncFromCodexMultiAuth(process.cwd())).resolves.toMatchObject({ - accountsPath: globalPath, - imported: 2, - }); + await expect(previewSyncFromCodexMultiAuth(process.cwd())).rejects.toThrow( + /Failed to remove temporary codex sync directory/, + ); expect(vi.mocked(loggerModule.logWarn)).toHaveBeenCalledWith( expect.stringContaining("Failed to remove temporary codex sync directory"), ); @@ -318,6 +317,32 @@ describe("codex-multi-auth sync", () => { } }); + it("fails preview 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").mockRejectedValueOnce(new Error("cleanup blocked")); + + try { + const { previewSyncFromCodexMultiAuth } = await import("../lib/codex-multi-auth-sync.js"); + await expect(previewSyncFromCodexMultiAuth(process.cwd())).rejects.toThrow( + /Failed to remove temporary codex sync directory/, + ); + } finally { + rmSpy.mockRestore(); + } + }); + 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; @@ -675,6 +700,51 @@ describe("codex-multi-auth sync", () => { }); }); + it("remaps active indices when synced overlap cleanup reorders accounts", async () => { + const storageModule = await import("../lib/storage.js"); + let persisted: AccountStorageV3 | null = null; + 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, + }, + ], + }, + vi.fn(async (next) => { + persisted = next; + }), + ), + ); + + const { cleanupCodexMultiAuthSyncedOverlaps } = await import("../lib/codex-multi-auth-sync.js"); + await cleanupCodexMultiAuthSyncedOverlaps(); + + expect(persisted?.accounts.map((account) => account.accountId)).toEqual(["org-local", "org-sync"]); + expect(persisted?.activeIndex).toBe(1); + expect(persisted?.activeIndexByFamily.codex).toBe(1); + }); + it("does not block preview when account limit is unlimited", async () => { const rootDir = join(process.cwd(), ".tmp-codex-multi-auth"); process.env.CODEX_MULTI_AUTH_DIR = rootDir; From c068e4011a2c7276f00aab2ee90640dfccc398a5 Mon Sep 17 00:00:00 2001 From: ndycode Date: Sun, 8 Mar 2026 00:05:45 +0800 Subject: [PATCH 34/81] fix: preserve sync variants and active account pointers - keep same-email workspace variants during sync filtering - enforce finite sync cap during apply inside the write path - remap active indices when prune removals delete earlier accounts Co-authored-by: Codex --- index.ts | 72 ++++++++ lib/codex-multi-auth-sync.ts | 262 +++++++++++++++++++---------- test/codex-multi-auth-sync.test.ts | 90 +++++++++- test/index.test.ts | 103 ++++++++++++ 4 files changed, 425 insertions(+), 102 deletions(-) diff --git a/index.ts b/index.ts index 0c200974..1489ae15 100644 --- a/index.ts +++ b/index.ts @@ -1605,6 +1605,38 @@ export const OpenAIOAuthPlugin: Plugin = async ({ client }: PluginInput) => { return `${target.organizationId ?? ""}|${target.accountId ?? ""}|${target.refreshToken}`; }; + const getAccountIdentityKeys = ( + account: { + refreshToken: string; + organizationId?: string; + accountId?: string; + }, + ): string[] => { + const keys: string[] = []; + if (account.organizationId) keys.push(`org:${account.organizationId}`); + if (account.accountId) keys.push(`account:${account.accountId}`); + keys.push(`refresh:${account.refreshToken}`); + return keys; + }; + + const findAccountIndexByIdentityKeys = ( + accounts: AccountStorageV3["accounts"], + identityKeys: string[], + ): number => { + if (identityKeys.length === 0) return -1; + for (const identityKey of identityKeys) { + const index = accounts.findIndex((account) => + getAccountIdentityKeys({ + refreshToken: account.refreshToken, + organizationId: account.organizationId, + accountId: account.accountId, + }).includes(identityKey), + ); + if (index >= 0) return index; + } + return -1; + }; + const normalizeAccountTags = (raw: string): string[] => { return Array.from( new Set( @@ -3890,6 +3922,29 @@ while (attempted.size < Math.max(1, accountCount)) { if (removedTargets.length !== targetKeySet.size) { throw new Error("Selected accounts changed before removal. Re-run sync and confirm again."); } + const activeAccountIdentityKeys = getAccountIdentityKeys({ + refreshToken: + currentStorage.accounts[currentStorage.activeIndex]?.refreshToken ?? "", + organizationId: + currentStorage.accounts[currentStorage.activeIndex]?.organizationId, + accountId: currentStorage.accounts[currentStorage.activeIndex]?.accountId, + }); + const familyActiveIdentityKeys = Object.fromEntries( + MODEL_FAMILIES.map((family) => { + const familyIndex = currentStorage.activeIndexByFamily?.[family] ?? currentStorage.activeIndex; + const familyAccount = currentStorage.accounts[familyIndex]; + return [ + family, + familyAccount + ? getAccountIdentityKeys({ + refreshToken: familyAccount.refreshToken, + organizationId: familyAccount.organizationId, + accountId: familyAccount.accountId, + }) + : [], + ]; + }), + ) as Partial>; currentStorage.accounts = currentStorage.accounts.filter( (account) => !targetKeySet.has( @@ -3900,6 +3955,23 @@ while (attempted.size < Math.max(1, accountCount)) { }), ), ); + const remappedActiveIndex = findAccountIndexByIdentityKeys( + currentStorage.accounts, + activeAccountIdentityKeys, + ); + 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 = findAccountIndexByIdentityKeys( + currentStorage.accounts, + familyActiveIdentityKeys[family] ?? [], + ); + currentStorage.activeIndexByFamily[family] = + remappedFamilyIndex >= 0 ? remappedFamilyIndex : currentStorage.activeIndex; + } clampActiveIndices(currentStorage); await saveAccounts(currentStorage); const removedRefreshTokens = new Set( diff --git a/lib/codex-multi-auth-sync.ts b/lib/codex-multi-auth-sync.ts index 05d2c884..64a12d4f 100644 --- a/lib/codex-multi-auth-sync.ts +++ b/lib/codex-multi-auth-sync.ts @@ -176,6 +176,10 @@ function deduplicateSourceAccountsByEmail( 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); @@ -208,22 +212,62 @@ function deduplicateSourceAccountsByEmail( return deduplicated; } +function buildExistingSyncIdentityState(existingAccounts: AccountStorageV3["accounts"]): { + organizationIds: Set; + accountIds: Set; + refreshTokens: Set; + legacyEmails: Set; +} { + const organizationIds = new Set(); + const accountIds = new Set(); + const refreshTokens = new Set(); + const legacyEmails = new Set(); + + for (const account of existingAccounts) { + const organizationId = normalizeIdentity(account.organizationId); + const accountId = normalizeIdentity(account.accountId); + const refreshToken = normalizeIdentity(account.refreshToken); + const email = normalizeIdentity(account.email); + if (organizationId) organizationIds.add(organizationId); + if (accountId) accountIds.add(accountId); + if (refreshToken) refreshTokens.add(refreshToken); + if (!organizationId && !accountId && email) { + legacyEmails.add(email); + } + } + + return { + organizationIds, + accountIds, + refreshTokens, + legacyEmails, + }; +} + function filterSourceAccountsAgainstExistingEmails( sourceStorage: AccountStorageV3, existingAccounts: AccountStorageV3["accounts"], ): AccountStorageV3 { - const existingEmails = new Set( - existingAccounts - .map((account) => normalizeIdentity(account.email)) - .filter((email): email is string => typeof email === "string" && email.length > 0), - ); + const existingState = buildExistingSyncIdentityState(existingAccounts); return { ...sourceStorage, accounts: deduplicateSourceAccountsByEmail(sourceStorage.accounts).filter((account) => { + const organizationId = normalizeIdentity(account.organizationId); + if (organizationId) { + return !existingState.organizationIds.has(organizationId); + } + const accountId = normalizeIdentity(account.accountId); + if (accountId) { + return !existingState.accountIds.has(accountId); + } + const refreshToken = normalizeIdentity(account.refreshToken); + if (refreshToken && existingState.refreshTokens.has(refreshToken)) { + return false; + } const normalizedEmail = normalizeIdentity(account.email); if (!normalizedEmail) return true; - return !existingEmails.has(normalizedEmail); + return !existingState.legacyEmails.has(normalizedEmail); }), }; } @@ -240,6 +284,96 @@ function buildMergedDedupedAccounts( }).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; @@ -611,21 +745,43 @@ export async function syncFromCodexMultiAuth( projectPath = process.cwd(), ): Promise { const resolved = await loadPreparedCodexMultiAuthSourceStorage(projectPath); + await assertSyncWithinCapacity(resolved); const result: ImportAccountsResult = await withNormalizedImportFile( tagSyncedAccounts(resolved.storage), - (filePath) => - importAccounts( + (filePath) => { + const maxAccounts = getSyncCapacityLimit(); + return importAccounts( filePath, { preImportBackupPrefix: "codex-multi-auth-sync-backup", backupMode: "required", }, - (normalizedStorage, existing) => - filterSourceAccountsAgainstExistingEmails( + (normalizedStorage, existing) => { + const filteredStorage = filterSourceAccountsAgainstExistingEmails( normalizedStorage, existing?.accounts ?? [], - ), - ), + ); + 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; + }, + ); + }, ); return { rootDir: resolved.rootDir, @@ -744,87 +900,7 @@ async function assertSyncWithinCapacity( activeIndex: 0, activeIndexByFamily: {}, }; - const sourceDedupedTotal = buildMergedDedupedAccounts([], resolved.storage.accounts).length; - const mergedAccounts = buildMergedDedupedAccounts(existing.accounts, resolved.storage.accounts); - if (mergedAccounts.length <= maxAccounts) { - return Promise.resolve(null); - } - - const currentCount = existing.accounts.length; - const sourceCount = resolved.storage.accounts.length; - const dedupedTotal = mergedAccounts.length; - const importableNewAccounts = Math.max(0, dedupedTotal - currentCount); - const skippedOverlaps = Math.max(0, sourceCount - importableNewAccounts); - if (sourceDedupedTotal > maxAccounts) { - return Promise.resolve({ - rootDir: resolved.rootDir, - accountsPath: resolved.accountsPath, - scope: resolved.scope, - currentCount, - sourceCount, - sourceDedupedTotal, - dedupedTotal: sourceDedupedTotal, - maxAccounts: maxAccounts, - needToRemove: sourceDedupedTotal - maxAccounts, - importableNewAccounts: 0, - skippedOverlaps: Math.max(0, sourceCount - sourceDedupedTotal), - suggestedRemovals: [], - } satisfies CodexMultiAuthSyncCapacityDetails); - } - const sourceIdentities = buildSourceIdentitySet(resolved.storage); - 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, resolved.storage.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 Promise.resolve({ - rootDir: resolved.rootDir, - accountsPath: resolved.accountsPath, - scope: resolved.scope, - currentCount, - sourceCount, - sourceDedupedTotal, - dedupedTotal, - maxAccounts: maxAccounts, - needToRemove: dedupedTotal - maxAccounts, - importableNewAccounts, - skippedOverlaps, - suggestedRemovals, - } satisfies CodexMultiAuthSyncCapacityDetails); + return Promise.resolve(computeSyncCapacityDetails(resolved, resolved.storage, existing, maxAccounts)); }); if (details) { diff --git a/test/codex-multi-auth-sync.test.ts b/test/codex-multi-auth-sync.test.ts index 6f8926b4..595cb827 100644 --- a/test/codex-multi-auth-sync.test.ts +++ b/test/codex-multi-auth-sync.test.ts @@ -409,17 +409,25 @@ describe("codex-multi-auth sync", () => { vi.mocked(storageModule.previewImportAccounts).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 }; + expect(parsed.accounts.map((account) => account.email)).toEqual([ + "shared@example.com", + "shared@example.com", + "new@example.com", + ]); + return { imported: 3, skipped: 0, total: 3 }; }); vi.mocked(storageModule.importAccounts).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"]); + expect(parsed.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", }; @@ -429,14 +437,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, }); }); @@ -592,6 +600,70 @@ describe("codex-multi-auth sync", () => { ); }); + 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"); + vi.mocked(storageModule.withAccountStorageTransaction).mockImplementationOnce(async (handler) => + handler( + { + 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, + }, + ], + }, + vi.fn(async () => {}), + ), + ); + + const { syncFromCodexMultiAuth, CodexMultiAuthSyncCapacityError } = await import("../lib/codex-multi-auth-sync.js"); + await expect(syncFromCodexMultiAuth(process.cwd())).rejects.toBeInstanceOf( + CodexMultiAuthSyncCapacityError, + ); + }); + 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) => diff --git a/test/index.test.ts b/test/index.test.ts index 8dfd9736..a1751c8c 100644 --- a/test/index.test.ts +++ b/test/index.test.ts @@ -3017,6 +3017,109 @@ describe("OpenAIOAuthPlugin persistAccountPool", () => { await fs.rm(tempDir, { recursive: true, force: true }); } }); + + it("preserves active pointers when sync prune removes an earlier account", async () => { + const cliModule = await import("../lib/cli.js"); + const storageModule = await import("../lib/storage.js"); + const syncModule = await import("../lib/codex-multi-auth-sync.js"); + const configModule = await import("../lib/config.js"); + const confirmModule = await import("../lib/ui/confirm.js"); + + const tempDir = await fs.mkdtemp(join(tmpdir(), "oc-sync-prune-active-")); + try { + mockStorage.accounts = [ + { + accountId: "org-prune", + organizationId: "org-prune", + accountIdSource: "org", + email: "prune@example.com", + refreshToken: "refresh-prune", + }, + { + accountId: "org-current", + organizationId: "org-current", + accountIdSource: "org", + email: "current@example.com", + refreshToken: "refresh-current", + }, + ]; + mockStorage.activeIndex = 1; + mockStorage.activeIndexByFamily = { codex: 1 }; + + vi.mocked(cliModule.promptLoginMode) + .mockResolvedValueOnce({ mode: "experimental-sync-now" }) + .mockResolvedValueOnce({ mode: "cancel" }); + vi.mocked(cliModule.promptCodexMultiAuthSyncPrune).mockResolvedValueOnce([0]); + vi.mocked(configModule.getSyncFromCodexMultiAuthEnabled).mockReturnValue(true); + vi.mocked(confirmModule.confirm) + .mockResolvedValueOnce(true) + .mockResolvedValueOnce(false); + + vi.mocked(storageModule.createTimestampedBackupPath).mockImplementation((prefix?: string) => + join(tempDir, `${prefix ?? "codex-backup"}.json`), + ); + vi.mocked(storageModule.exportAccounts).mockImplementation(async (filePath: string) => { + await fs.writeFile( + filePath, + JSON.stringify({ + version: mockStorage.version, + activeIndex: mockStorage.activeIndex, + activeIndexByFamily: { ...mockStorage.activeIndexByFamily }, + accounts: mockStorage.accounts.map((account) => ({ ...account })), + }), + "utf8", + ); + }); + vi.mocked(storageModule.saveAccounts).mockImplementation(async (nextStorage) => { + mockStorage.version = nextStorage.version; + mockStorage.activeIndex = nextStorage.activeIndex; + mockStorage.activeIndexByFamily = { ...nextStorage.activeIndexByFamily }; + mockStorage.accounts = nextStorage.accounts.map((account) => ({ ...account })); + }); + + const capacityError = new syncModule.CodexMultiAuthSyncCapacityError({ + rootDir: tempDir, + accountsPath: join(tempDir, "openai-codex-accounts.json"), + scope: "global", + currentCount: 2, + sourceCount: 2, + sourceDedupedTotal: 3, + dedupedTotal: 3, + maxAccounts: 2, + needToRemove: 1, + importableNewAccounts: 1, + skippedOverlaps: 1, + suggestedRemovals: [ + { + index: 0, + email: "prune@example.com", + accountLabel: "Workspace prune", + isCurrentAccount: false, + score: 180, + reason: "disabled", + }, + ], + }); + + vi.mocked(syncModule.previewSyncFromCodexMultiAuth).mockRejectedValueOnce(capacityError); + + const mockClient = createMockClient(); + const { OpenAIOAuthPlugin } = await import("../index.js"); + const plugin = (await OpenAIOAuthPlugin({ client: mockClient } as never)) as unknown as PluginType; + const autoMethod = plugin.auth.methods[0] as unknown as { + authorize: (inputs?: Record) => Promise<{ instructions: string }>; + }; + + const authResult = await autoMethod.authorize(); + expect(authResult.instructions).toBe("Authentication cancelled"); + expect(mockStorage.accounts).toHaveLength(1); + expect(mockStorage.accounts[0]?.accountId).toBe("org-current"); + expect(mockStorage.activeIndex).toBe(0); + expect(mockStorage.activeIndexByFamily.codex).toBe(0); + } finally { + await fs.rm(tempDir, { recursive: true, force: true }); + } + }); }); describe("OpenAIOAuthPlugin showToast error handling", () => { From 1e8b5ae67eff4f87489a277aca7bb62d59610a43 Mon Sep 17 00:00:00 2001 From: ndycode Date: Sun, 8 Mar 2026 00:41:36 +0800 Subject: [PATCH 35/81] fix: harden sync maintenance flows Stabilize worktree-aware codex-multi-auth project lookups, make post-apply temp cleanup warning-only, and add preview plus backup guards for maintenance actions while limiting duplicate-email cleanup to legacy records. Co-authored-by: Codex --- index.ts | 61 +++++++- lib/cli.ts | 2 +- lib/codex-multi-auth-sync.ts | 191 +++++++++++++---------- lib/storage.ts | 237 +++++++++++++++++++---------- lib/storage/paths.ts | 68 ++++++++- lib/ui/copy.ts | 2 +- test/codex-multi-auth-sync.test.ts | 70 ++++++++- test/index.test.ts | 107 ++++++++++++- test/paths.test.ts | 36 ++++- test/storage.test.ts | 70 ++++++++- 10 files changed, 669 insertions(+), 175 deletions(-) diff --git a/index.ts b/index.ts index 1489ae15..8f2bd12b 100644 --- a/index.ts +++ b/index.ts @@ -114,6 +114,7 @@ import { saveAccounts, withAccountStorageTransaction, cleanupDuplicateEmailAccounts, + previewDuplicateEmailCleanup, clearAccounts, setStoragePath, exportAccounts, @@ -192,6 +193,7 @@ import { import { CodexMultiAuthSyncCapacityError, cleanupCodexMultiAuthSyncedOverlaps, + previewCodexMultiAuthSyncedOverlapCleanup, previewSyncFromCodexMultiAuth, syncFromCodexMultiAuth, } from "./lib/codex-multi-auth-sync.js"; @@ -3852,6 +3854,14 @@ while (attempted.size < Math.max(1, accountCount)) { } }; + const createMaintenanceAccountsBackup = async ( + prefix: string, + ): Promise => { + const backupPath = createTimestampedBackupPath(prefix); + await exportAccounts(backupPath, true); + return backupPath; + }; + const runCodexMultiAuthSync = async (): Promise => { const currentConfig = loadPluginConfig(); if (!getSyncFromCodexMultiAuthEnabled(currentConfig)) { @@ -4177,6 +4187,29 @@ while (attempted.size < Math.max(1, accountCount)) { 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}`); + console.log("A backup will be created before changes are applied."); + console.log(""); + const confirmed = await confirm( + `Create a backup and apply synced overlap cleanup?`, + ); + if (!confirmed) { + console.log("\nCleanup cancelled.\n"); + return; + } + const backupPath = await createMaintenanceAccountsBackup( + "codex-maintenance-overlap-backup", + ); const result = await cleanupCodexMultiAuthSyncedOverlaps(); invalidateAccountManagerCache(); console.log(""); @@ -4184,6 +4217,8 @@ while (attempted.size < Math.max(1, accountCount)) { 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); @@ -4193,6 +4228,29 @@ while (attempted.size < Math.max(1, accountCount)) { const runDuplicateEmailCleanup = async (): Promise => { try { + const preview = await previewDuplicateEmailCleanup(); + if (preview.removed <= 0) { + console.log("\nNo legacy duplicate emails found.\n"); + return; + } + console.log(""); + console.log("Cleanup preview."); + console.log(`Before: ${preview.before}`); + console.log(`After: ${preview.after}`); + console.log(`Would remove legacy duplicates: ${preview.removed}`); + console.log("Only legacy accounts without organization or workspace IDs are eligible."); + console.log("A backup will be created before changes are applied."); + console.log(""); + const confirmed = await confirm( + `Create a backup and remove ${preview.removed} legacy duplicate-email account(s)?`, + ); + if (!confirmed) { + console.log("\nDuplicate email cleanup cancelled.\n"); + return; + } + const backupPath = await createMaintenanceAccountsBackup( + "codex-maintenance-duplicate-email-backup", + ); const result = await cleanupDuplicateEmailAccounts(); if (result.removed > 0) { invalidateAccountManagerCache(); @@ -4201,11 +4259,12 @@ while (attempted.size < Math.max(1, accountCount)) { console.log(`Before: ${result.before}`); console.log(`After: ${result.after}`); console.log(`Removed duplicates: ${result.removed}`); + console.log(`Backup: ${backupPath}`); console.log(""); return; } - console.log("\nNo duplicate emails found.\n"); + console.log("\nNo legacy duplicate emails found.\n"); } catch (error) { const message = error instanceof Error ? error.message : String(error); console.log(`\nDuplicate email cleanup failed: ${message}\n`); diff --git a/lib/cli.ts b/lib/cli.ts index d3febea3..a99ca827 100644 --- a/lib/cli.ts +++ b/lib/cli.ts @@ -223,7 +223,7 @@ async function promptSettingsModeFallback( while (true) { const syncState = syncFromCodexMultiAuthEnabled ? "enabled" : "disabled"; const answer = await rl.question( - `(t) toggle sync [${syncState}], (i) sync now, (c) cleanup overlaps, (d) clean duplicate emails, (b) back [t/i/c/d/b]: `, + `(t) toggle sync [${syncState}], (i) sync now, (c) cleanup overlaps, (d) clean legacy duplicate emails, (b) back [t/i/c/d/b]: `, ); const normalized = answer.trim().toLowerCase(); if (normalized === "t" || normalized === "toggle") { diff --git a/lib/codex-multi-auth-sync.ts b/lib/codex-multi-auth-sync.ts index 64a12d4f..060d2bc5 100644 --- a/lib/codex-multi-auth-sync.ts +++ b/lib/codex-multi-auth-sync.ts @@ -13,7 +13,7 @@ import { type AccountStorageV3, type ImportAccountsResult, } from "./storage.js"; -import { findProjectRoot, getProjectStorageKey } from "./storage/paths.js"; +import { findProjectRoot, getProjectStorageKeyCandidates } from "./storage/paths.js"; const EXTERNAL_ROOT_SUFFIX = "multi-auth"; const EXTERNAL_ACCOUNT_FILE_NAMES = [ @@ -108,9 +108,14 @@ function normalizeSourceStorage(storage: AccountStorageV3): AccountStorageV3 { }; } +type NormalizedImportFileOptions = { + postSuccessCleanupFailureMode?: "throw" | "warn"; +}; + 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); @@ -137,7 +142,9 @@ async function withNormalizedImportFile( } catch (cleanupError) { const message = cleanupError instanceof Error ? cleanupError.message : String(cleanupError); logWarn(`Failed to remove temporary codex sync directory ${tempDir}: ${message}`); - throw new Error(`Failed to remove temporary codex sync directory ${tempDir}: ${message}`); + if (options.postSuccessCleanupFailureMode !== "warn") { + throw new Error(`Failed to remove temporary codex sync directory ${tempDir}: ${message}`); + } } return result; }; @@ -629,11 +636,12 @@ function getProjectScopedAccountsPath(rootDir: string, projectPath: string): str return undefined; } - const projectKey = getProjectStorageKey(projectRoot); - for (const fileName of EXTERNAL_ACCOUNT_FILE_NAMES) { - const candidate = join(rootDir, "projects", projectKey, fileName); - if (existsSync(candidate)) { - return candidate; + for (const candidateKey of getProjectStorageKeyCandidates(projectRoot)) { + for (const fileName of EXTERNAL_ACCOUNT_FILE_NAMES) { + const candidate = join(rootDir, "projects", candidateKey, fileName); + if (existsSync(candidate)) { + return candidate; + } } } return undefined; @@ -782,6 +790,7 @@ export async function syncFromCodexMultiAuth( }, ); }, + { postSuccessCleanupFailureMode: "warn" }, ); return { rootDir: resolved.rootDir, @@ -796,91 +805,119 @@ export async function syncFromCodexMultiAuth( }; } -export async function cleanupCodexMultiAuthSyncedOverlaps(): Promise { - return withAccountStorageTransaction(async (current, persist) => { - const existing = current ?? { - version: 3 as const, - accounts: [], - activeIndex: 0, - activeIndexByFamily: {}, - }; - 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 { +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 { + }, + }; + } + 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 normalized = { - ...existing, - accounts: [...preservedAccounts, ...normalizedSyncedStorage.accounts], - } 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 JSON.stringify(original) === JSON.stringify(account) ? count : count + 1; - }, 0); - - if (removed > 0 || after !== before || JSON.stringify(normalized) !== JSON.stringify(existing)) { - await persist(normalized); + }, + }; + } + const normalized = { + ...existing, + accounts: [...preservedAccounts, ...normalizedSyncedStorage.accounts], + } 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 JSON.stringify(original) === JSON.stringify(account) ? count : count + 1; + }, 0); + const changed = + removed > 0 || after !== before || JSON.stringify(normalized) !== JSON.stringify(existing); - return { + return { + result: { before, after, removed, updated, + }, + nextStorage: changed ? normalized : undefined, + }; +} + +export async function previewCodexMultiAuthSyncedOverlapCleanup(): Promise { + return withAccountStorageTransaction((current) => { + const existing = current ?? { + version: 3 as const, + accounts: [], + activeIndex: 0, + activeIndexByFamily: {}, }; + return Promise.resolve(buildCodexMultiAuthOverlapCleanupPlan(existing).result); + }); +} + +export async function cleanupCodexMultiAuthSyncedOverlaps(): Promise { + return withAccountStorageTransaction(async (current, persist) => { + const existing = current ?? { + version: 3 as const, + accounts: [], + activeIndex: 0, + activeIndexByFamily: {}, + }; + const plan = buildCodexMultiAuthOverlapCleanupPlan(existing); + if (plan.nextStorage) { + await persist(plan.nextStorage); + } + return plan.result; }); } diff --git a/lib/storage.ts b/lib/storage.ts index b007c67d..19f5ff50 100644 --- a/lib/storage.ts +++ b/lib/storage.ts @@ -440,6 +440,12 @@ function deduplicateAccountsByEmailForMaintenance { + if (deduplicatedAccounts.length === 0) return 0; + if (existingActiveKeys.length > 0) { + const byIdentity = findAccountIndexByIdentityKeys(deduplicatedAccounts, existingActiveKeys); + if (byIdentity >= 0) return byIdentity; + } + const byEmail = findAccountIndexByNormalizedEmail(deduplicatedAccounts, existingActiveEmail); + if (byEmail >= 0) return byEmail; + return clampIndex(existingActiveIndex, deduplicatedAccounts.length); + })(); + + const activeIndexByFamily: Partial> = {}; + const rawFamilyIndices = existing.activeIndexByFamily ?? {}; + + for (const family of MODEL_FAMILIES) { + const rawIndexValue = rawFamilyIndices[family]; + const rawIndex = + typeof rawIndexValue === "number" && Number.isFinite(rawIndexValue) + ? rawIndexValue + : existingActiveIndex; + const clampedRawIndex = clampIndex(rawIndex, existing.accounts.length); + const familyKeys = extractActiveKeys(existing.accounts, clampedRawIndex); + const familyEmail = extractActiveEmail(existing.accounts, clampedRawIndex); + + let mappedIndex = mappedActiveIndex; + if (familyKeys.length > 0) { + const byIdentity = findAccountIndexByIdentityKeys(deduplicatedAccounts, familyKeys); + if (byIdentity >= 0) { + mappedIndex = byIdentity; + activeIndexByFamily[family] = mappedIndex; + continue; + } + } + + const byEmail = findAccountIndexByNormalizedEmail(deduplicatedAccounts, familyEmail); + if (byEmail >= 0) { + mappedIndex = byEmail; + } + activeIndexByFamily[family] = mappedIndex; + } + + return { + result: { + before, + after, + removed, + }, + nextStorage: { + version: 3, + accounts: deduplicatedAccounts, + activeIndex: mappedActiveIndex, + activeIndexByFamily, + }, + }; +} + +function normalizeDuplicateCleanupSourceStorage(data: unknown): AccountStorageV3 | null { + if (!isRecord(data) || (data.version !== 1 && data.version !== 3) || !Array.isArray(data.accounts)) { + return null; + } + + const accounts = data.accounts + .filter( + (account): account is AccountStorageV3["accounts"][number] => + isRecord(account) && + typeof account.refreshToken === "string" && + account.refreshToken.trim().length > 0, + ) + .map((account) => ({ ...account })); + const activeIndexValue = + typeof data.activeIndex === "number" && Number.isFinite(data.activeIndex) + ? data.activeIndex + : 0; + const activeIndex = clampIndex(activeIndexValue, accounts.length); + const rawActiveIndexByFamily = isRecord(data.activeIndexByFamily) ? data.activeIndexByFamily : {}; + const activeIndexByFamily = Object.fromEntries( + MODEL_FAMILIES.map((family) => { + const rawValue = rawActiveIndexByFamily[family]; + const nextIndex = + typeof rawValue === "number" && Number.isFinite(rawValue) + ? clampIndex(rawValue, accounts.length) + : activeIndex; + return [family, nextIndex]; + }), + ) as AccountStorageV3["activeIndexByFamily"]; + + return { + version: 3, + accounts, + activeIndex, + activeIndexByFamily, + }; +} + +async function loadDuplicateCleanupSourceStorage(): Promise { + const fallback = await loadAccountsInternal(null); + try { + const rawContent = await fs.readFile(getStoragePath(), "utf-8"); + const rawData = JSON.parse(rawContent) as unknown; + return normalizeDuplicateCleanupSourceStorage(rawData) ?? fallback ?? { + version: 3, + accounts: [], + activeIndex: 0, + activeIndexByFamily: {}, + }; + } catch (error) { + const code = (error as NodeJS.ErrnoException).code; + if (code !== "ENOENT") { + log.warn("Failed to read raw storage snapshot for duplicate cleanup", { error: String(error) }); + } + return fallback ?? { + version: 3, + accounts: [], + activeIndex: 0, + activeIndexByFamily: {}, + }; + } +} + /** * Removes duplicate accounts, keeping the most recently used entry for each unique key. * Deduplication identity hierarchy: organizationId -> accountId -> refreshToken. @@ -987,85 +1134,21 @@ export async function clearAccounts(): Promise { }); } -export async function cleanupDuplicateEmailAccounts(): Promise { - return withAccountStorageTransaction(async (current, persist) => { - const existing: AccountStorageV3 = - current ?? - ({ - version: 3, - accounts: [], - activeIndex: 0, - activeIndexByFamily: {}, - } satisfies AccountStorageV3); - const before = existing.accounts.length; - const existingActiveIndex = clampIndex(existing.activeIndex, existing.accounts.length); - const existingActiveKeys = extractActiveKeys(existing.accounts, existingActiveIndex); - const existingActiveEmail = extractActiveEmail(existing.accounts, existingActiveIndex); - const deduplicatedAccounts = deduplicateAccountsByEmailForMaintenance(existing.accounts); - const after = deduplicatedAccounts.length; - const removed = Math.max(0, before - after); - - if (removed === 0) { - return { - before, - after, - removed, - }; - } - - const mappedActiveIndex = (() => { - if (deduplicatedAccounts.length === 0) return 0; - if (existingActiveKeys.length > 0) { - const byIdentity = findAccountIndexByIdentityKeys(deduplicatedAccounts, existingActiveKeys); - if (byIdentity >= 0) return byIdentity; - } - const byEmail = findAccountIndexByNormalizedEmail(deduplicatedAccounts, existingActiveEmail); - if (byEmail >= 0) return byEmail; - return clampIndex(existingActiveIndex, deduplicatedAccounts.length); - })(); - - const activeIndexByFamily: Partial> = {}; - const rawFamilyIndices = existing.activeIndexByFamily ?? {}; - - for (const family of MODEL_FAMILIES) { - const rawIndexValue = rawFamilyIndices[family]; - const rawIndex = - typeof rawIndexValue === "number" && Number.isFinite(rawIndexValue) - ? rawIndexValue - : existingActiveIndex; - const clampedRawIndex = clampIndex(rawIndex, existing.accounts.length); - const familyKeys = extractActiveKeys(existing.accounts, clampedRawIndex); - const familyEmail = extractActiveEmail(existing.accounts, clampedRawIndex); - - let mappedIndex = mappedActiveIndex; - if (familyKeys.length > 0) { - const byIdentity = findAccountIndexByIdentityKeys(deduplicatedAccounts, familyKeys); - if (byIdentity >= 0) { - mappedIndex = byIdentity; - activeIndexByFamily[family] = mappedIndex; - continue; - } - } +export async function previewDuplicateEmailCleanup(): Promise { + return withStorageLock(async () => { + const existing = await loadDuplicateCleanupSourceStorage(); + return buildDuplicateEmailCleanupPlan(existing).result; + }); +} - const byEmail = findAccountIndexByNormalizedEmail(deduplicatedAccounts, familyEmail); - if (byEmail >= 0) { - mappedIndex = byEmail; - } - activeIndexByFamily[family] = mappedIndex; +export async function cleanupDuplicateEmailAccounts(): Promise { + return withStorageLock(async () => { + const existing = await loadDuplicateCleanupSourceStorage(); + const plan = buildDuplicateEmailCleanupPlan(existing); + if (plan.nextStorage) { + await saveAccountsUnlocked(plan.nextStorage); } - - await persist({ - version: 3, - accounts: deduplicatedAccounts, - activeIndex: mappedActiveIndex, - activeIndexByFamily, - }); - - return { - before, - after, - removed, - }; + return plan.result; }); } diff --git a/lib/storage/paths.ts b/lib/storage/paths.ts index b3e9d5b9..601928c8 100644 --- a/lib/storage/paths.ts +++ b/lib/storage/paths.ts @@ -3,7 +3,7 @@ * Extracted from storage.ts to reduce module size. */ -import { existsSync } from "node:fs"; +import { existsSync, readFileSync } from "node:fs"; import { createHash } from "node:crypto"; import { basename, dirname, isAbsolute, join, relative, resolve } from "node:path"; import { homedir, tmpdir } from "node:os"; @@ -34,16 +34,74 @@ function sanitizeProjectName(projectPath: string): string { return sanitized || "project"; } -export function getProjectStorageKey(projectPath: string): string { - const normalizedPath = normalizeProjectPath(projectPath); +function buildProjectStorageKey(projectPath: string, identityPath: string): string { const hash = createHash("sha256") - .update(normalizedPath) + .update(identityPath) .digest("hex") .slice(0, PROJECT_KEY_HASH_LENGTH); - const projectName = sanitizeProjectName(normalizedPath).slice(0, 40); + const projectName = sanitizeProjectName(projectPath).slice(0, 40); return `${projectName}-${hash}`; } +function getCanonicalProjectStorageIdentity(projectPath: string): { + identityPath: string; + projectNamePath: string; +} { + const resolvedProjectPath = resolve(projectPath); + const gitPath = join(resolvedProjectPath, ".git"); + if (!existsSync(gitPath)) { + return { + identityPath: normalizeProjectPath(resolvedProjectPath), + projectNamePath: resolvedProjectPath, + }; + } + + try { + const gitMetadata = readFileSync(gitPath, "utf-8").trim(); + const gitDirMatch = /^gitdir:\s*(.+)$/im.exec(gitMetadata); + const gitDirValue = gitDirMatch?.[1]; + if (!gitDirValue) { + return { + identityPath: normalizeProjectPath(resolvedProjectPath), + projectNamePath: resolvedProjectPath, + }; + } + const gitDir = resolve(resolvedProjectPath, gitDirValue.trim()); + const gitDirParent = dirname(gitDir); + if (basename(gitDirParent).toLowerCase() === "worktrees") { + const commonGitDir = dirname(gitDirParent); + return { + identityPath: normalizeProjectPath(commonGitDir), + projectNamePath: dirname(commonGitDir), + }; + } + return { + identityPath: normalizeProjectPath(gitDir), + projectNamePath: resolvedProjectPath, + }; + } catch { + return { + identityPath: normalizeProjectPath(resolvedProjectPath), + projectNamePath: resolvedProjectPath, + }; + } +} + +export function getProjectStorageKeyCandidates(projectPath: string): string[] { + const normalizedProjectPath = normalizeProjectPath(projectPath); + const canonicalIdentity = getCanonicalProjectStorageIdentity(projectPath); + const candidates = [ + buildProjectStorageKey(canonicalIdentity.projectNamePath, canonicalIdentity.identityPath), + buildProjectStorageKey(projectPath, normalizedProjectPath), + ]; + return Array.from(new Set(candidates)); +} + +export function getProjectStorageKey(projectPath: string): string { + const normalizedPath = normalizeProjectPath(projectPath); + return buildProjectStorageKey(projectPath, normalizedPath); +} + /** * Per-project storage is namespaced under ~/.opencode/projects * to avoid writing account files into user repositories. diff --git a/lib/ui/copy.ts b/lib/ui/copy.ts index 27ddde54..6e9b7dac 100644 --- a/lib/ui/copy.ts +++ b/lib/ui/copy.ts @@ -40,7 +40,7 @@ export const UI_COPY = { navigationHeading: "Navigation", syncToggle: "Sync from codex-multi-auth", syncNow: "Sync Now", - cleanupDuplicateEmails: "Clean Duplicate Emails", + cleanupDuplicateEmails: "Clean Legacy Duplicate Emails", cleanupOverlaps: "Cleanup Synced Overlaps", back: "Back", }, diff --git a/test/codex-multi-auth-sync.test.ts b/test/codex-multi-auth-sync.test.ts index 595cb827..ecf73c6e 100644 --- a/test/codex-multi-auth-sync.test.ts +++ b/test/codex-multi-auth-sync.test.ts @@ -2,7 +2,7 @@ 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 { findProjectRoot, getProjectStorageKey, getProjectStorageKeyCandidates } from "../lib/storage/paths.js"; vi.mock("../lib/logger.js", () => ({ logWarn: vi.fn(), @@ -317,6 +317,41 @@ describe("codex-multi-auth sync", () => { } }); + it("finds the project-scoped codex-multi-auth source across same-repo worktrees", async () => { + const rootDir = join(process.cwd(), ".tmp-codex-multi-auth"); + process.env.CODEX_MULTI_AUTH_DIR = rootDir; + const mainWorktree = "C:\\Users\\neil\\DevTools\\oc-chatgpt-multi-auth"; + const branchWorktree = "C:\\Users\\neil\\DevTools\\oc-chatgpt-multi-auth-sync-worktree"; + const sharedGitFile = "gitdir: C:/Users/neil/DevTools/oc-chatgpt-multi-auth/.git/worktrees/feature-sync\n"; + const mainGitPath = join(mainWorktree, ".git"); + const branchGitPath = join(branchWorktree, ".git"); + let projectPath = ""; + + mockExistsSync.mockImplementation((candidate) => { + return ( + String(candidate) === projectPath || + String(candidate) === mainGitPath || + String(candidate) === branchGitPath + ); + }); + vi.mocked(fs.readFileSync).mockImplementation((candidate) => { + if (String(candidate) === mainGitPath || String(candidate) === branchGitPath) { + return sharedGitFile; + } + throw new Error(`unexpected read: ${String(candidate)}`); + }); + const sharedProjectKey = getProjectStorageKeyCandidates(mainWorktree)[0]; + projectPath = join(rootDir, "projects", sharedProjectKey ?? "missing", "openai-codex-accounts.json"); + + const { resolveCodexMultiAuthAccountsSource } = await import("../lib/codex-multi-auth-sync.js"); + const resolved = resolveCodexMultiAuthAccountsSource(branchWorktree); + expect(resolved).toEqual({ + rootDir, + accountsPath: projectPath, + scope: "project", + }); + }); + it("fails preview 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; @@ -881,6 +916,39 @@ describe("codex-multi-auth sync", () => { }); }); + it("returns sync results even if temporary import cleanup fails after apply", 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(new Error("rm failed")); + const loggerModule = await import("../lib/logger.js"); + try { + const { syncFromCodexMultiAuth } = await import("../lib/codex-multi-auth-sync.js"); + + await expect(syncFromCodexMultiAuth(process.cwd())).resolves.toMatchObject({ + imported: 2, + skipped: 0, + total: 4, + backupStatus: "created", + }); + expect(vi.mocked(loggerModule.logWarn)).toHaveBeenCalledWith( + expect.stringContaining("Failed to remove temporary codex sync directory"), + ); + } finally { + rmSpy.mockRestore(); + } + }); + it("does not block source-only imports above the old cap when limit is unlimited", async () => { const rootDir = join(process.cwd(), ".tmp-codex-multi-auth"); process.env.CODEX_MULTI_AUTH_DIR = rootDir; diff --git a/test/index.test.ts b/test/index.test.ts index a1751c8c..962197c9 100644 --- a/test/index.test.ts +++ b/test/index.test.ts @@ -197,6 +197,12 @@ vi.mock("../lib/codex-multi-auth-sync.js", () => ({ skipped: 0, total: 4, })), + previewCodexMultiAuthSyncedOverlapCleanup: vi.fn(async () => ({ + before: 0, + after: 0, + removed: 0, + updated: 0, + })), syncFromCodexMultiAuth: vi.fn(async () => ({ rootDir: "/tmp/codex-root", accountsPath: "/tmp/codex-root/openai-codex-accounts.json", @@ -291,6 +297,11 @@ vi.mock("../lib/storage.js", () => ({ after: 0, removed: 0, })), + previewDuplicateEmailCleanup: vi.fn(async () => ({ + before: 0, + after: 0, + removed: 0, + })), clearAccounts: vi.fn(async () => {}), setStoragePath: vi.fn(), exportAccounts: vi.fn(async () => {}), @@ -2725,23 +2736,18 @@ describe("OpenAIOAuthPlugin persistAccountPool", () => { expect(mockStorage.accounts[0]?.email).toBe("keep@example.com"); }); - it("runs duplicate email cleanup from maintenance settings", async () => { + it("runs legacy duplicate email cleanup from maintenance settings with confirmation and backup", async () => { const cliModule = await import("../lib/cli.js"); const storageModule = await import("../lib/storage.js"); + const confirmModule = await import("../lib/ui/confirm.js"); mockStorage.accounts = [ { - accountId: "org-older", - organizationId: "org-older", - accountIdSource: "org", email: "shared@example.com", refreshToken: "refresh-older", lastUsed: 1, }, { - accountId: "org-newer", - organizationId: "org-newer", - accountIdSource: "org", email: "shared@example.com", refreshToken: "refresh-newer", lastUsed: 2, @@ -2753,6 +2759,12 @@ describe("OpenAIOAuthPlugin persistAccountPool", () => { vi.mocked(cliModule.promptLoginMode) .mockResolvedValueOnce({ mode: "maintenance-clean-duplicate-emails" }) .mockResolvedValueOnce({ mode: "cancel" }); + vi.mocked(storageModule.previewDuplicateEmailCleanup).mockResolvedValueOnce({ + before: 2, + after: 1, + removed: 1, + }); + vi.mocked(confirmModule.confirm).mockResolvedValueOnce(true); vi.mocked(storageModule.cleanupDuplicateEmailAccounts).mockImplementationOnce(async () => { mockStorage.accounts = [mockStorage.accounts[1]].filter(Boolean) as typeof mockStorage.accounts; @@ -2774,9 +2786,88 @@ describe("OpenAIOAuthPlugin persistAccountPool", () => { const authResult = await autoMethod.authorize(); expect(authResult.instructions).toBe("Authentication cancelled"); + expect(vi.mocked(storageModule.previewDuplicateEmailCleanup)).toHaveBeenCalledTimes(1); + expect(vi.mocked(confirmModule.confirm)).toHaveBeenCalledWith( + "Create a backup and remove 1 legacy duplicate-email account(s)?", + ); + expect(vi.mocked(storageModule.createTimestampedBackupPath)).toHaveBeenCalledWith( + "codex-maintenance-duplicate-email-backup", + ); + expect(vi.mocked(storageModule.exportAccounts)).toHaveBeenCalledWith( + "/tmp/codex-maintenance-duplicate-email-backup-20260101-000000.json", + true, + ); expect(vi.mocked(storageModule.cleanupDuplicateEmailAccounts)).toHaveBeenCalledTimes(1); expect(mockStorage.accounts).toHaveLength(1); - expect(mockStorage.accounts[0]?.accountId).toBe("org-newer"); + expect(mockStorage.accounts[0]?.refreshToken).toBe("refresh-newer"); + }); + + it("runs synced overlap cleanup from maintenance settings with confirmation and backup", async () => { + const cliModule = await import("../lib/cli.js"); + const storageModule = await import("../lib/storage.js"); + const syncModule = await import("../lib/codex-multi-auth-sync.js"); + const confirmModule = await import("../lib/ui/confirm.js"); + + mockStorage.accounts = [ + { + accountId: "org-local", + organizationId: "org-local", + accountIdSource: "org", + email: "local@example.com", + refreshToken: "refresh-local", + }, + { + accountId: "org-sync", + organizationId: "org-sync", + accountIdSource: "org", + email: "sync@example.com", + refreshToken: "refresh-sync", + }, + ]; + + vi.mocked(cliModule.promptLoginMode) + .mockResolvedValueOnce({ mode: "experimental-cleanup-overlaps" }) + .mockResolvedValueOnce({ mode: "cancel" }); + vi.mocked(syncModule.previewCodexMultiAuthSyncedOverlapCleanup).mockResolvedValueOnce({ + before: 2, + after: 1, + removed: 1, + updated: 0, + }); + vi.mocked(confirmModule.confirm).mockResolvedValueOnce(true); + vi.mocked(syncModule.cleanupCodexMultiAuthSyncedOverlaps).mockImplementationOnce(async () => { + mockStorage.accounts = [mockStorage.accounts[0]].filter(Boolean) as typeof mockStorage.accounts; + return { + before: 2, + after: 1, + removed: 1, + updated: 0, + }; + }); + + const mockClient = createMockClient(); + const { OpenAIOAuthPlugin } = await import("../index.js"); + const plugin = (await OpenAIOAuthPlugin({ client: mockClient } as never)) as unknown as PluginType; + const autoMethod = plugin.auth.methods[0] as unknown as { + authorize: (inputs?: Record) => Promise<{ instructions: string }>; + }; + + const authResult = await autoMethod.authorize(); + expect(authResult.instructions).toBe("Authentication cancelled"); + expect(vi.mocked(syncModule.previewCodexMultiAuthSyncedOverlapCleanup)).toHaveBeenCalledTimes(1); + expect(vi.mocked(confirmModule.confirm)).toHaveBeenCalledWith( + "Create a backup and apply synced overlap cleanup?", + ); + expect(vi.mocked(storageModule.createTimestampedBackupPath)).toHaveBeenCalledWith( + "codex-maintenance-overlap-backup", + ); + expect(vi.mocked(storageModule.exportAccounts)).toHaveBeenCalledWith( + "/tmp/codex-maintenance-overlap-backup-20260101-000000.json", + true, + ); + expect(vi.mocked(syncModule.cleanupCodexMultiAuthSyncedOverlaps)).toHaveBeenCalledTimes(1); + expect(mockStorage.accounts).toHaveLength(1); + expect(mockStorage.accounts[0]?.accountId).toBe("org-local"); }); it("runs best-account selection from the dashboard forecast action", async () => { diff --git a/test/paths.test.ts b/test/paths.test.ts index ca215ec8..820ad944 100644 --- a/test/paths.test.ts +++ b/test/paths.test.ts @@ -4,20 +4,23 @@ import path from "node:path"; vi.mock("node:fs", () => ({ existsSync: vi.fn(), + readFileSync: vi.fn(), })); -import { existsSync } from "node:fs"; +import { existsSync, readFileSync } from "node:fs"; import { getConfigDir, getProjectConfigDir, getProjectGlobalConfigDir, getProjectStorageKey, + getProjectStorageKeyCandidates, isProjectDirectory, findProjectRoot, resolvePath, } from "../lib/storage/paths.js"; const mockedExistsSync = vi.mocked(existsSync); +const mockedReadFileSync = vi.mocked(readFileSync); describe("Storage Paths Module", () => { beforeEach(() => { @@ -59,6 +62,37 @@ describe("Storage Paths Module", () => { }); }); + describe("getProjectStorageKeyCandidates", () => { + it("returns a shared canonical key for same-repo worktrees before the legacy fallback", () => { + const mainWorktree = "C:\\Users\\neil\\DevTools\\oc-chatgpt-multi-auth"; + const branchWorktree = "C:\\Users\\neil\\DevTools\\oc-chatgpt-multi-auth-sync-worktree"; + const sharedGitFile = "gitdir: C:/Users/neil/DevTools/oc-chatgpt-multi-auth/.git/worktrees/feature-sync\n"; + mockedExistsSync.mockImplementation((candidate) => { + const normalized = String(candidate).replace(/\//g, "\\").toLowerCase(); + return ( + normalized === `${mainWorktree}\\.git`.toLowerCase() || + normalized === `${branchWorktree}\\.git`.toLowerCase() + ); + }); + mockedReadFileSync.mockImplementation((candidate) => { + const normalized = String(candidate).replace(/\//g, "\\").toLowerCase(); + if ( + normalized === `${mainWorktree}\\.git`.toLowerCase() || + normalized === `${branchWorktree}\\.git`.toLowerCase() + ) { + return sharedGitFile; + } + throw new Error(`unexpected read: ${String(candidate)}`); + }); + + const mainCandidates = getProjectStorageKeyCandidates(mainWorktree); + const branchCandidates = getProjectStorageKeyCandidates(branchWorktree); + + expect(mainCandidates[0]).toBe(branchCandidates[0]); + expect(mainCandidates[1]).not.toBe(branchCandidates[1]); + }); + }); + describe("getProjectGlobalConfigDir", () => { it("returns ~/.opencode/projects/", () => { const projectPath = "/home/user/myproject"; diff --git a/test/storage.test.ts b/test/storage.test.ts index 447a1be4..a3a0b0fa 100644 --- a/test/storage.test.ts +++ b/test/storage.test.ts @@ -91,7 +91,7 @@ describe("storage", () => { expect(deduped[0]?.lastUsed).toBe(now); }); - it("cleans duplicate emails across local accounts and remaps active indices", async () => { + it("preserves org-scoped accounts that share an email during duplicate cleanup", async () => { const testStoragePath = join( tmpdir(), `codex-clean-duplicate-emails-${Math.random().toString(36).slice(2)}.json`, @@ -137,6 +137,72 @@ describe("storage", () => { ], }); + await expect(cleanupDuplicateEmailAccounts()).resolves.toEqual({ + before: 3, + after: 3, + removed: 0, + }); + + const loaded = await loadAccounts(); + expect(loaded?.accounts).toHaveLength(3); + expect(loaded?.accounts[0]).toMatchObject({ + accountId: "org-older", + organizationId: "org-older", + email: "shared@example.com", + refreshToken: "rt-older", + }); + expect(loaded?.accounts[1]?.accountId).toBe("org-newer"); + expect(loaded?.accounts[2]?.email).toBe("unique@example.com"); + expect(loaded?.activeIndex).toBe(0); + expect(loaded?.activeIndexByFamily?.codex).toBe(0); + expect(loaded?.activeIndexByFamily?.["gpt-5.1"]).toBe(1); + } finally { + setStoragePathDirect(null); + await fs.rm(testStoragePath, { force: true }); + } + }); + + it("cleans legacy duplicate emails and remaps active indices", async () => { + const testStoragePath = join( + tmpdir(), + `codex-clean-legacy-duplicate-emails-${Math.random().toString(36).slice(2)}.json`, + ); + setStoragePathDirect(testStoragePath); + + try { + await fs.writeFile( + testStoragePath, + JSON.stringify({ + version: 3, + activeIndex: 0, + activeIndexByFamily: { + codex: 0, + "gpt-5.1": 1, + }, + accounts: [ + { + email: "shared@example.com", + refreshToken: "rt-older", + addedAt: 1, + lastUsed: 1, + }, + { + email: "shared@example.com", + refreshToken: "rt-newer", + addedAt: 2, + lastUsed: 2, + }, + { + email: "unique@example.com", + refreshToken: "rt-unique", + addedAt: 3, + lastUsed: 3, + }, + ], + }), + "utf8", + ); + await expect(cleanupDuplicateEmailAccounts()).resolves.toEqual({ before: 3, after: 2, @@ -146,8 +212,6 @@ describe("storage", () => { const loaded = await loadAccounts(); expect(loaded?.accounts).toHaveLength(2); expect(loaded?.accounts[0]).toMatchObject({ - accountId: "org-newer", - organizationId: "org-newer", email: "shared@example.com", refreshToken: "rt-newer", }); From 182a9ea2151f6a7c2f6047760920e8d5fddc0abf Mon Sep 17 00:00:00 2001 From: ndycode Date: Sun, 8 Mar 2026 01:00:56 +0800 Subject: [PATCH 36/81] fix: resolve worktree sync regressions Handle primary-checkout .git directories in canonical project identity lookup and make synced-overlap cleanup read the raw storage file before applying maintenance dedupe so duplicate tagged rows are actually removed from disk. Co-authored-by: Codex --- lib/codex-multi-auth-sync.ts | 74 +++++++++++++++++++++++-- lib/storage/paths.ts | 8 ++- test/codex-multi-auth-sync.test.ts | 89 +++++++++++++++++++++++++++++- test/paths.test.ts | 22 +++++--- 4 files changed, 178 insertions(+), 15 deletions(-) diff --git a/lib/codex-multi-auth-sync.ts b/lib/codex-multi-auth-sync.ts index 060d2bc5..5e4dbcd2 100644 --- a/lib/codex-multi-auth-sync.ts +++ b/lib/codex-multi-auth-sync.ts @@ -6,6 +6,7 @@ import { logWarn } from "./logger.js"; import { deduplicateAccounts, deduplicateAccountsByEmail, + getStoragePath, importAccounts, normalizeAccountStorage, previewImportAccounts, @@ -893,26 +894,91 @@ 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 record = data as { + accounts: unknown[]; + activeIndex?: unknown; + activeIndexByFamily?: unknown; + }; + const accounts = record.accounts.filter((account): account is AccountStorageV3["accounts"][number] => { + return ( + typeof account === "object" && + account !== null && + typeof (account as { refreshToken?: unknown }).refreshToken === "string" && + (account as { refreshToken: string }).refreshToken.trim().length > 0 + ); + }); + const activeIndexValue = + typeof record.activeIndex === "number" && Number.isFinite(record.activeIndex) + ? record.activeIndex + : 0; + const activeIndex = Math.max(0, Math.min(accounts.length - 1, activeIndexValue)); + const rawActiveIndexByFamily = + record.activeIndexByFamily && typeof record.activeIndexByFamily === "object" + ? record.activeIndexByFamily + : {}; + const activeIndexByFamily = Object.fromEntries( + Object.entries(rawActiveIndexByFamily).flatMap(([family, value]) => { + if (typeof value !== "number" || !Number.isFinite(value)) { + return []; + } + return [[family, Math.max(0, Math.min(accounts.length - 1, value))]]; + }), + ) 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; + return normalizeOverlapCleanupSourceStorage(parsed) ?? fallback; + } catch { + return fallback; + } +} + export async function previewCodexMultiAuthSyncedOverlapCleanup(): Promise { - return withAccountStorageTransaction((current) => { - const existing = current ?? { + return withAccountStorageTransaction(async (current) => { + const fallback = current ?? { version: 3 as const, accounts: [], activeIndex: 0, activeIndexByFamily: {}, }; - return Promise.resolve(buildCodexMultiAuthOverlapCleanupPlan(existing).result); + const existing = await loadRawCodexMultiAuthOverlapCleanupStorage(fallback); + return buildCodexMultiAuthOverlapCleanupPlan(existing).result; }); } export async function cleanupCodexMultiAuthSyncedOverlaps(): Promise { return withAccountStorageTransaction(async (current, persist) => { - const existing = current ?? { + 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); diff --git a/lib/storage/paths.ts b/lib/storage/paths.ts index 601928c8..0dcb3278 100644 --- a/lib/storage/paths.ts +++ b/lib/storage/paths.ts @@ -3,7 +3,7 @@ * Extracted from storage.ts to reduce module size. */ -import { existsSync, readFileSync } from "node:fs"; +import { existsSync, readFileSync, statSync } from "node:fs"; import { createHash } from "node:crypto"; import { basename, dirname, isAbsolute, join, relative, resolve } from "node:path"; import { homedir, tmpdir } from "node:os"; @@ -57,6 +57,12 @@ function getCanonicalProjectStorageIdentity(projectPath: string): { } try { + if (statSync(gitPath).isDirectory()) { + return { + identityPath: normalizeProjectPath(gitPath), + projectNamePath: resolvedProjectPath, + }; + } const gitMetadata = readFileSync(gitPath, "utf-8").trim(); const gitDirMatch = /^gitdir:\s*(.+)$/im.exec(gitMetadata); const gitDirValue = gitDirMatch?.[1]; diff --git a/test/codex-multi-auth-sync.test.ts b/test/codex-multi-auth-sync.test.ts index ecf73c6e..b287d488 100644 --- a/test/codex-multi-auth-sync.test.ts +++ b/test/codex-multi-auth-sync.test.ts @@ -14,12 +14,14 @@ vi.mock("node:fs", async () => { ...actual, existsSync: 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"), previewImportAccounts: vi.fn(async () => ({ imported: 2, skipped: 0, total: 4 })), importAccounts: vi.fn(async () => ({ imported: 2, @@ -334,8 +336,13 @@ describe("codex-multi-auth sync", () => { String(candidate) === branchGitPath ); }); + vi.mocked(fs.statSync).mockImplementation((candidate) => { + return { + isDirectory: () => String(candidate) === mainGitPath, + } as ReturnType; + }); vi.mocked(fs.readFileSync).mockImplementation((candidate) => { - if (String(candidate) === mainGitPath || String(candidate) === branchGitPath) { + if (String(candidate) === branchGitPath) { return sharedGitFile; } throw new Error(`unexpected read: ${String(candidate)}`); @@ -751,6 +758,86 @@ 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"); + let persisted: AccountStorageV3 | null = null; + 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, + }, + ], + }, + vi.fn(async (next) => { + persisted = next; + }), + ), + ); + 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) => { + const record = value as { + version: 3; + activeIndex: number; + activeIndexByFamily: Record; + accounts: Array>; + }; + 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, + }); + expect(persisted?.accounts).toHaveLength(1); + expect(persisted?.accounts[0]?.organizationId).toBe("org-sync"); + }); + it("limits overlap cleanup to accounts tagged from codex-multi-auth sync", async () => { const storageModule = await import("../lib/storage.js"); vi.mocked(storageModule.withAccountStorageTransaction).mockImplementationOnce(async (handler) => diff --git a/test/paths.test.ts b/test/paths.test.ts index 820ad944..9852fb68 100644 --- a/test/paths.test.ts +++ b/test/paths.test.ts @@ -5,9 +5,10 @@ import path from "node:path"; vi.mock("node:fs", () => ({ existsSync: vi.fn(), readFileSync: vi.fn(), + statSync: vi.fn(), })); -import { existsSync, readFileSync } from "node:fs"; +import { existsSync, readFileSync, statSync } from "node:fs"; import { getConfigDir, getProjectConfigDir, @@ -21,6 +22,7 @@ import { const mockedExistsSync = vi.mocked(existsSync); const mockedReadFileSync = vi.mocked(readFileSync); +const mockedStatSync = vi.mocked(statSync); describe("Storage Paths Module", () => { beforeEach(() => { @@ -66,20 +68,22 @@ describe("Storage Paths Module", () => { it("returns a shared canonical key for same-repo worktrees before the legacy fallback", () => { const mainWorktree = "C:\\Users\\neil\\DevTools\\oc-chatgpt-multi-auth"; const branchWorktree = "C:\\Users\\neil\\DevTools\\oc-chatgpt-multi-auth-sync-worktree"; + const mainGitPath = `${mainWorktree}\\.git`.toLowerCase(); + const branchGitPath = `${branchWorktree}\\.git`.toLowerCase(); const sharedGitFile = "gitdir: C:/Users/neil/DevTools/oc-chatgpt-multi-auth/.git/worktrees/feature-sync\n"; mockedExistsSync.mockImplementation((candidate) => { const normalized = String(candidate).replace(/\//g, "\\").toLowerCase(); - return ( - normalized === `${mainWorktree}\\.git`.toLowerCase() || - normalized === `${branchWorktree}\\.git`.toLowerCase() - ); + return normalized === mainGitPath || normalized === branchGitPath; + }); + mockedStatSync.mockImplementation((candidate) => { + const normalized = String(candidate).replace(/\//g, "\\").toLowerCase(); + return { + isDirectory: () => normalized === mainGitPath, + } as ReturnType; }); mockedReadFileSync.mockImplementation((candidate) => { const normalized = String(candidate).replace(/\//g, "\\").toLowerCase(); - if ( - normalized === `${mainWorktree}\\.git`.toLowerCase() || - normalized === `${branchWorktree}\\.git`.toLowerCase() - ) { + if (normalized === branchGitPath) { return sharedGitFile; } throw new Error(`unexpected read: ${String(candidate)}`); From 3bff54836dd3e2b76abd4a29bc52346c4cedc080 Mon Sep 17 00:00:00 2001 From: ndycode Date: Sun, 8 Mar 2026 01:57:28 +0800 Subject: [PATCH 37/81] fix: harden sync cleanup and prune restore Surface sync temp-cleanup warnings, validate and rollback prune restores from a single backup snapshot, preserve the original prune backup across multi-step capacity retries, and remove dead styling logic. Co-authored-by: Codex --- index.ts | 103 +++++++++++++++++++++++------ lib/codex-multi-auth-sync.ts | 22 +++++- test/codex-multi-auth-sync.test.ts | 4 ++ test/index.test.ts | 1 + 4 files changed, 110 insertions(+), 20 deletions(-) diff --git a/index.ts b/index.ts index 8f2bd12b..d19b4a43 100644 --- a/index.ts +++ b/index.ts @@ -126,6 +126,7 @@ import { clearFlaggedAccounts, StorageError, formatStorageErrorHint, + normalizeAccountStorage, type AccountStorageV3, type FlaggedAccountMetadataV1, } from "./lib/storage.js"; @@ -1244,9 +1245,7 @@ export const OpenAIOAuthPlugin: Plugin = async ({ client }: PluginInput) => { tone: OperationTone, ): string => { if (ui.v2Enabled) { - const mappedTone = - tone === "accent" ? "accent" : tone === "normal" ? "normal" : tone; - return paintUiText(ui, text, mappedTone); + return paintUiText(ui, text, tone); } const ansiCode = tone === "accent" @@ -3870,28 +3869,90 @@ while (attempted.size < Math.max(1, accountCount)) { } const createSyncPruneBackup = async (): Promise<{ - accountsBackupPath: string; - flaggedBackupPath: string; + backupPath: string; restore: () => Promise; }> => { + const currentAccountsStorage = + (await loadAccounts()) ?? + ({ + version: 3, + accounts: [], + activeIndex: 0, + activeIndexByFamily: {}, + } satisfies AccountStorageV3); const currentFlaggedStorage = await loadFlaggedAccounts(); - const accountsBackupPath = createTimestampedBackupPath("codex-sync-prune-backup"); - const flaggedBackupPath = createTimestampedBackupPath("codex-sync-prune-flagged-backup"); - await exportAccounts(accountsBackupPath, true); - const flaggedSnapshot = { ...currentFlaggedStorage, accounts: currentFlaggedStorage.accounts.map((flagged) => ({ ...flagged })) }; - await fsPromises.writeFile(flaggedBackupPath, `${JSON.stringify(flaggedSnapshot, null, 2)}\n`, { + const backupPath = createTimestampedBackupPath("codex-sync-prune-backup"); + const backupPayload = { + version: 1 as const, + accounts: { + ...currentAccountsStorage, + accounts: currentAccountsStorage.accounts.map((account) => ({ ...account })), + activeIndexByFamily: { ...(currentAccountsStorage.activeIndexByFamily ?? {}) }, + }, + flagged: { + ...currentFlaggedStorage, + accounts: currentFlaggedStorage.accounts.map((flagged) => ({ ...flagged })), + }, + }; + await fsPromises.writeFile(backupPath, `${JSON.stringify(backupPayload, null, 2)}\n`, { encoding: "utf-8", mode: 0o600, flag: "wx", }); return { - accountsBackupPath, - flaggedBackupPath, + backupPath, restore: async () => { - const accountsRaw = await fsPromises.readFile(accountsBackupPath, "utf-8"); - await saveAccounts(JSON.parse(accountsRaw) as AccountStorageV3); - const flaggedRaw = await fsPromises.readFile(flaggedBackupPath, "utf-8"); - await saveFlaggedAccounts(JSON.parse(flaggedRaw) as { version: 1; accounts: FlaggedAccountMetadataV1[] }); + const backupRaw = await fsPromises.readFile(backupPath, "utf-8"); + const parsed = JSON.parse(backupRaw) as { + accounts?: unknown; + flagged?: unknown; + }; + const normalizedAccounts = normalizeAccountStorage(parsed.accounts); + if (!normalizedAccounts) { + throw new Error("Prune backup account snapshot failed validation."); + } + const flaggedSnapshot = parsed.flagged; + if ( + !flaggedSnapshot || + typeof flaggedSnapshot !== "object" || + (flaggedSnapshot as { version?: unknown }).version !== 1 || + !Array.isArray((flaggedSnapshot as { accounts?: unknown }).accounts) + ) { + throw new Error("Prune backup flagged snapshot failed validation."); + } + const liveAccountsBeforeRestore = + (await loadAccounts()) ?? + ({ + version: 3, + accounts: [], + activeIndex: 0, + activeIndexByFamily: {}, + } satisfies AccountStorageV3); + try { + await saveAccounts(normalizedAccounts); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + throw new Error(`Failed to restore account storage from prune backup: ${message}`); + } + try { + await saveFlaggedAccounts( + flaggedSnapshot as { version: 1; accounts: FlaggedAccountMetadataV1[] }, + ); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + try { + await saveAccounts(liveAccountsBeforeRestore); + } catch (rollbackError) { + const rollbackMessage = + rollbackError instanceof Error ? rollbackError.message : String(rollbackError); + throw new Error( + `Failed to restore flagged storage from prune backup: ${message}. Account-store rollback also failed: ${rollbackMessage}`, + ); + } + throw new Error( + `Failed to restore flagged storage from prune backup: ${message}. Account-store changes were rolled back.`, + ); + } invalidateAccountManagerCache(); }, }; @@ -4043,8 +4104,7 @@ while (attempted.size < Math.max(1, accountCount)) { let pruneBackup: | { - accountsBackupPath: string; - flaggedBackupPath: string; + backupPath: string; restore: () => Promise; } | null = null; @@ -4109,6 +4169,9 @@ while (attempted.size < Math.max(1, accountCount)) { console.log(`Skipped: ${result.skipped}`); console.log(`Total: ${result.total}`); console.log(`Auto-backup: ${backupLabel}`); + if (result.tempCleanupWarning) { + console.log(result.tempCleanupWarning); + } console.log(""); return; } catch (error) { @@ -4173,7 +4236,9 @@ while (attempted.size < Math.max(1, accountCount)) { console.log("Sync cancelled.\n"); return; } - pruneBackup = await createSyncPruneBackup(); + if (!pruneBackup) { + pruneBackup = await createSyncPruneBackup(); + } await removeAccountsForSync(removalPlan.targets); continue; } diff --git a/lib/codex-multi-auth-sync.ts b/lib/codex-multi-auth-sync.ts index 5e4dbcd2..bc92532a 100644 --- a/lib/codex-multi-auth-sync.ts +++ b/lib/codex-multi-auth-sync.ts @@ -40,6 +40,8 @@ export interface CodexMultiAuthSyncResult extends CodexMultiAuthSyncPreview { backupStatus: ImportAccountsResult["backupStatus"]; backupPath?: string; backupError?: string; + tempCleanupWarning?: string; + tempCleanupPath?: string; } export interface CodexMultiAuthCleanupResult { @@ -111,6 +113,7 @@ function normalizeSourceStorage(storage: AccountStorageV3): AccountStorageV3 { type NormalizedImportFileOptions = { postSuccessCleanupFailureMode?: "throw" | "warn"; + onPostSuccessCleanupFailure?: (details: { tempDir: string; tempPath: string; message: string }) => void; }; async function withNormalizedImportFile( @@ -143,6 +146,7 @@ async function withNormalizedImportFile( } catch (cleanupError) { const message = cleanupError instanceof Error ? cleanupError.message : String(cleanupError); logWarn(`Failed to remove temporary codex sync directory ${tempDir}: ${message}`); + options.onPostSuccessCleanupFailure?.({ tempDir, tempPath, message }); if (options.postSuccessCleanupFailureMode !== "warn") { throw new Error(`Failed to remove temporary codex sync directory ${tempDir}: ${message}`); } @@ -755,6 +759,13 @@ export async function syncFromCodexMultiAuth( ): Promise { const resolved = await loadPreparedCodexMultiAuthSourceStorage(projectPath); await assertSyncWithinCapacity(resolved); + let tempCleanupFailure: + | { + tempDir: string; + tempPath: string; + message: string; + } + | undefined; const result: ImportAccountsResult = await withNormalizedImportFile( tagSyncedAccounts(resolved.storage), (filePath) => { @@ -791,7 +802,12 @@ export async function syncFromCodexMultiAuth( }, ); }, - { postSuccessCleanupFailureMode: "warn" }, + { + postSuccessCleanupFailureMode: "warn", + onPostSuccessCleanupFailure: (details) => { + tempCleanupFailure = details; + }, + }, ); return { rootDir: resolved.rootDir, @@ -803,6 +819,10 @@ export async function syncFromCodexMultiAuth( imported: result.imported, skipped: result.skipped, total: result.total, + tempCleanupWarning: tempCleanupFailure + ? `Sensitive sync temp data could not be removed automatically. Delete ${tempCleanupFailure.tempPath} after the file lock clears.` + : undefined, + tempCleanupPath: tempCleanupFailure?.tempPath, }; } diff --git a/test/codex-multi-auth-sync.test.ts b/test/codex-multi-auth-sync.test.ts index b287d488..faadf059 100644 --- a/test/codex-multi-auth-sync.test.ts +++ b/test/codex-multi-auth-sync.test.ts @@ -1027,6 +1027,10 @@ describe("codex-multi-auth sync", () => { skipped: 0, total: 4, backupStatus: "created", + tempCleanupWarning: expect.stringContaining( + "Sensitive sync temp data could not be removed automatically", + ), + tempCleanupPath: expect.stringContaining("accounts.json"), }); expect(vi.mocked(loggerModule.logWarn)).toHaveBeenCalledWith( expect.stringContaining("Failed to remove temporary codex sync directory"), diff --git a/test/index.test.ts b/test/index.test.ts index 962197c9..466a9882 100644 --- a/test/index.test.ts +++ b/test/index.test.ts @@ -317,6 +317,7 @@ vi.mock("../lib/storage.js", () => ({ loadFlaggedAccounts: vi.fn(async () => ({ version: 1, accounts: [] })), saveFlaggedAccounts: vi.fn(async () => {}), clearFlaggedAccounts: vi.fn(async () => {}), + normalizeAccountStorage: vi.fn((value: unknown) => value), StorageError: class StorageError extends Error { hint: string; constructor(message: string, hint: string) { From 9f58645c8710aa6510dade8e0182f95b72bbbaf6 Mon Sep 17 00:00:00 2001 From: ndycode Date: Sun, 8 Mar 2026 02:15:51 +0800 Subject: [PATCH 38/81] fix: tighten windows sync and config safety Preserve legacy Windows project storage keys, harden stale config-lock reclaim against replacement races, redact capture-script hotkeys, and move sync prune account removal onto an atomic storage transaction. Co-authored-by: Codex --- index.ts | 68 ++++++++++++++++++------------- lib/config.ts | 65 ++++++++++++++++++++++------- lib/storage/paths.ts | 6 +-- scripts/capture-tui-input.js | 2 +- test/paths.test.ts | 5 +++ test/plugin-config.race.test.ts | 72 +++++++++++++++++++++++++++++++++ test/plugin-config.test.ts | 5 ++- 7 files changed, 177 insertions(+), 46 deletions(-) diff --git a/index.ts b/index.ts index d19b4a43..f7a6223f 100644 --- a/index.ts +++ b/index.ts @@ -3958,24 +3958,29 @@ while (attempted.size < Math.max(1, accountCount)) { }; }; - const removeAccountsForSync = async ( - targets: SyncRemovalTarget[], - ): Promise => { + const removeAccountsForSync = async ( + targets: SyncRemovalTarget[], + ): Promise => { + const currentFlaggedStorage = await loadFlaggedAccounts(); + const targetKeySet = new Set( + targets + .filter((target) => typeof target.refreshToken === "string" && target.refreshToken.length > 0) + .map((target) => getSyncRemovalTargetKey(target)), + ); + let removedTargets: Array<{ + index: number; + account: AccountStorageV3["accounts"][number]; + }> = []; + await withAccountStorageTransaction(async (loadedStorage, persist) => { const currentStorage = - (await loadAccounts()) ?? + loadedStorage ?? ({ version: 3, accounts: [], activeIndex: 0, activeIndexByFamily: {}, } satisfies AccountStorageV3); - const currentFlaggedStorage = await loadFlaggedAccounts(); - const targetKeySet = new Set( - targets - .filter((target) => typeof target.refreshToken === "string" && target.refreshToken.length > 0) - .map((target) => getSyncRemovalTargetKey(target)), - ); - const removedTargets = currentStorage.accounts + removedTargets = currentStorage.accounts .map((account, index) => ({ index, account })) .filter((entry) => entry.account && @@ -4044,22 +4049,31 @@ while (attempted.size < Math.max(1, accountCount)) { remappedFamilyIndex >= 0 ? remappedFamilyIndex : currentStorage.activeIndex; } clampActiveIndices(currentStorage); - await saveAccounts(currentStorage); - const removedRefreshTokens = new Set( - removedTargets.map((entry) => entry.account?.refreshToken).filter((token): token is string => Boolean(token)), - ); - await saveFlaggedAccounts({ - version: 1, - accounts: currentFlaggedStorage.accounts.filter( - (flagged) => !removedRefreshTokens.has(flagged.refreshToken), - ), - }); - invalidateAccountManagerCache(); - const removedLabels = removedTargets - .map((entry) => entry.account?.email ?? `Account ${entry.index + 1}`) - .join(", "); - console.log(`\nRemoved ${removedTargets.length} account(s): ${removedLabels}\n`); - }; + await persist(currentStorage); + }); + if (removedTargets.length === 0) { + return; + } + const removedRefreshTokens = new Set( + removedTargets.map((entry) => entry.account?.refreshToken).filter((token): token is string => Boolean(token)), + ); + await saveFlaggedAccounts({ + version: 1, + accounts: currentFlaggedStorage.accounts.filter( + (flagged) => !removedRefreshTokens.has(flagged.refreshToken), + ), + }); + invalidateAccountManagerCache(); + const removedLabels = removedTargets + .map((entry) => { + const accountId = entry.account?.accountId?.trim(); + return accountId + ? `Account ${entry.index + 1} [${accountId.slice(-6)}]` + : `Account ${entry.index + 1}`; + }) + .join(", "); + console.log(`\nRemoved ${removedTargets.length} account(s): ${removedLabels}\n`); + }; const buildSyncRemovalPlan = async (indexes: number[]): Promise<{ previewLines: string[]; diff --git a/lib/config.ts b/lib/config.ts index 10a58fcd..744c813f 100644 --- a/lib/config.ts +++ b/lib/config.ts @@ -215,6 +215,54 @@ function isProcessAlive(pid: number): boolean { } } +function tryRecoverStalePluginConfigLock(rawLockContents: string): boolean { + const lockOwnerPid = Number.parseInt(rawLockContents.trim(), 10); + if ( + !Number.isFinite(lockOwnerPid) || + lockOwnerPid === process.pid || + isProcessAlive(lockOwnerPid) + ) { + return false; + } + + const staleLockPath = `${CONFIG_LOCK_PATH}.${lockOwnerPid}.${process.pid}.${Date.now()}.stale`; + try { + renameSync(CONFIG_LOCK_PATH, staleLockPath); + } catch { + return false; + } + + try { + const movedLockContents = readFileSync(staleLockPath, "utf-8"); + if (movedLockContents !== rawLockContents) { + try { + if (!existsSync(CONFIG_LOCK_PATH)) { + renameSync(staleLockPath, CONFIG_LOCK_PATH); + } + } catch { + // best effort restore when a live lock was moved unexpectedly + } + return false; + } + } catch { + try { + if (!existsSync(CONFIG_LOCK_PATH)) { + renameSync(staleLockPath, CONFIG_LOCK_PATH); + } + } catch { + // best effort restore when stale-lock verification fails + } + return false; + } + + try { + unlinkSync(staleLockPath); + } catch { + // best effort stale-lock cleanup + } + return true; +} + function withPluginConfigLock(fn: () => T): T { mkdirSync(dirname(CONFIG_PATH), { recursive: true }); const deadline = Date.now() + 2_000; @@ -225,25 +273,14 @@ function withPluginConfigLock(fn: () => T): T { } catch (error) { const code = (error as NodeJS.ErrnoException).code; const retryableLockError = - code === "EEXIST" || (process.platform === "win32" && code === "EPERM"); + code === "EEXIST" || (process.platform === "win32" && (code === "EPERM" || code === "EBUSY")); if (!retryableLockError || Date.now() >= deadline) { throw error; } if (code === "EEXIST") { try { - const lockOwnerPid = Number.parseInt(readFileSync(CONFIG_LOCK_PATH, "utf-8").trim(), 10); - if ( - Number.isFinite(lockOwnerPid) && - lockOwnerPid !== process.pid && - !isProcessAlive(lockOwnerPid) - ) { - const staleLockPath = `${CONFIG_LOCK_PATH}.${lockOwnerPid}.${process.pid}.${Date.now()}.stale`; - renameSync(CONFIG_LOCK_PATH, staleLockPath); - try { - unlinkSync(staleLockPath); - } catch { - // best effort stale-lock cleanup - } + const rawLockContents = readFileSync(CONFIG_LOCK_PATH, "utf-8"); + if (tryRecoverStalePluginConfigLock(rawLockContents)) { continue; } } catch { diff --git a/lib/storage/paths.ts b/lib/storage/paths.ts index 0dcb3278..456e4da4 100644 --- a/lib/storage/paths.ts +++ b/lib/storage/paths.ts @@ -97,15 +97,15 @@ export function getProjectStorageKeyCandidates(projectPath: string): string[] { const normalizedProjectPath = normalizeProjectPath(projectPath); const canonicalIdentity = getCanonicalProjectStorageIdentity(projectPath); const candidates = [ - buildProjectStorageKey(canonicalIdentity.projectNamePath, canonicalIdentity.identityPath), - buildProjectStorageKey(projectPath, normalizedProjectPath), + buildProjectStorageKey(normalizeProjectPath(canonicalIdentity.projectNamePath), canonicalIdentity.identityPath), + buildProjectStorageKey(normalizedProjectPath, normalizedProjectPath), ]; return Array.from(new Set(candidates)); } export function getProjectStorageKey(projectPath: string): string { const normalizedPath = normalizeProjectPath(projectPath); - return buildProjectStorageKey(projectPath, normalizedPath); + return buildProjectStorageKey(normalizedPath, normalizedPath); } /** diff --git a/scripts/capture-tui-input.js b/scripts/capture-tui-input.js index 815087ed..386e4ed9 100644 --- a/scripts/capture-tui-input.js +++ b/scripts/capture-tui-input.js @@ -56,7 +56,7 @@ const logEvent = (event) => { function sanitizeAuditValue(key, value) { if (typeof value === "string") { - if (["utf8", "bytesHex", "token", "normalizedInput", "pending", "token"].includes(key)) { + if (["utf8", "bytesHex", "token", "normalizedInput", "pending", "hotkey"].includes(key)) { return `[redacted:${value.length}]`; } if (value.includes("@")) { diff --git a/test/paths.test.ts b/test/paths.test.ts index 9852fb68..7705d641 100644 --- a/test/paths.test.ts +++ b/test/paths.test.ts @@ -62,6 +62,11 @@ describe("Storage Paths Module", () => { expect(first).toBe(second); expect(first).toMatch(/^myproject-[a-f0-9]{12}$/); }); + + it("preserves the legacy lowercase key prefix on Windows paths", () => { + const projectPath = "C:\\Users\\Test\\MyProject"; + expect(getProjectStorageKey(projectPath)).toMatch(/^myproject-[a-f0-9]{12}$/); + }); }); describe("getProjectStorageKeyCandidates", () => { diff --git a/test/plugin-config.race.test.ts b/test/plugin-config.race.test.ts index c348d527..7efcdb46 100644 --- a/test/plugin-config.race.test.ts +++ b/test/plugin-config.race.test.ts @@ -1,5 +1,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import * as fs from "node:fs"; +import * as os from "node:os"; +import * as path from "node:path"; import * as logger from "../lib/logger.js"; vi.mock("node:fs", async () => { @@ -77,4 +79,74 @@ describe("plugin config lock retry", () => { expect(mockWriteFileSync).toHaveBeenCalled(); expect(vi.mocked(logger.logWarn)).not.toHaveBeenCalled(); }); + + it("does not steal a live lock that replaced a stale one before rename", async () => { + Object.defineProperty(process, "platform", { value: "win32" }); + const configPath = path.join(os.homedir(), ".opencode", "openai-codex-auth-config.json"); + const lockPath = `${configPath}.lock`; + let lockAttempts = 0; + let lockFilePresent = true; + const killSpy = vi.spyOn(process, "kill").mockImplementation((pid) => { + if (pid === 111) { + const error = new Error("process not found") as NodeJS.ErrnoException; + error.code = "ESRCH"; + throw error; + } + return true as never; + }); + + mockExistsSync.mockImplementation((filePath) => String(filePath) === lockPath && lockFilePresent); + mockReadFileSync.mockImplementation((filePath: fs.PathOrFileDescriptor) => { + const path = String(filePath); + if (path === lockPath) { + return lockAttempts === 1 ? "111" : "{}"; + } + if (path.includes(".stale")) { + return "222"; + } + return "{}"; + }); + mockRenameSync.mockImplementation((source, destination) => { + if (String(source) === lockPath) { + lockFilePresent = false; + } + if (String(destination) === lockPath) { + lockFilePresent = true; + } + return undefined; + }); + mockWriteFileSync.mockImplementation((filePath) => { + const path = String(filePath); + if (path === lockPath) { + lockAttempts += 1; + if (lockAttempts === 1) { + const error = new Error("exists") as NodeJS.ErrnoException; + error.code = "EEXIST"; + throw error; + } + } + return undefined; + }); + + const { savePluginConfigMutation } = await import("../lib/config.js"); + + try { + expect(() => + savePluginConfigMutation((current) => ({ + ...current, + experimental: { syncFromCodexMultiAuth: { enabled: true } }, + })), + ).not.toThrow(); + const lockRenameCalls = mockRenameSync.mock.calls.filter( + ([source, destination]) => + String(source) === lockPath || String(destination) === lockPath, + ); + expect(lockRenameCalls).toHaveLength(2); + expect(String(lockRenameCalls[0]?.[0])).toBe(lockPath); + expect(String(lockRenameCalls[1]?.[1])).toBe(lockPath); + expect(killSpy).toHaveBeenCalledWith(111, 0); + } finally { + killSpy.mockRestore(); + } + }); }); diff --git a/test/plugin-config.test.ts b/test/plugin-config.test.ts index 7cfa5ea4..73437cad 100644 --- a/test/plugin-config.test.ts +++ b/test/plugin-config.test.ts @@ -862,6 +862,9 @@ describe('Plugin Configuration', () => { if (String(filePath) === lockPath) { return '424242'; } + if (String(filePath).includes('.stale')) { + return '424242'; + } return JSON.stringify({ codexMode: false }); }); mockWriteFileSync.mockImplementation((filePath: fs.PathOrFileDescriptor) => { @@ -875,7 +878,7 @@ describe('Plugin Configuration', () => { try { expect(() => setSyncFromCodexMultiAuthEnabled(true)).not.toThrow(); - expect(mockUnlinkSync).toHaveBeenCalledWith(lockPath); + expect(mockUnlinkSync).toHaveBeenCalledWith(expect.stringContaining('.stale')); expect(killSpy).toHaveBeenCalledWith(424242, 0); expect(mockRenameSync).toHaveBeenCalled(); } finally { From 8e4faa9071d1649891ec99c49f2f446aeb457a30 Mon Sep 17 00:00:00 2001 From: ndycode Date: Sun, 8 Mar 2026 02:20:47 +0800 Subject: [PATCH 39/81] fix: make config lock retries non-blocking Convert plugin config lock contention handling to async waits so Windows lock retries no longer freeze the event loop, and update the config mutation call sites and regression tests accordingly. Co-authored-by: Codex --- index.ts | 6 +++--- lib/config.ts | 20 ++++++++++---------- test/plugin-config.race.test.ts | 8 ++++---- test/plugin-config.test.ts | 22 +++++++++++----------- 4 files changed, 28 insertions(+), 28 deletions(-) diff --git a/index.ts b/index.ts index f7a6223f..affcb016 100644 --- a/index.ts +++ b/index.ts @@ -3840,11 +3840,11 @@ while (attempted.size < Math.max(1, accountCount)) { } }; - const toggleCodexMultiAuthSyncSetting = (): void => { + const toggleCodexMultiAuthSyncSetting = async (): Promise => { try { const currentConfig = loadPluginConfig(); const enabled = getSyncFromCodexMultiAuthEnabled(currentConfig); - setSyncFromCodexMultiAuthEnabled(!enabled); + await setSyncFromCodexMultiAuthEnabled(!enabled); const nextLabel = !enabled ? "enabled" : "disabled"; console.log(`\nSync from codex-multi-auth ${nextLabel}.\n`); } catch (error) { @@ -4641,7 +4641,7 @@ while (attempted.size < Math.max(1, accountCount)) { continue; } if (menuResult.mode === "experimental-toggle-sync") { - toggleCodexMultiAuthSyncSetting(); + await toggleCodexMultiAuthSyncSetting(); continue; } if (menuResult.mode === "experimental-sync-now") { diff --git a/lib/config.ts b/lib/config.ts index 744c813f..76caf1eb 100644 --- a/lib/config.ts +++ b/lib/config.ts @@ -131,11 +131,11 @@ function readRawPluginConfig(recoverInvalid = false): RawPluginConfig { } } -export function savePluginConfigMutation( +export async function savePluginConfigMutation( mutate: (current: RawPluginConfig) => RawPluginConfig, options: { recoverInvalidCurrent?: boolean } = {}, -): void { - withPluginConfigLock(() => { +): Promise { + await withPluginConfigLock(() => { const current = readRawPluginConfig(options.recoverInvalidCurrent === true); const next = mutate({ ...current }); @@ -201,8 +201,8 @@ function isRecord(value: unknown): value is Record { return value !== null && typeof value === "object" && !Array.isArray(value); } -function sleepSync(ms: number): void { - Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms); +function sleepAsync(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); } function isProcessAlive(pid: number): boolean { @@ -263,7 +263,7 @@ function tryRecoverStalePluginConfigLock(rawLockContents: string): boolean { return true; } -function withPluginConfigLock(fn: () => T): T { +async function withPluginConfigLock(fn: () => T | Promise): Promise { mkdirSync(dirname(CONFIG_PATH), { recursive: true }); const deadline = Date.now() + 2_000; while (true) { @@ -287,12 +287,12 @@ function withPluginConfigLock(fn: () => T): T { // best effort stale-lock recovery } } - sleepSync(25); + await sleepAsync(25); } } try { - return fn(); + return await fn(); } finally { try { unlinkSync(CONFIG_LOCK_PATH); @@ -390,8 +390,8 @@ export function getSyncFromCodexMultiAuthEnabled(pluginConfig: PluginConfig): bo return pluginConfig.experimental?.syncFromCodexMultiAuth?.enabled === true; } -export function setSyncFromCodexMultiAuthEnabled(enabled: boolean): void { - savePluginConfigMutation((current) => { +export async function setSyncFromCodexMultiAuthEnabled(enabled: boolean): Promise { + await savePluginConfigMutation((current) => { const experimental = isRecord(current.experimental) ? { ...current.experimental } : {}; const syncSettings = isRecord(experimental.syncFromCodexMultiAuth) ? { ...experimental.syncFromCodexMultiAuth } diff --git a/test/plugin-config.race.test.ts b/test/plugin-config.race.test.ts index 7efcdb46..24a56b4c 100644 --- a/test/plugin-config.race.test.ts +++ b/test/plugin-config.race.test.ts @@ -68,12 +68,12 @@ describe("plugin config lock retry", () => { const { savePluginConfigMutation } = await import("../lib/config.js"); - expect(() => + await expect( savePluginConfigMutation((current) => ({ ...current, experimental: { syncFromCodexMultiAuth: { enabled: true } }, })), - ).not.toThrow(); + ).resolves.toBeUndefined(); expect(lockAttempts).toBeGreaterThanOrEqual(2); expect(mockWriteFileSync).toHaveBeenCalled(); @@ -131,12 +131,12 @@ describe("plugin config lock retry", () => { const { savePluginConfigMutation } = await import("../lib/config.js"); try { - expect(() => + await expect( savePluginConfigMutation((current) => ({ ...current, experimental: { syncFromCodexMultiAuth: { enabled: true } }, })), - ).not.toThrow(); + ).resolves.toBeUndefined(); const lockRenameCalls = mockRenameSync.mock.calls.filter( ([source, destination]) => String(source) === lockPath || String(destination) === lockPath, diff --git a/test/plugin-config.test.ts b/test/plugin-config.test.ts index 73437cad..b4be5f37 100644 --- a/test/plugin-config.test.ts +++ b/test/plugin-config.test.ts @@ -775,7 +775,7 @@ describe('Plugin Configuration', () => { ).toBe(true); }); - it('persists sync-from-codex-multi-auth while preserving unrelated keys', () => { + it('persists sync-from-codex-multi-auth while preserving unrelated keys', async () => { mockExistsSync.mockReturnValue(true); mockReadFileSync.mockReturnValue( JSON.stringify({ @@ -809,10 +809,10 @@ describe('Plugin Configuration', () => { ); }); - it('creates a new config file when enabling sync on a missing config', () => { + it('creates a new config file when enabling sync on a missing config', async () => { mockExistsSync.mockReturnValue(false); - setSyncFromCodexMultiAuthEnabled(true); + await setSyncFromCodexMultiAuthEnabled(true); const [, writtenContent] = mockWriteFileSync.mock.calls[1] ?? []; expect(JSON.parse(String(writtenContent))).toEqual({ @@ -824,32 +824,32 @@ describe('Plugin Configuration', () => { }); }); - it('throws when mutating an invalid existing config file to avoid clobbering it', () => { + it('throws when mutating an invalid existing config file to avoid clobbering it', async () => { mockExistsSync.mockReturnValue(true); mockReadFileSync.mockReturnValue('invalid json'); - expect(() => savePluginConfigMutation((current) => current)).toThrow(); + await expect(savePluginConfigMutation((current) => current)).rejects.toThrow(); expect(mockRenameSync).not.toHaveBeenCalled(); }); - it('rejects array roots when reading raw plugin config', () => { + it('rejects array roots when reading raw plugin config', async () => { mockExistsSync.mockReturnValue(true); mockReadFileSync.mockReturnValue('[]'); - expect(() => savePluginConfigMutation((current) => current)).toThrow( + await expect(savePluginConfigMutation((current) => current)).rejects.toThrow( 'Plugin config root must be a JSON object', ); }); - it('throws when toggling sync setting on malformed config to preserve existing settings', () => { + it('throws when toggling sync setting on malformed config to preserve existing settings', async () => { mockExistsSync.mockReturnValue(true); mockReadFileSync.mockReturnValue('invalid json'); - expect(() => setSyncFromCodexMultiAuthEnabled(true)).toThrow(); + await expect(setSyncFromCodexMultiAuthEnabled(true)).rejects.toThrow(); expect(mockRenameSync).not.toHaveBeenCalled(); }); - it('recovers stale config lock files before mutating config', () => { + it('recovers stale config lock files before mutating config', async () => { const configPath = path.join(os.homedir(), '.opencode', 'openai-codex-auth-config.json'); const lockPath = `${configPath}.lock`; const killSpy = vi.spyOn(process, 'kill').mockImplementation(() => { @@ -877,7 +877,7 @@ describe('Plugin Configuration', () => { }); try { - expect(() => setSyncFromCodexMultiAuthEnabled(true)).not.toThrow(); + await expect(setSyncFromCodexMultiAuthEnabled(true)).resolves.toBeUndefined(); expect(mockUnlinkSync).toHaveBeenCalledWith(expect.stringContaining('.stale')); expect(killSpy).toHaveBeenCalledWith(424242, 0); expect(mockRenameSync).toHaveBeenCalled(); From 6f012571732f3a112dc699fef0b7b8602a17e023 Mon Sep 17 00:00:00 2001 From: ndycode Date: Sun, 8 Mar 2026 06:53:57 +0800 Subject: [PATCH 40/81] docs: align account capacity note with runtime Co-authored-by: Codex --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 37695618..c9d5a735 100644 --- a/README.md +++ b/README.md @@ -11,7 +11,7 @@ OAuth plugin for OpenCode that lets you use ChatGPT Plus/Pro rate limits with mo ## What You Get - **GPT-5.4, GPT-5 Codex, GPT-5.1 Codex Max** and all GPT-5.x variants via ChatGPT OAuth -- **Multi-account support** — Add up to 20 ChatGPT accounts, health-aware rotation with automatic failover +- **Multi-account support** — Add as many ChatGPT accounts as you need, health-aware rotation with automatic failover - **Per-project accounts** — Each project gets its own account storage (new in v4.10.0) - **Workspace-aware identity persistence** — Keeps workspace/org identity stable across token refresh and verify-flagged restore flows - **Click-to-switch** — Switch accounts directly from the OpenCode TUI From c6aa1977fe440e04c1187290ea4f9cb2bfcac89b Mon Sep 17 00:00:00 2001 From: ndycode Date: Sun, 8 Mar 2026 07:31:28 +0800 Subject: [PATCH 41/81] fix: close remaining sync review blockers Harden codex-multi-auth sync cleanup, align source discovery and email filtering with the PR contract, and preserve raw-state maintenance backups with updated regressions. Co-authored-by: Codex --- index.ts | 14 +- lib/codex-multi-auth-sync.ts | 260 ++++++++++++++++++----------- lib/storage.ts | 46 +++-- test/codex-multi-auth-sync.test.ts | 147 +++++++++++++--- test/index.test.ts | 54 +++++- 5 files changed, 384 insertions(+), 137 deletions(-) diff --git a/index.ts b/index.ts index affcb016..cda44944 100644 --- a/index.ts +++ b/index.ts @@ -26,6 +26,7 @@ import { tool } from "@opencode-ai/plugin/tool"; import { promises as fsPromises } from "node:fs"; import { createInterface } from "node:readline/promises"; +import { dirname, join } from "node:path"; import type { Plugin, PluginInput } from "@opencode-ai/plugin"; import type { Auth } from "@opencode-ai/sdk"; import { @@ -117,6 +118,7 @@ import { previewDuplicateEmailCleanup, clearAccounts, setStoragePath, + backupRawAccountsFile, exportAccounts, importAccounts, previewImportAccounts, @@ -194,6 +196,7 @@ import { import { CodexMultiAuthSyncCapacityError, cleanupCodexMultiAuthSyncedOverlaps, + isCodexMultiAuthSourceTooLargeForCapacity, previewCodexMultiAuthSyncedOverlapCleanup, previewSyncFromCodexMultiAuth, syncFromCodexMultiAuth, @@ -3857,7 +3860,7 @@ while (attempted.size < Math.max(1, accountCount)) { prefix: string, ): Promise => { const backupPath = createTimestampedBackupPath(prefix); - await exportAccounts(backupPath, true); + await backupRawAccountsFile(backupPath, true); return backupPath; }; @@ -3882,6 +3885,7 @@ while (attempted.size < Math.max(1, accountCount)) { } satisfies AccountStorageV3); const currentFlaggedStorage = await loadFlaggedAccounts(); const backupPath = createTimestampedBackupPath("codex-sync-prune-backup"); + await fsPromises.mkdir(dirname(backupPath), { recursive: true }); const backupPayload = { version: 1 as const, accounts: { @@ -4201,6 +4205,14 @@ while (attempted.size < Math.max(1, accountCount)) { console.log(`Overlap accounts skipped by dedupe: ${details.skippedOverlaps}`); 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. Reduce the source set or raise CODEX_AUTH_SYNC_MAX_ACCOUNTS before retrying.`, + ); + console.log(""); + return; + } console.log(`Remove at least ${details.needToRemove} account(s) first.`); if (details.suggestedRemovals.length > 0) { console.log("Suggested removals:"); diff --git a/lib/codex-multi-auth-sync.ts b/lib/codex-multi-auth-sync.ts index bc92532a..f726052d 100644 --- a/lib/codex-multi-auth-sync.ts +++ b/lib/codex-multi-auth-sync.ts @@ -4,12 +4,14 @@ import { join, win32 } from "node:path"; import { ACCOUNT_LIMITS } from "./constants.js"; import { logWarn } from "./logger.js"; import { + clearAccounts, deduplicateAccounts, deduplicateAccountsByEmail, getStoragePath, importAccounts, normalizeAccountStorage, previewImportAccounts, + saveAccounts, withAccountStorageTransaction, type AccountStorageV3, type ImportAccountsResult, @@ -70,20 +72,6 @@ export interface CodexMultiAuthSyncCapacityDetails extends CodexMultiAuthResolve }>; } -export class CodexMultiAuthSyncCapacityError extends Error { - readonly details: CodexMultiAuthSyncCapacityDetails; - - constructor(details: CodexMultiAuthSyncCapacityDetails) { - super( - `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.`, - ); - this.name = "CodexMultiAuthSyncCapacityError"; - this.details = details; - } -} - function normalizeSourceStorage(storage: AccountStorageV3): AccountStorageV3 { const normalizedAccounts = storage.accounts.map((account) => { const accountId = account.accountId?.trim(); @@ -116,6 +104,38 @@ type NormalizedImportFileOptions = { onPostSuccessCleanupFailure?: (details: { tempDir: string; tempPath: string; message: string }) => void; }; +const TEMP_CLEANUP_RETRY_DELAYS_MS = [100, 250, 500] as const; + +function sleepAsync(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +async function removeNormalizedImportTempDir( + tempDir: string, + tempPath: string, + options: NormalizedImportFileOptions, +): Promise { + 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) { + lastMessage = cleanupError instanceof Error ? cleanupError.message : String(cleanupError); + if (attempt < TEMP_CLEANUP_RETRY_DELAYS_MS.length) { + await sleepAsync(TEMP_CLEANUP_RETRY_DELAYS_MS[attempt] ?? 0); + continue; + } + } + } + + 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}`); + } +} + async function withNormalizedImportFile( storage: AccountStorageV3, handler: (filePath: string) => Promise, @@ -134,23 +154,14 @@ async function withNormalizedImportFile( result = await handler(tempPath); } catch (error) { try { - await fs.rm(tempDir, { recursive: true, force: true }); + 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; } - try { - await fs.rm(tempDir, { recursive: true, force: true }); - } catch (cleanupError) { - const message = cleanupError instanceof Error ? cleanupError.message : String(cleanupError); - logWarn(`Failed to remove temporary codex sync directory ${tempDir}: ${message}`); - options.onPostSuccessCleanupFailure?.({ tempDir, tempPath, message }); - if (options.postSuccessCleanupFailureMode !== "warn") { - throw new Error(`Failed to remove temporary codex sync directory ${tempDir}: ${message}`); - } - } + await removeNormalizedImportTempDir(tempDir, tempPath, options); return result; }; @@ -228,12 +239,12 @@ function buildExistingSyncIdentityState(existingAccounts: AccountStorageV3["acco organizationIds: Set; accountIds: Set; refreshTokens: Set; - legacyEmails: Set; + emails: Set; } { const organizationIds = new Set(); const accountIds = new Set(); const refreshTokens = new Set(); - const legacyEmails = new Set(); + const emails = new Set(); for (const account of existingAccounts) { const organizationId = normalizeIdentity(account.organizationId); @@ -243,16 +254,14 @@ function buildExistingSyncIdentityState(existingAccounts: AccountStorageV3["acco if (organizationId) organizationIds.add(organizationId); if (accountId) accountIds.add(accountId); if (refreshToken) refreshTokens.add(refreshToken); - if (!organizationId && !accountId && email) { - legacyEmails.add(email); - } + if (email) emails.add(email); } return { organizationIds, accountIds, refreshTokens, - legacyEmails, + emails, }; } @@ -265,6 +274,10 @@ 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); @@ -277,9 +290,7 @@ function filterSourceAccountsAgainstExistingEmails( if (refreshToken && existingState.refreshTokens.has(refreshToken)) { return false; } - const normalizedEmail = normalizeIdentity(account.email); - if (!normalizedEmail) return true; - return !existingState.legacyEmails.has(normalizedEmail); + return true; }), }; } @@ -570,6 +581,18 @@ function getCodexHomeDir(): string { 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) { @@ -613,12 +636,7 @@ export function getCodexMultiAuthSourceRootDir(): string { } const userHome = getResolvedUserHomeDir(); - const primary = join(getCodexHomeDir(), EXTERNAL_ROOT_SUFFIX); - const candidates = deduplicatePaths([ - primary, - join(userHome, "DevTools", "config", "codex", EXTERNAL_ROOT_SUFFIX), - join(userHome, ".codex", EXTERNAL_ROOT_SUFFIX), - ]); + const candidates = getCodexMultiAuthRootCandidates(userHome); for (const candidate of candidates) { if (hasAccountsStorage(candidate)) { @@ -632,7 +650,7 @@ export function getCodexMultiAuthSourceRootDir(): string { } } - return primary; + return candidates[0] ?? join(userHome, ".codex", EXTERNAL_ROOT_SUFFIX); } function getProjectScopedAccountsPath(rootDir: string, projectPath: string): string | undefined { @@ -759,56 +777,63 @@ export async function syncFromCodexMultiAuth( ): Promise { const resolved = await loadPreparedCodexMultiAuthSourceStorage(projectPath); await assertSyncWithinCapacity(resolved); - let tempCleanupFailure: - | { - tempDir: string; - tempPath: string; - message: string; - } - | undefined; - 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 ?? [], - ); - if (Number.isFinite(maxAccounts)) { - const details = computeSyncCapacityDetails( - resolved, - filteredStorage, - existing ?? - ({ - version: 3, - accounts: [], - activeIndex: 0, - activeIndexByFamily: {}, - } satisfies AccountStorageV3), - maxAccounts, + const preSyncStorage = await withAccountStorageTransaction((current) => Promise.resolve(current)); + let result: ImportAccountsResult; + try { + result = 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 ?? [], ); - if (details) { - throw new CodexMultiAuthSyncCapacityError(details); + 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", - onPostSuccessCleanupFailure: (details) => { - tempCleanupFailure = details; + return filteredStorage; + }, + ); }, - }, - ); + ); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + if (message.includes("Failed to remove temporary codex sync directory")) { + try { + if (preSyncStorage) { + await saveAccounts(preSyncStorage); + } else { + await clearAccounts(); + } + } catch (rollbackError) { + const rollbackMessage = + rollbackError instanceof Error ? rollbackError.message : String(rollbackError); + throw new Error(`${message}. Sync rollback failed: ${rollbackMessage}`); + } + } + throw error; + } return { rootDir: resolved.rootDir, accountsPath: resolved.accountsPath, @@ -819,10 +844,6 @@ export async function syncFromCodexMultiAuth( imported: result.imported, skipped: result.skipped, total: result.total, - tempCleanupWarning: tempCleanupFailure - ? `Sensitive sync temp data could not be removed automatically. Delete ${tempCleanupFailure.tempPath} after the file lock clears.` - : undefined, - tempCleanupPath: tempCleanupFailure?.tempPath, }; } @@ -971,9 +992,58 @@ async function loadRawCodexMultiAuthOverlapCleanupStorage( try { const raw = await fs.readFile(getStoragePath(), "utf-8"); const parsed = JSON.parse(raw) as unknown; - return normalizeOverlapCleanupSourceStorage(parsed) ?? fallback; - } catch { - return fallback; + 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; + } + 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; } } diff --git a/lib/storage.ts b/lib/storage.ts index 19f5ff50..5defcf41 100644 --- a/lib/storage.ts +++ b/lib/storage.ts @@ -596,23 +596,23 @@ async function loadDuplicateCleanupSourceStorage(): Promise { try { const rawContent = await fs.readFile(getStoragePath(), "utf-8"); const rawData = JSON.parse(rawContent) as unknown; - return normalizeDuplicateCleanupSourceStorage(rawData) ?? fallback ?? { - version: 3, - accounts: [], - activeIndex: 0, - activeIndexByFamily: {}, - }; + const normalized = normalizeDuplicateCleanupSourceStorage(rawData); + if (normalized) { + return normalized; + } + throw new Error("Invalid raw storage snapshot for duplicate cleanup."); } catch (error) { const code = (error as NodeJS.ErrnoException).code; - if (code !== "ENOENT") { - log.warn("Failed to read raw storage snapshot for duplicate cleanup", { error: String(error) }); + if (code === "ENOENT") { + return fallback ?? { + version: 3, + accounts: [], + activeIndex: 0, + activeIndexByFamily: {}, + }; } - return fallback ?? { - version: 3, - accounts: [], - activeIndex: 0, - activeIndexByFamily: {}, - }; + const message = error instanceof Error ? error.message : String(error); + throw new Error(`Failed to read raw storage snapshot for duplicate cleanup: ${message}`); } } @@ -1440,6 +1440,24 @@ export async function exportAccounts(filePath: string, force = true): Promise { + const resolvedPath = resolvePath(filePath); + + if (!force && existsSync(resolvedPath)) { + throw new Error(`File already exists: ${resolvedPath}`); + } + + 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 }); +} + /** * Imports accounts from a JSON file, merging with existing accounts. * Deduplicates by identity key first (organizationId -> accountId -> refreshToken), diff --git a/test/codex-multi-auth-sync.test.ts b/test/codex-multi-auth-sync.test.ts index faadf059..f7501167 100644 --- a/test/codex-multi-auth-sync.test.ts +++ b/test/codex-multi-auth-sync.test.ts @@ -22,6 +22,8 @@ vi.mock("../lib/storage.js", () => ({ deduplicateAccounts: vi.fn((accounts) => accounts), deduplicateAccountsByEmail: vi.fn((accounts) => accounts), getStoragePath: vi.fn(() => "/tmp/opencode-accounts.json"), + saveAccounts: vi.fn(async () => {}), + clearAccounts: vi.fn(async () => {}), previewImportAccounts: vi.fn(async () => ({ imported: 2, skipped: 0, total: 4 })), importAccounts: vi.fn(async () => ({ imported: 2, @@ -163,6 +165,34 @@ describe("codex-multi-auth sync", () => { ); }); + it("prefers the DevTools root over ~/.codex when CODEX_HOME is not set", async () => { + process.env.USERPROFILE = "C:\\Users\\tester"; + process.env.HOME = "C:\\Users\\tester"; + 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"; @@ -303,7 +333,7 @@ describe("codex-multi-auth sync", () => { }), ); - const rmSpy = vi.spyOn(fs.promises, "rm").mockRejectedValueOnce(new Error("cleanup blocked")); + const rmSpy = vi.spyOn(fs.promises, "rm").mockRejectedValue(new Error("cleanup blocked")); const loggerModule = await import("../lib/logger.js"); try { @@ -373,7 +403,7 @@ describe("codex-multi-auth sync", () => { }), ); - const rmSpy = vi.spyOn(fs.promises, "rm").mockRejectedValueOnce(new Error("cleanup blocked")); + const rmSpy = vi.spyOn(fs.promises, "rm").mockRejectedValue(new Error("cleanup blocked")); try { const { previewSyncFromCodexMultiAuth } = await import("../lib/codex-multi-auth-sync.js"); @@ -452,24 +482,20 @@ 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) => { 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, + imported: 1, skipped: 0, - total: 3, + total: 1, backupStatus: "created", backupPath: "/tmp/filtered-sync-backup.json", }; @@ -479,14 +505,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, }); }); @@ -706,6 +732,82 @@ describe("codex-multi-auth sync", () => { ); }); + 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.withAccountStorageTransaction).mockImplementationOnce(async (handler) => + handler( + { + version: 3, + activeIndex: 0, + activeIndexByFamily: {}, + accounts: [], + }, + vi.fn(async () => {}), + ), + ); + + 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) => @@ -1003,7 +1105,7 @@ describe("codex-multi-auth sync", () => { }); }); - it("returns sync results even if temporary import cleanup fails after apply", async () => { + it("fails sync when temporary import cleanup cannot remove sensitive data after apply", 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"); @@ -1017,24 +1119,19 @@ describe("codex-multi-auth sync", () => { accounts: [{ refreshToken: "sync-refresh", addedAt: 1, lastUsed: 1 }], }), ); - const rmSpy = vi.spyOn(fs.promises, "rm").mockRejectedValueOnce(new Error("rm failed")); + 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({ - imported: 2, - skipped: 0, - total: 4, - backupStatus: "created", - tempCleanupWarning: expect.stringContaining( - "Sensitive sync temp data could not be removed automatically", - ), - tempCleanupPath: expect.stringContaining("accounts.json"), - }); + await expect(syncFromCodexMultiAuth(process.cwd())).rejects.toThrow( + /Failed to remove temporary codex sync directory/, + ); expect(vi.mocked(loggerModule.logWarn)).toHaveBeenCalledWith( expect.stringContaining("Failed to remove temporary codex sync directory"), ); + expect(vi.mocked(storageModule.saveAccounts)).toHaveBeenCalledTimes(1); } finally { rmSpy.mockRestore(); } diff --git a/test/index.test.ts b/test/index.test.ts index 466a9882..434cf0e5 100644 --- a/test/index.test.ts +++ b/test/index.test.ts @@ -219,6 +219,17 @@ vi.mock("../lib/codex-multi-auth-sync.js", () => ({ removed: 0, updated: 0, })), + isCodexMultiAuthSourceTooLargeForCapacity: vi.fn( + (details: { sourceDedupedTotal?: number; maxAccounts?: number; importableNewAccounts?: number; suggestedRemovals?: unknown[] }) => + Boolean( + typeof details.sourceDedupedTotal === "number" && + typeof details.maxAccounts === "number" && + details.sourceDedupedTotal > details.maxAccounts && + details.importableNewAccounts === 0 && + Array.isArray(details.suggestedRemovals) && + details.suggestedRemovals.length === 0, + ), + ), })); vi.mock("../lib/request/rate-limit-backoff.js", () => ({ @@ -304,6 +315,7 @@ vi.mock("../lib/storage.js", () => ({ })), clearAccounts: vi.fn(async () => {}), setStoragePath: vi.fn(), + backupRawAccountsFile: vi.fn(async () => {}), exportAccounts: vi.fn(async () => {}), importAccounts: vi.fn(async () => ({ imported: 2, @@ -2794,7 +2806,7 @@ describe("OpenAIOAuthPlugin persistAccountPool", () => { expect(vi.mocked(storageModule.createTimestampedBackupPath)).toHaveBeenCalledWith( "codex-maintenance-duplicate-email-backup", ); - expect(vi.mocked(storageModule.exportAccounts)).toHaveBeenCalledWith( + expect(vi.mocked(storageModule.backupRawAccountsFile)).toHaveBeenCalledWith( "/tmp/codex-maintenance-duplicate-email-backup-20260101-000000.json", true, ); @@ -2862,7 +2874,7 @@ describe("OpenAIOAuthPlugin persistAccountPool", () => { expect(vi.mocked(storageModule.createTimestampedBackupPath)).toHaveBeenCalledWith( "codex-maintenance-overlap-backup", ); - expect(vi.mocked(storageModule.exportAccounts)).toHaveBeenCalledWith( + expect(vi.mocked(storageModule.backupRawAccountsFile)).toHaveBeenCalledWith( "/tmp/codex-maintenance-overlap-backup-20260101-000000.json", true, ); @@ -3110,6 +3122,44 @@ describe("OpenAIOAuthPlugin persistAccountPool", () => { } }); + it("does not prompt for local pruning when the sync source alone exceeds the configured limit", async () => { + const cliModule = await import("../lib/cli.js"); + const syncModule = await import("../lib/codex-multi-auth-sync.js"); + const configModule = await import("../lib/config.js"); + + vi.mocked(cliModule.promptLoginMode) + .mockResolvedValueOnce({ mode: "experimental-sync-now" }); + vi.mocked(configModule.getSyncFromCodexMultiAuthEnabled).mockReturnValue(true); + + const capacityError = new syncModule.CodexMultiAuthSyncCapacityError({ + rootDir: "/tmp/codex-root", + accountsPath: "/tmp/codex-root/openai-codex-accounts.json", + scope: "global", + currentCount: 0, + sourceCount: 3, + sourceDedupedTotal: 3, + dedupedTotal: 3, + maxAccounts: 2, + needToRemove: 1, + importableNewAccounts: 0, + skippedOverlaps: 0, + suggestedRemovals: [], + }); + + vi.mocked(syncModule.previewSyncFromCodexMultiAuth).mockRejectedValueOnce(capacityError); + + const mockClient = createMockClient(); + const { OpenAIOAuthPlugin } = await import("../index.js"); + const plugin = (await OpenAIOAuthPlugin({ client: mockClient } as never)) as unknown as PluginType; + const autoMethod = plugin.auth.methods[0] as unknown as { + authorize: (inputs?: Record) => Promise<{ instructions: string }>; + }; + + await autoMethod.authorize(); + expect(vi.mocked(cliModule.promptCodexMultiAuthSyncPrune)).not.toHaveBeenCalled(); + expect(vi.mocked(syncModule.syncFromCodexMultiAuth)).not.toHaveBeenCalled(); + }); + it("preserves active pointers when sync prune removes an earlier account", async () => { const cliModule = await import("../lib/cli.js"); const storageModule = await import("../lib/storage.js"); From 2a30ade4babebb13edf920965b41c33bf75526fc Mon Sep 17 00:00:00 2001 From: ndycode Date: Sun, 8 Mar 2026 08:18:46 +0800 Subject: [PATCH 42/81] fix: clear remaining review blockers Address the open storage, worktree-key, TTY-capture, and regression-test feedback on PR #69. Co-authored-by: Codex --- lib/storage.ts | 2 +- lib/storage/paths.ts | 7 +- scripts/capture-tui-input.js | 129 ++++++++++++++++------------- test/auth-menu.test.ts | 2 +- test/codex-multi-auth-sync.test.ts | 25 +++++- test/index.test.ts | 3 + test/paths.test.ts | 27 ++++++ test/plugin-config.test.ts | 2 +- test/storage.test.ts | 59 +++++++++++++ 9 files changed, 190 insertions(+), 66 deletions(-) diff --git a/lib/storage.ts b/lib/storage.ts index 5defcf41..42a234f1 100644 --- a/lib/storage.ts +++ b/lib/storage.ts @@ -592,7 +592,7 @@ function normalizeDuplicateCleanupSourceStorage(data: unknown): AccountStorageV3 } async function loadDuplicateCleanupSourceStorage(): Promise { - const fallback = await loadAccountsInternal(null); + const fallback = await loadAccountsInternal(saveAccountsUnlocked); try { const rawContent = await fs.readFile(getStoragePath(), "utf-8"); const rawData = JSON.parse(rawContent) as unknown; diff --git a/lib/storage/paths.ts b/lib/storage/paths.ts index 456e4da4..b0d4c52e 100644 --- a/lib/storage/paths.ts +++ b/lib/storage/paths.ts @@ -104,8 +104,11 @@ export function getProjectStorageKeyCandidates(projectPath: string): string[] { } export function getProjectStorageKey(projectPath: string): string { - const normalizedPath = normalizeProjectPath(projectPath); - return buildProjectStorageKey(normalizedPath, normalizedPath); + const canonicalIdentity = getCanonicalProjectStorageIdentity(projectPath); + return buildProjectStorageKey( + normalizeProjectPath(canonicalIdentity.projectNamePath), + canonicalIdentity.identityPath, + ); } /** diff --git a/scripts/capture-tui-input.js b/scripts/capture-tui-input.js index 386e4ed9..c8414c02 100644 --- a/scripts/capture-tui-input.js +++ b/scripts/capture-tui-input.js @@ -45,8 +45,6 @@ const { coalesceTerminalInput, tokenizeTerminalInput } = await import(selectModu const { parseKey } = await import(ansiModulePath); const ESCAPE_TIMEOUT_MS = 50; -mkdirSync(dirname(output), { recursive: true }); - const logEvent = (event) => { appendFileSync(output, `${JSON.stringify(sanitizeAuditValue("event", { ts: new Date().toISOString(), ...event }))}\n`, { encoding: "utf8", @@ -83,6 +81,8 @@ if (!process.stdin.isTTY || !process.stdout.isTTY) { process.exit(1); } +mkdirSync(dirname(output), { recursive: true }); + console.log(`Logging raw terminal input to ${output}`); console.log("Press keys to capture. Ctrl+C exits."); @@ -91,8 +91,11 @@ let pendingEscapeTimer = null; const stdin = process.stdin; const stdout = process.stdout; const wasRaw = stdin.isRaw ?? false; +let cleanedUp = false; const cleanup = () => { + if (cleanedUp) return; + cleanedUp = true; if (pendingEscapeTimer) { clearTimeout(pendingEscapeTimer); pendingEscapeTimer = null; @@ -105,71 +108,83 @@ const cleanup = () => { } }; +const exitCapture = (code = 0) => { + cleanup(); + stdout.write("\nCapture complete.\n"); + process.exit(code); +}; + +const handleFatal = (error) => { + cleanup(); + console.error(error); + process.exit(1); +}; + stdin.setRawMode(true); stdin.resume(); stdin.on("data", (data) => { - const rawInput = data.toString("utf8"); - if (pendingEscapeTimer) { - clearTimeout(pendingEscapeTimer); - pendingEscapeTimer = null; - } - logEvent({ - type: "raw", - bytesHex: Array.from(data.values()).map((value) => value.toString(16).padStart(2, "0")).join(" "), - utf8: rawInput, - }); - - let shouldExit = false; - for (const token of tokenizeTerminalInput(rawInput)) { - const coalesced = coalesceTerminalInput(token, pending); - pending = coalesced.pending; - logEvent({ - type: "token", - token, - pending: pending?.value ?? null, - hasEscape: pending?.hasEscape ?? false, - normalizedInput: coalesced.normalizedInput, - }); - if (coalesced.normalizedInput === null) { - if (pending?.hasEscape && pending.value === "\u001b") { - pendingEscapeTimer = setTimeout(() => { - logEvent({ - type: "timeout", - reason: "escape-start", - }); - cleanup(); - stdout.write("\nCapture complete.\n"); - process.exit(0); - }, ESCAPE_TIMEOUT_MS); - } - continue; + try { + const rawInput = data.toString("utf8"); + if (pendingEscapeTimer) { + clearTimeout(pendingEscapeTimer); + pendingEscapeTimer = null; } - - const buffer = Buffer.from(coalesced.normalizedInput, "utf8"); - const action = parseKey(buffer); - const hotkey = printableHotkey(coalesced.normalizedInput); logEvent({ - type: "parsed", - normalizedInput: coalesced.normalizedInput, - action, - hotkey, + type: "raw", + bytesHex: Array.from(data.values()).map((value) => value.toString(16).padStart(2, "0")).join(" "), + utf8: rawInput, }); - if (action === "escape" || action === "escape-start" || coalesced.normalizedInput === "\u0003") { - shouldExit = true; - break; + let shouldExit = false; + for (const token of tokenizeTerminalInput(rawInput)) { + const coalesced = coalesceTerminalInput(token, pending); + pending = coalesced.pending; + logEvent({ + type: "token", + token, + pending: pending?.value ?? null, + hasEscape: pending?.hasEscape ?? false, + normalizedInput: coalesced.normalizedInput, + }); + if (coalesced.normalizedInput === null) { + if (pending?.hasEscape && pending.value === "\u001b") { + pendingEscapeTimer = setTimeout(() => { + logEvent({ + type: "timeout", + reason: "escape-start", + }); + exitCapture(0); + }, ESCAPE_TIMEOUT_MS); + } + continue; + } + + const buffer = Buffer.from(coalesced.normalizedInput, "utf8"); + const action = parseKey(buffer); + const hotkey = printableHotkey(coalesced.normalizedInput); + logEvent({ + type: "parsed", + normalizedInput: coalesced.normalizedInput, + action, + hotkey, + }); + + if (action === "escape" || action === "escape-start" || coalesced.normalizedInput === "\u0003") { + shouldExit = true; + break; + } } - } - if (shouldExit) { - cleanup(); - stdout.write("\nCapture complete.\n"); - process.exit(0); + if (shouldExit) { + exitCapture(0); + } + } catch (error) { + handleFatal(error); } }); -process.on("SIGINT", () => { - cleanup(); - process.exit(0); -}); +process.on("SIGINT", () => exitCapture(0)); +process.on("SIGTERM", () => exitCapture(0)); +process.on("uncaughtException", handleFatal); +process.on("unhandledRejection", handleFatal); diff --git a/test/auth-menu.test.ts b/test/auth-menu.test.ts index 35f07770..25f26cbf 100644 --- a/test/auth-menu.test.ts +++ b/test/auth-menu.test.ts @@ -52,7 +52,7 @@ describe("auth-menu", () => { const firstCall = vi.mocked(select).mock.calls[0]; expect(firstCall).toBeDefined(); - const items = firstCall?.[0] as Array<{ label: string; value?: { type?: string } }>; + const items = firstCall?.[0] as Array<{ label: string; kind?: string; value?: { type?: string } }>; const accountRows = items.filter((item) => item.value?.type === "select-account"); expect(accountRows).toHaveLength(2); expect(accountRows[0]?.label).toContain("shared@example.com"); diff --git a/test/codex-multi-auth-sync.test.ts b/test/codex-multi-auth-sync.test.ts index f7501167..dfd3784e 100644 --- a/test/codex-multi-auth-sync.test.ts +++ b/test/codex-multi-auth-sync.test.ts @@ -1181,13 +1181,21 @@ describe("codex-multi-auth sync", () => { vi.fn(async () => {}), ), ); + vi.mocked(storageModule.previewImportAccounts).mockImplementationOnce(async (filePath) => { + const raw = await fs.promises.readFile(filePath, "utf8"); + const parsed = JSON.parse(raw) as { accounts: Array<{ accountId?: string }> }; + expect(parsed.accounts).toHaveLength(21); + expect(parsed.accounts[0]?.accountId).toBe("org-source-1"); + expect(parsed.accounts[20]?.accountId).toBe("org-source-21"); + return { imported: 21, skipped: 0, total: 22 }; + }); const { previewSyncFromCodexMultiAuth } = await import("../lib/codex-multi-auth-sync.js"); await expect(previewSyncFromCodexMultiAuth(process.cwd())).resolves.toMatchObject({ accountsPath: globalPath, - imported: 2, - total: 4, + imported: 21, + total: 22, skipped: 0, }); }); @@ -1213,12 +1221,21 @@ describe("codex-multi-auth sync", () => { })), }), ); + const storageModule = await import("../lib/storage.js"); + vi.mocked(storageModule.previewImportAccounts).mockImplementationOnce(async (filePath) => { + const raw = await fs.promises.readFile(filePath, "utf8"); + const parsed = JSON.parse(raw) as { accounts: Array<{ accountId?: string }> }; + expect(parsed.accounts).toHaveLength(50); + expect(parsed.accounts[0]?.accountId).toBe("org-source-1"); + expect(parsed.accounts[49]?.accountId).toBe("org-source-50"); + return { imported: 50, skipped: 0, total: 52 }; + }); const { previewSyncFromCodexMultiAuth } = await import("../lib/codex-multi-auth-sync.js"); await expect(previewSyncFromCodexMultiAuth(process.cwd())).resolves.toMatchObject({ accountsPath: globalPath, - imported: 2, - total: 4, + imported: 50, + total: 52, skipped: 0, }); }); diff --git a/test/index.test.ts b/test/index.test.ts index 434cf0e5..1862e6f9 100644 --- a/test/index.test.ts +++ b/test/index.test.ts @@ -2730,6 +2730,9 @@ describe("OpenAIOAuthPlugin persistAccountPool", () => { accounts: mockStorage.accounts.map((account) => ({ ...account })), })); vi.mocked(storageModule.saveAccounts).mockImplementation(async (nextStorage) => { + if (nextStorage.accounts.length === 2) { + throw new Error("stale pre-cleanup storage was persisted"); + } mockStorage.version = nextStorage.version; mockStorage.activeIndex = nextStorage.activeIndex; mockStorage.activeIndexByFamily = { ...nextStorage.activeIndexByFamily }; diff --git a/test/paths.test.ts b/test/paths.test.ts index 7705d641..379724fa 100644 --- a/test/paths.test.ts +++ b/test/paths.test.ts @@ -67,6 +67,33 @@ describe("Storage Paths Module", () => { const projectPath = "C:\\Users\\Test\\MyProject"; expect(getProjectStorageKey(projectPath)).toMatch(/^myproject-[a-f0-9]{12}$/); }); + + it("uses the canonical git identity for same-repo worktrees", () => { + const mainWorktree = "C:\\Users\\neil\\DevTools\\oc-chatgpt-multi-auth"; + const branchWorktree = "C:\\Users\\neil\\DevTools\\oc-chatgpt-multi-auth-sync-worktree"; + const mainGitPath = `${mainWorktree}\\.git`.toLowerCase(); + const branchGitPath = `${branchWorktree}\\.git`.toLowerCase(); + const sharedGitFile = "gitdir: C:/Users/neil/DevTools/oc-chatgpt-multi-auth/.git/worktrees/feature-sync\n"; + mockedExistsSync.mockImplementation((candidate) => { + const normalized = String(candidate).replace(/\//g, "\\").toLowerCase(); + return normalized === mainGitPath || normalized === branchGitPath; + }); + mockedStatSync.mockImplementation((candidate) => { + const normalized = String(candidate).replace(/\//g, "\\").toLowerCase(); + return { + isDirectory: () => normalized === mainGitPath, + } as ReturnType; + }); + mockedReadFileSync.mockImplementation((candidate) => { + const normalized = String(candidate).replace(/\//g, "\\").toLowerCase(); + if (normalized === branchGitPath) { + return sharedGitFile; + } + throw new Error(`unexpected read: ${String(candidate)}`); + }); + + expect(getProjectStorageKey(mainWorktree)).toBe(getProjectStorageKey(branchWorktree)); + }); }); describe("getProjectStorageKeyCandidates", () => { diff --git a/test/plugin-config.test.ts b/test/plugin-config.test.ts index b4be5f37..8205b0ba 100644 --- a/test/plugin-config.test.ts +++ b/test/plugin-config.test.ts @@ -784,7 +784,7 @@ describe('Plugin Configuration', () => { }), ); - setSyncFromCodexMultiAuthEnabled(true); + await setSyncFromCodexMultiAuthEnabled(true); expect(mockMkdirSync).toHaveBeenCalledWith( path.join(os.homedir(), '.opencode'), diff --git a/test/storage.test.ts b/test/storage.test.ts index a3a0b0fa..01957716 100644 --- a/test/storage.test.ts +++ b/test/storage.test.ts @@ -21,6 +21,7 @@ import { previewImportAccounts, createTimestampedBackupPath, withAccountStorageTransaction, + previewDuplicateEmailCleanup, cleanupDuplicateEmailAccounts, } from "../lib/storage.js"; @@ -1947,6 +1948,64 @@ describe("storage", () => { expect(existsSync(getStoragePath())).toBe(true); }); + it("migrates legacy project storage before duplicate-email cleanup on cold start", async () => { + const fakeHome = join(testWorkDir, "home-legacy-cleanup"); + const projectDir = join(testWorkDir, "project-legacy-cleanup"); + const projectGitDir = join(projectDir, ".git"); + const legacyProjectConfigDir = join(projectDir, ".opencode"); + const legacyStoragePath = join(legacyProjectConfigDir, "openai-codex-accounts.json"); + + await fs.mkdir(fakeHome, { recursive: true }); + await fs.mkdir(projectGitDir, { recursive: true }); + await fs.mkdir(legacyProjectConfigDir, { recursive: true }); + process.env.HOME = fakeHome; + process.env.USERPROFILE = fakeHome; + setStoragePath(projectDir); + + await fs.writeFile( + legacyStoragePath, + JSON.stringify({ + version: 3, + activeIndex: 0, + activeIndexByFamily: {}, + accounts: [ + { + email: "shared@example.com", + refreshToken: "legacy-older", + addedAt: 1, + lastUsed: 1, + }, + { + email: "shared@example.com", + refreshToken: "legacy-newer", + addedAt: 2, + lastUsed: 2, + }, + ], + }), + "utf-8", + ); + + const preview = await previewDuplicateEmailCleanup(); + expect(preview).toEqual({ + before: 1, + after: 1, + removed: 0, + }); + + const result = await cleanupDuplicateEmailAccounts(); + expect(result).toEqual({ + before: 1, + after: 1, + removed: 0, + }); + expect(existsSync(legacyStoragePath)).toBe(false); + + const migrated = await loadAccounts(); + expect(migrated?.accounts).toHaveLength(1); + expect(migrated?.accounts[0]?.refreshToken).toBe("legacy-newer"); + }); + it("loads global storage as fallback when project-scoped storage is missing", async () => { const fakeHome = join(testWorkDir, "home-fallback"); const projectDir = join(testWorkDir, "project-fallback"); From 885721513f3149781d5a0db87b0456303657aa5d Mon Sep 17 00:00:00 2001 From: ndycode Date: Sun, 8 Mar 2026 08:27:46 +0800 Subject: [PATCH 43/81] fix: address new review follow-ups Tighten sync prune identity remapping, restore the CLI fallback split, remove unsafe sync rollback on temp cleanup errors, and harden config temp-file cleanup with matching regressions. Co-authored-by: Codex --- index.ts | 37 +++++++--- lib/cli.ts | 1 - lib/codex-multi-auth-sync.ts | 92 ++++++++++-------------- lib/config.ts | 94 +++++++++++++++---------- test/cli.test.ts | 41 +++++++++++ test/codex-multi-auth-sync.test.ts | 1 - test/index.test.ts | 108 +++++++++++++++++++++++++++++ test/plugin-config.test.ts | 41 +++++++++++ 8 files changed, 307 insertions(+), 108 deletions(-) diff --git a/index.ts b/index.ts index cda44944..246db826 100644 --- a/index.ts +++ b/index.ts @@ -1609,6 +1609,21 @@ export const OpenAIOAuthPlugin: Plugin = async ({ client }: PluginInput) => { 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 getAccountIdentityKeys = ( account: { refreshToken: string; @@ -4002,29 +4017,29 @@ while (attempted.size < Math.max(1, accountCount)) { if (removedTargets.length !== targetKeySet.size) { throw new Error("Selected accounts changed before removal. Re-run sync and confirm again."); } - const activeAccountIdentityKeys = getAccountIdentityKeys({ + const activeAccountIdentity = { refreshToken: currentStorage.accounts[currentStorage.activeIndex]?.refreshToken ?? "", organizationId: currentStorage.accounts[currentStorage.activeIndex]?.organizationId, accountId: currentStorage.accounts[currentStorage.activeIndex]?.accountId, - }); - const familyActiveIdentityKeys = Object.fromEntries( + } 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 - ? getAccountIdentityKeys({ + ? ({ refreshToken: familyAccount.refreshToken, organizationId: familyAccount.organizationId, accountId: familyAccount.accountId, - }) - : [], + } satisfies SyncRemovalTarget) + : null, ]; }), - ) as Partial>; + ) as Partial>; currentStorage.accounts = currentStorage.accounts.filter( (account) => !targetKeySet.has( @@ -4035,9 +4050,9 @@ while (attempted.size < Math.max(1, accountCount)) { }), ), ); - const remappedActiveIndex = findAccountIndexByIdentityKeys( + const remappedActiveIndex = findAccountIndexByExactIdentity( currentStorage.accounts, - activeAccountIdentityKeys, + activeAccountIdentity, ); currentStorage.activeIndex = remappedActiveIndex >= 0 @@ -4045,9 +4060,9 @@ while (attempted.size < Math.max(1, accountCount)) { : Math.min(currentStorage.activeIndex, Math.max(0, currentStorage.accounts.length - 1)); currentStorage.activeIndexByFamily = currentStorage.activeIndexByFamily ?? {}; for (const family of MODEL_FAMILIES) { - const remappedFamilyIndex = findAccountIndexByIdentityKeys( + const remappedFamilyIndex = findAccountIndexByExactIdentity( currentStorage.accounts, - familyActiveIdentityKeys[family] ?? [], + familyActiveIdentities[family] ?? null, ); currentStorage.activeIndexByFamily[family] = remappedFamilyIndex >= 0 ? remappedFamilyIndex : currentStorage.activeIndex; diff --git a/lib/cli.ts b/lib/cli.ts index a99ca827..1c09580b 100644 --- a/lib/cli.ts +++ b/lib/cli.ts @@ -13,7 +13,6 @@ import { UI_COPY } from "./ui/copy.js"; export function isNonInteractiveMode(): boolean { if (process.env.FORCE_INTERACTIVE_MODE === "1") return false; - if (!input.isTTY || !output.isTTY) return true; if (process.env.OPENCODE_TUI === "1") return true; if (process.env.OPENCODE_DESKTOP === "1") return true; if (process.env.TERM_PROGRAM === "opencode") return true; diff --git a/lib/codex-multi-auth-sync.ts b/lib/codex-multi-auth-sync.ts index f726052d..70856df0 100644 --- a/lib/codex-multi-auth-sync.ts +++ b/lib/codex-multi-auth-sync.ts @@ -4,14 +4,12 @@ import { join, win32 } from "node:path"; import { ACCOUNT_LIMITS } from "./constants.js"; import { logWarn } from "./logger.js"; import { - clearAccounts, deduplicateAccounts, deduplicateAccountsByEmail, getStoragePath, importAccounts, normalizeAccountStorage, previewImportAccounts, - saveAccounts, withAccountStorageTransaction, type AccountStorageV3, type ImportAccountsResult, @@ -777,63 +775,43 @@ export async function syncFromCodexMultiAuth( ): Promise { const resolved = await loadPreparedCodexMultiAuthSourceStorage(projectPath); await assertSyncWithinCapacity(resolved); - const preSyncStorage = await withAccountStorageTransaction((current) => Promise.resolve(current)); - let result: ImportAccountsResult; - try { - result = 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 ?? [], + 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 ?? [], + ); + if (Number.isFinite(maxAccounts)) { + const details = computeSyncCapacityDetails( + resolved, + filteredStorage, + existing ?? + ({ + version: 3, + accounts: [], + activeIndex: 0, + activeIndexByFamily: {}, + } satisfies AccountStorageV3), + maxAccounts, ); - 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); - } + if (details) { + throw new CodexMultiAuthSyncCapacityError(details); } - return filteredStorage; - }, - ); - }, - ); - } catch (error) { - const message = error instanceof Error ? error.message : String(error); - if (message.includes("Failed to remove temporary codex sync directory")) { - try { - if (preSyncStorage) { - await saveAccounts(preSyncStorage); - } else { - await clearAccounts(); - } - } catch (rollbackError) { - const rollbackMessage = - rollbackError instanceof Error ? rollbackError.message : String(rollbackError); - throw new Error(`${message}. Sync rollback failed: ${rollbackMessage}`); - } - } - throw error; - } + } + return filteredStorage; + }, + ); + }, + ); return { rootDir: resolved.rootDir, accountsPath: resolved.accountsPath, diff --git a/lib/config.ts b/lib/config.ts index 76caf1eb..dcb284cb 100644 --- a/lib/config.ts +++ b/lib/config.ts @@ -144,51 +144,69 @@ export async function savePluginConfigMutation( } const tempPath = `${CONFIG_PATH}.${process.pid}.${Date.now()}.tmp`; - writeFileSync(tempPath, `${JSON.stringify(next, null, 2)}\n`, { - encoding: "utf-8", - mode: 0o600, - }); + let tempFilePresent = false; try { - renameSync(tempPath, CONFIG_PATH); - } catch (error) { - const code = (error as NodeJS.ErrnoException).code; - if ( - process.platform === "win32" && - (code === "EEXIST" || code === "EPERM") && - existsSync(CONFIG_PATH) - ) { - const backupPath = `${CONFIG_PATH}.${process.pid}.${Date.now()}.bak`; - renameSync(CONFIG_PATH, backupPath); - try { - renameSync(tempPath, CONFIG_PATH); - try { - unlinkSync(backupPath); - } catch { - // best effort backup cleanup - } - return; - } catch (retryError) { + writeFileSync(tempPath, `${JSON.stringify(next, null, 2)}\n`, { + encoding: "utf-8", + mode: 0o600, + }); + tempFilePresent = true; + try { + renameSync(tempPath, CONFIG_PATH); + tempFilePresent = false; + return; + } catch (error) { + const code = (error as NodeJS.ErrnoException).code; + if ( + process.platform === "win32" && + (code === "EEXIST" || code === "EPERM") && + existsSync(CONFIG_PATH) + ) { + const backupPath = `${CONFIG_PATH}.${process.pid}.${Date.now()}.bak`; + let backupMoved = false; try { - if (!existsSync(CONFIG_PATH)) { - renameSync(backupPath, CONFIG_PATH); + renameSync(CONFIG_PATH, backupPath); + backupMoved = true; + renameSync(tempPath, CONFIG_PATH); + tempFilePresent = false; + try { + unlinkSync(backupPath); + } catch { + // best effort backup cleanup + } + return; + } catch (retryError) { + if (backupMoved) { + try { + if (!existsSync(CONFIG_PATH)) { + renameSync(backupPath, CONFIG_PATH); + backupMoved = false; + } + } catch { + // best effort config restore + } + } + throw retryError; + } finally { + if (backupMoved) { + try { + unlinkSync(backupPath); + } catch { + // best effort backup cleanup + } } - } catch { - // best effort config restore - } - try { - unlinkSync(tempPath); - } catch { - // best effort temp cleanup } - throw retryError; } + throw error; } - try { - unlinkSync(tempPath); - } catch { - // best effort temp cleanup + } finally { + if (tempFilePresent) { + try { + unlinkSync(tempPath); + } catch { + // best effort temp cleanup + } } - throw error; } }); } diff --git a/test/cli.test.ts b/test/cli.test.ts index b3988962..ae5f8f0b 100644 --- a/test/cli.test.ts +++ b/test/cli.test.ts @@ -270,6 +270,26 @@ describe("CLI Module", () => { expect(result).toBeNull(); }); + + it("uses the readline fallback when menus are unavailable but interaction is still allowed", async () => { + const { stdin, stdout } = await import("node:process"); + const origInputTTY = stdin.isTTY; + const origOutputTTY = stdout.isTTY; + Object.defineProperty(stdin, "isTTY", { value: false, writable: true, configurable: true }); + Object.defineProperty(stdout, "isTTY", { value: false, writable: true, configurable: true }); + mockRl.question.mockResolvedValueOnce("1"); + + try { + const { promptCodexMultiAuthSyncPrune } = await import("../lib/cli.js"); + const result = await promptCodexMultiAuthSyncPrune(1, [ + { index: 0, email: "one@example.com", reason: "least recently used" }, + ]); + expect(result).toEqual([0]); + } finally { + Object.defineProperty(stdin, "isTTY", { value: origInputTTY, writable: true, configurable: true }); + Object.defineProperty(stdout, "isTTY", { value: origOutputTTY, writable: true, configurable: true }); + } + }); }); describe("isNonInteractiveMode", () => { @@ -496,5 +516,26 @@ describe("CLI Module", () => { ]); expect(result).toBeNull(); }); + + it("promptLoginMode still falls back to readline when TTY is unavailable but non-interactive flags are absent", async () => { + delete process.env.OPENCODE_TUI; + const { stdin, stdout } = await import("node:process"); + const origInputTTY = stdin.isTTY; + const origOutputTTY = stdout.isTTY; + Object.defineProperty(stdin, "isTTY", { value: false, writable: true, configurable: true }); + Object.defineProperty(stdout, "isTTY", { value: false, writable: true, configurable: true }); + mockRl.question.mockResolvedValueOnce("a"); + + try { + const { promptLoginMode } = await import("../lib/cli.js"); + const result = await promptLoginMode([{ index: 0 }]); + expect(result).toEqual({ mode: "add" }); + expect(mockRl.question).toHaveBeenCalled(); + } finally { + Object.defineProperty(stdin, "isTTY", { value: origInputTTY, writable: true, configurable: true }); + Object.defineProperty(stdout, "isTTY", { value: origOutputTTY, writable: true, configurable: true }); + process.env.OPENCODE_TUI = "1"; + } + }); }); }); diff --git a/test/codex-multi-auth-sync.test.ts b/test/codex-multi-auth-sync.test.ts index dfd3784e..5a5bd789 100644 --- a/test/codex-multi-auth-sync.test.ts +++ b/test/codex-multi-auth-sync.test.ts @@ -1131,7 +1131,6 @@ describe("codex-multi-auth sync", () => { expect(vi.mocked(loggerModule.logWarn)).toHaveBeenCalledWith( expect.stringContaining("Failed to remove temporary codex sync directory"), ); - expect(vi.mocked(storageModule.saveAccounts)).toHaveBeenCalledTimes(1); } finally { rmSpy.mockRestore(); } diff --git a/test/index.test.ts b/test/index.test.ts index 1862e6f9..849358c5 100644 --- a/test/index.test.ts +++ b/test/index.test.ts @@ -3265,6 +3265,114 @@ describe("OpenAIOAuthPlugin persistAccountPool", () => { await fs.rm(tempDir, { recursive: true, force: true }); } }); + + it("remaps sync-prune active pointers by exact identity when sibling accounts share fragments", async () => { + const cliModule = await import("../lib/cli.js"); + const storageModule = await import("../lib/storage.js"); + const syncModule = await import("../lib/codex-multi-auth-sync.js"); + const configModule = await import("../lib/config.js"); + const confirmModule = await import("../lib/ui/confirm.js"); + + const tempDir = await fs.mkdtemp(join(tmpdir(), "oc-sync-prune-exact-")); + try { + mockStorage.accounts = [ + { + accountId: "org-prune", + organizationId: "org-prune", + accountIdSource: "org", + email: "prune@example.com", + refreshToken: "refresh-prune", + }, + { + accountId: "workspace-a", + organizationId: "org-shared", + accountIdSource: "org", + email: "shared-a@example.com", + refreshToken: "refresh-shared", + }, + { + accountId: "workspace-b", + organizationId: "org-shared", + accountIdSource: "org", + email: "shared-b@example.com", + refreshToken: "refresh-shared", + }, + ]; + mockStorage.activeIndex = 2; + mockStorage.activeIndexByFamily = { codex: 2 }; + + vi.mocked(cliModule.promptLoginMode) + .mockResolvedValueOnce({ mode: "experimental-sync-now" }) + .mockResolvedValueOnce({ mode: "cancel" }); + vi.mocked(cliModule.promptCodexMultiAuthSyncPrune).mockResolvedValueOnce([0]); + vi.mocked(configModule.getSyncFromCodexMultiAuthEnabled).mockReturnValue(true); + vi.mocked(confirmModule.confirm).mockReset(); + vi.mocked(confirmModule.confirm).mockResolvedValue(true); + + vi.mocked(storageModule.createTimestampedBackupPath).mockImplementation((prefix?: string) => + join(tempDir, `${prefix ?? "codex-backup"}.json`), + ); + vi.mocked(storageModule.exportAccounts).mockImplementation(async (filePath: string) => { + await fs.writeFile( + filePath, + JSON.stringify({ + version: mockStorage.version, + activeIndex: mockStorage.activeIndex, + activeIndexByFamily: { ...mockStorage.activeIndexByFamily }, + accounts: mockStorage.accounts.map((account) => ({ ...account })), + }), + "utf8", + ); + }); + vi.mocked(storageModule.saveAccounts).mockImplementation(async (nextStorage) => { + mockStorage.version = nextStorage.version; + mockStorage.activeIndex = nextStorage.activeIndex; + mockStorage.activeIndexByFamily = { ...nextStorage.activeIndexByFamily }; + mockStorage.accounts = nextStorage.accounts.map((account) => ({ ...account })); + }); + + const capacityError = new syncModule.CodexMultiAuthSyncCapacityError({ + rootDir: tempDir, + accountsPath: join(tempDir, "openai-codex-accounts.json"), + scope: "global", + currentCount: 3, + sourceCount: 2, + sourceDedupedTotal: 3, + dedupedTotal: 3, + maxAccounts: 2, + needToRemove: 1, + importableNewAccounts: 1, + skippedOverlaps: 1, + suggestedRemovals: [ + { + index: 0, + email: "prune@example.com", + accountLabel: "Workspace prune", + isCurrentAccount: false, + score: 180, + reason: "disabled", + }, + ], + }); + + vi.mocked(syncModule.previewSyncFromCodexMultiAuth).mockRejectedValueOnce(capacityError); + + const mockClient = createMockClient(); + const { OpenAIOAuthPlugin } = await import("../index.js"); + const plugin = (await OpenAIOAuthPlugin({ client: mockClient } as never)) as unknown as PluginType; + const autoMethod = plugin.auth.methods[0] as unknown as { + authorize: (inputs?: Record) => Promise<{ instructions: string }>; + }; + + const authResult = await autoMethod.authorize(); + expect(authResult.instructions).toBe("Authentication cancelled"); + expect(mockStorage.accounts.map((account) => account.accountId)).toEqual(["workspace-a", "workspace-b"]); + expect(mockStorage.activeIndex).toBe(1); + expect(mockStorage.activeIndexByFamily.codex).toBe(1); + } finally { + await fs.rm(tempDir, { recursive: true, force: true }); + } + }); }); describe("OpenAIOAuthPlugin showToast error handling", () => { diff --git a/test/plugin-config.test.ts b/test/plugin-config.test.ts index 8205b0ba..9cb9a410 100644 --- a/test/plugin-config.test.ts +++ b/test/plugin-config.test.ts @@ -82,6 +82,12 @@ describe('Plugin Configuration', () => { originalEnv[key] = process.env[key]; } vi.clearAllMocks(); + mockExistsSync.mockReturnValue(false); + mockReadFileSync.mockReturnValue('{}'); + mockMkdirSync.mockImplementation(() => undefined); + mockRenameSync.mockImplementation(() => undefined); + mockUnlinkSync.mockImplementation(() => undefined); + mockWriteFileSync.mockImplementation(() => undefined); }); afterEach(() => { @@ -849,6 +855,41 @@ describe('Plugin Configuration', () => { expect(mockRenameSync).not.toHaveBeenCalled(); }); + it('cleans up temp config files when the initial rename fails', async () => { + mockExistsSync.mockReturnValue(false); + mockRenameSync.mockImplementation(() => { + throw Object.assign(new Error('rename failed'), { code: 'EACCES' }); + }); + + await expect(setSyncFromCodexMultiAuthEnabled(true)).rejects.toThrow('rename failed'); + expect(mockUnlinkSync).toHaveBeenCalledWith(expect.stringContaining('.tmp')); + }); + + it('cleans up temp config files when the Windows fallback retry fails', async () => { + const originalPlatform = process.platform; + Object.defineProperty(process, 'platform', { value: 'win32' }); + mockExistsSync.mockImplementation((filePath) => + String(filePath).endsWith('openai-codex-auth-config.json'), + ); + let renameCalls = 0; + mockRenameSync.mockImplementation((source, destination) => { + if (String(source).includes('.tmp') && String(destination).endsWith('openai-codex-auth-config.json')) { + renameCalls += 1; + if (renameCalls <= 2) { + throw Object.assign(new Error('rename failed'), { code: 'EPERM' }); + } + } + return undefined; + }); + + try { + await expect(setSyncFromCodexMultiAuthEnabled(true)).rejects.toThrow('rename failed'); + expect(mockUnlinkSync).toHaveBeenCalledWith(expect.stringContaining('.tmp')); + } finally { + Object.defineProperty(process, 'platform', { value: originalPlatform }); + } + }); + it('recovers stale config lock files before mutating config', async () => { const configPath = path.join(os.homedir(), '.opencode', 'openai-codex-auth-config.json'); const lockPath = `${configPath}.lock`; From 22c87d4031a7bcfd9eb2c8d561d42497a95f558e Mon Sep 17 00:00:00 2001 From: ndycode Date: Sun, 8 Mar 2026 09:10:52 +0800 Subject: [PATCH 44/81] fix: address greptile review blockers Restore the non-TTY guard, remove dead sync cleanup warning plumbing, make stale config-lock recovery work on Windows, and move auto-repair token persistence under the storage transaction with matching regressions. Co-authored-by: Codex --- index.ts | 67 ++++++++++++++++++++------------- lib/cli.ts | 1 + lib/codex-multi-auth-sync.ts | 2 - lib/config.ts | 6 +++ test/cli.test.ts | 27 +++++++++++-- test/plugin-config.race.test.ts | 61 ++++++++++++++++++++++++++++++ 6 files changed, 133 insertions(+), 31 deletions(-) diff --git a/index.ts b/index.ts index 246db826..45078827 100644 --- a/index.ts +++ b/index.ts @@ -4202,9 +4202,6 @@ while (attempted.size < Math.max(1, accountCount)) { console.log(`Skipped: ${result.skipped}`); console.log(`Total: ${result.total}`); console.log(`Auto-backup: ${backupLabel}`); - if (result.tempCleanupWarning) { - console.log(result.tempCleanupWarning); - } console.log(""); return; } catch (error) { @@ -4506,8 +4503,44 @@ while (attempted.size < Math.max(1, accountCount)) { appliedFixes.push(`Removed ${cleanupResult.removed} synced overlap(s).`); emit(`Removed ${cleanupResult.removed} synced overlap(s).`, "success"); } - const storage = await loadAccounts(); - if (!storage || storage.accounts.length === 0) { + const refreshedStorage = await withAccountStorageTransaction( + async (loadedStorage, persist) => { + if (!loadedStorage || loadedStorage.accounts.length === 0) { + return null; + } + const workingStorage: AccountStorageV3 = { + ...loadedStorage, + accounts: loadedStorage.accounts.map((account) => ({ ...account })), + activeIndexByFamily: { ...(loadedStorage.activeIndexByFamily ?? {}) }, + }; + + let changedByRefresh = false; + let refreshedCount = 0; + for (const account of workingStorage.accounts) { + try { + const refreshResult = await queuedRefresh(account.refreshToken); + if (refreshResult.type === "success") { + account.refreshToken = refreshResult.refresh; + account.accessToken = refreshResult.access; + account.expiresAt = refreshResult.expires; + changedByRefresh = true; + refreshedCount += 1; + } + } catch (error) { + fixErrors.push(error instanceof Error ? error.message : String(error)); + } + } + + if (changedByRefresh) { + await persist(workingStorage); + } + return { + changedByRefresh, + refreshedCount, + }; + }, + ); + if (!refreshedStorage) { emit("No accounts available after cleanup.", "warning"); if (screen) { await screen.finish(); @@ -4516,27 +4549,9 @@ while (attempted.size < Math.max(1, accountCount)) { return; } - let changedByRefresh = false; - let refreshedCount = 0; - for (const account of storage.accounts) { - try { - const refreshResult = await queuedRefresh(account.refreshToken); - if (refreshResult.type === "success") { - account.refreshToken = refreshResult.refresh; - account.accessToken = refreshResult.access; - account.expiresAt = refreshResult.expires; - changedByRefresh = true; - refreshedCount += 1; - } - } catch (error) { - fixErrors.push(error instanceof Error ? error.message : String(error)); - } - } - - if (changedByRefresh) { - await saveAccounts(storage); - appliedFixes.push(`Refreshed ${refreshedCount} account token(s).`); - emit(`Refreshed ${refreshedCount} account token(s).`, "success"); + if (refreshedStorage.changedByRefresh) { + appliedFixes.push(`Refreshed ${refreshedStorage.refreshedCount} account token(s).`); + emit(`Refreshed ${refreshedStorage.refreshedCount} account token(s).`, "success"); } await verifyFlaggedAccounts(screen); await pickBestAccountFromDashboard(screen); diff --git a/lib/cli.ts b/lib/cli.ts index 1c09580b..a99ca827 100644 --- a/lib/cli.ts +++ b/lib/cli.ts @@ -13,6 +13,7 @@ import { UI_COPY } from "./ui/copy.js"; export function isNonInteractiveMode(): boolean { if (process.env.FORCE_INTERACTIVE_MODE === "1") return false; + if (!input.isTTY || !output.isTTY) return true; if (process.env.OPENCODE_TUI === "1") return true; if (process.env.OPENCODE_DESKTOP === "1") return true; if (process.env.TERM_PROGRAM === "opencode") return true; diff --git a/lib/codex-multi-auth-sync.ts b/lib/codex-multi-auth-sync.ts index 70856df0..a271193d 100644 --- a/lib/codex-multi-auth-sync.ts +++ b/lib/codex-multi-auth-sync.ts @@ -40,8 +40,6 @@ export interface CodexMultiAuthSyncResult extends CodexMultiAuthSyncPreview { backupStatus: ImportAccountsResult["backupStatus"]; backupPath?: string; backupError?: string; - tempCleanupWarning?: string; - tempCleanupPath?: string; } export interface CodexMultiAuthCleanupResult { diff --git a/lib/config.ts b/lib/config.ts index dcb284cb..7aae4fc8 100644 --- a/lib/config.ts +++ b/lib/config.ts @@ -229,6 +229,12 @@ function isProcessAlive(pid: number): boolean { return true; } catch (error) { const code = (error as NodeJS.ErrnoException).code; + if (code === "ESRCH") { + return false; + } + if (process.platform === "win32" && code === "EPERM") { + return false; + } return code === "EPERM"; } } diff --git a/test/cli.test.ts b/test/cli.test.ts index ae5f8f0b..7653d83c 100644 --- a/test/cli.test.ts +++ b/test/cli.test.ts @@ -517,20 +517,41 @@ describe("CLI Module", () => { expect(result).toBeNull(); }); - it("promptLoginMode still falls back to readline when TTY is unavailable but non-interactive flags are absent", async () => { + it("promptLoginMode returns add immediately when TTY is unavailable without env overrides", async () => { delete process.env.OPENCODE_TUI; const { stdin, stdout } = await import("node:process"); const origInputTTY = stdin.isTTY; const origOutputTTY = stdout.isTTY; Object.defineProperty(stdin, "isTTY", { value: false, writable: true, configurable: true }); Object.defineProperty(stdout, "isTTY", { value: false, writable: true, configurable: true }); - mockRl.question.mockResolvedValueOnce("a"); try { const { promptLoginMode } = await import("../lib/cli.js"); const result = await promptLoginMode([{ index: 0 }]); expect(result).toEqual({ mode: "add" }); - expect(mockRl.question).toHaveBeenCalled(); + expect(mockRl.question).not.toHaveBeenCalled(); + } finally { + Object.defineProperty(stdin, "isTTY", { value: origInputTTY, writable: true, configurable: true }); + Object.defineProperty(stdout, "isTTY", { value: origOutputTTY, writable: true, configurable: true }); + process.env.OPENCODE_TUI = "1"; + } + }); + + it("promptCodexMultiAuthSyncPrune returns null when TTY is unavailable without env overrides", async () => { + delete process.env.OPENCODE_TUI; + const { stdin, stdout } = await import("node:process"); + const origInputTTY = stdin.isTTY; + const origOutputTTY = stdout.isTTY; + Object.defineProperty(stdin, "isTTY", { value: false, writable: true, configurable: true }); + Object.defineProperty(stdout, "isTTY", { value: false, writable: true, configurable: true }); + + try { + const { promptCodexMultiAuthSyncPrune } = await import("../lib/cli.js"); + const result = await promptCodexMultiAuthSyncPrune(1, [ + { index: 0, email: "one@example.com", reason: "least recently used" }, + ]); + expect(result).toBeNull(); + expect(mockRl.question).not.toHaveBeenCalled(); } finally { Object.defineProperty(stdin, "isTTY", { value: origInputTTY, writable: true, configurable: true }); Object.defineProperty(stdout, "isTTY", { value: origOutputTTY, writable: true, configurable: true }); diff --git a/test/plugin-config.race.test.ts b/test/plugin-config.race.test.ts index 24a56b4c..3a6e86a9 100644 --- a/test/plugin-config.race.test.ts +++ b/test/plugin-config.race.test.ts @@ -149,4 +149,65 @@ describe("plugin config lock retry", () => { killSpy.mockRestore(); } }); + + it("recovers stale locks on Windows when the pid probe returns EPERM", async () => { + Object.defineProperty(process, "platform", { value: "win32" }); + const configPath = path.join(os.homedir(), ".opencode", "openai-codex-auth-config.json"); + const lockPath = `${configPath}.lock`; + let lockAttempts = 0; + let lockFilePresent = true; + const killSpy = vi.spyOn(process, "kill").mockImplementation(() => { + const error = new Error("permission denied") as NodeJS.ErrnoException; + error.code = "EPERM"; + throw error; + }); + + mockExistsSync.mockImplementation((filePath) => String(filePath) === lockPath && lockFilePresent); + mockReadFileSync.mockImplementation((filePath: fs.PathOrFileDescriptor) => { + const pathValue = String(filePath); + if (pathValue === lockPath) { + return "111"; + } + if (pathValue.includes(".stale")) { + return "111"; + } + return "{}"; + }); + mockRenameSync.mockImplementation((source, destination) => { + if (String(source) === lockPath) { + lockFilePresent = false; + } + if (String(destination) === lockPath) { + lockFilePresent = true; + } + return undefined; + }); + mockWriteFileSync.mockImplementation((filePath) => { + const pathValue = String(filePath); + if (pathValue === lockPath) { + lockAttempts += 1; + if (lockAttempts === 1) { + const error = new Error("exists") as NodeJS.ErrnoException; + error.code = "EEXIST"; + throw error; + } + } + return undefined; + }); + + const { savePluginConfigMutation } = await import("../lib/config.js"); + + try { + await expect( + savePluginConfigMutation((current) => ({ + ...current, + experimental: { syncFromCodexMultiAuth: { enabled: true } }, + })), + ).resolves.toBeUndefined(); + expect(killSpy).toHaveBeenCalledWith(111, 0); + expect(mockRenameSync).toHaveBeenCalled(); + } finally { + killSpy.mockRestore(); + } + }); }); From 9de607349dee4588c64a74ff0f6a50c05333cf2b Mon Sep 17 00:00:00 2001 From: ndycode Date: Sun, 8 Mar 2026 10:07:47 +0800 Subject: [PATCH 45/81] fix: address remaining greptile findings Fix path/root fallback logic, make overlap cleanup tolerate transient Windows file locks, keep refresh-token identity case-sensitive, add an auto-repair backup, and cover concurrent sync behavior with dedicated race coverage. Co-authored-by: Codex --- index.ts | 2 + lib/codex-multi-auth-sync.ts | 15 +++- lib/storage/paths.ts | 3 +- test/codex-multi-auth-sync.race.test.ts | 75 +++++++++++++++++ test/codex-multi-auth-sync.test.ts | 103 ++++++++++++++++++++++++ test/index.test.ts | 7 ++ 6 files changed, 199 insertions(+), 6 deletions(-) create mode 100644 test/codex-multi-auth-sync.race.test.ts diff --git a/index.ts b/index.ts index 45078827..02c76fd2 100644 --- a/index.ts +++ b/index.ts @@ -4498,6 +4498,8 @@ while (attempted.size < Math.max(1, accountCount)) { } const appliedFixes: string[] = []; const fixErrors: string[] = []; + const backupPath = await createMaintenanceAccountsBackup("codex-auto-repair-backup"); + emit(`Backup created: ${backupPath}`, "muted"); const cleanupResult = await cleanupCodexMultiAuthSyncedOverlaps(); if (cleanupResult.removed > 0) { appliedFixes.push(`Removed ${cleanupResult.removed} synced overlap(s).`); diff --git a/lib/codex-multi-auth-sync.ts b/lib/codex-multi-auth-sync.ts index a271193d..c2e42bb0 100644 --- a/lib/codex-multi-auth-sync.ts +++ b/lib/codex-multi-auth-sync.ts @@ -68,6 +68,11 @@ export interface CodexMultiAuthSyncCapacityDetails extends CodexMultiAuthResolve }>; } +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(); @@ -162,6 +167,8 @@ async function withNormalizedImportFile( }; 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 }); const tempDir = await fs.mkdtemp(join(secureTempRoot, "oc-chatgpt-multi-auth-sync-")); return runWithTempDir(tempDir); @@ -245,7 +252,7 @@ function buildExistingSyncIdentityState(existingAccounts: AccountStorageV3["acco for (const account of existingAccounts) { const organizationId = normalizeIdentity(account.organizationId); const accountId = normalizeIdentity(account.accountId); - const refreshToken = normalizeIdentity(account.refreshToken); + const refreshToken = normalizeTrimmedIdentity(account.refreshToken); const email = normalizeIdentity(account.email); if (organizationId) organizationIds.add(organizationId); if (accountId) accountIds.add(accountId); @@ -282,7 +289,7 @@ function filterSourceAccountsAgainstExistingEmails( if (accountId) { return !existingState.accountIds.has(accountId); } - const refreshToken = normalizeIdentity(account.refreshToken); + const refreshToken = normalizeTrimmedIdentity(account.refreshToken); if (refreshToken && existingState.refreshTokens.has(refreshToken)) { return false; } @@ -408,7 +415,7 @@ function toCleanupIdentityKeys(account: { if (organizationId) keys.push(`org:${organizationId}`); const accountId = normalizeIdentity(account.accountId); if (accountId) keys.push(`account:${accountId}`); - const refreshToken = normalizeIdentity(account.refreshToken); + const refreshToken = normalizeTrimmedIdentity(account.refreshToken); if (refreshToken) keys.push(`refresh:${refreshToken}`); return keys; } @@ -975,7 +982,7 @@ async function loadRawCodexMultiAuthOverlapCleanupStorage( throw new Error("Invalid raw storage snapshot for synced overlap cleanup."); } catch (error) { const code = (error as NodeJS.ErrnoException).code; - if (code === "ENOENT") { + if (code === "ENOENT" || code === "EBUSY" || code === "EACCES" || code === "EPERM") { return fallback; } const message = error instanceof Error ? error.message : String(error); diff --git a/lib/storage/paths.ts b/lib/storage/paths.ts index b0d4c52e..dfc46db9 100644 --- a/lib/storage/paths.ts +++ b/lib/storage/paths.ts @@ -125,7 +125,6 @@ export function isProjectDirectory(dir: string): boolean { export function findProjectRoot(startDir: string): string | null { let current = startDir; - const root = dirname(current) === current ? current : null; while (current) { if (isProjectDirectory(current)) { @@ -139,7 +138,7 @@ export function findProjectRoot(startDir: string): string | null { current = parent; } - return root && isProjectDirectory(root) ? root : null; + return null; } function normalizePathForComparison(filePath: string): string { diff --git a/test/codex-multi-auth-sync.race.test.ts b/test/codex-multi-auth-sync.race.test.ts new file mode 100644 index 00000000..5599e52b --- /dev/null +++ b/test/codex-multi-auth-sync.race.test.ts @@ -0,0 +1,75 @@ +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { promises as fs } from "node:fs"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; + +describe("codex-multi-auth sync race paths", () => { + let testDir: string; + let sourceRoot: string; + let storagePath: string; + const originalEnv = { + CODEX_MULTI_AUTH_DIR: process.env.CODEX_MULTI_AUTH_DIR, + }; + + beforeEach(async () => { + testDir = await fs.mkdtemp(join(tmpdir(), "codex-sync-race-")); + sourceRoot = join(testDir, "source"); + storagePath = join(testDir, "accounts.json"); + process.env.CODEX_MULTI_AUTH_DIR = sourceRoot; + await fs.mkdir(sourceRoot, { recursive: true }); + await fs.writeFile( + join(sourceRoot, "openai-codex-accounts.json"), + JSON.stringify({ + version: 3, + activeIndex: 0, + activeIndexByFamily: {}, + accounts: [ + { + accountId: "org-source-1", + organizationId: "org-source-1", + accountIdSource: "org", + email: "source@example.com", + refreshToken: "rt-source-1", + addedAt: 1, + lastUsed: 1, + }, + ], + }), + "utf8", + ); + + const storageModule = await import("../lib/storage.js"); + storageModule.setStoragePathDirect(storagePath); + await storageModule.clearAccounts(); + }); + + afterEach(async () => { + const storageModule = await import("../lib/storage.js"); + storageModule.setStoragePathDirect(null); + 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; + } + await fs.rm(testDir, { recursive: true, force: true }); + }); + + it("keeps the final account store deduplicated under concurrent syncs", async () => { + const { syncFromCodexMultiAuth } = await import("../lib/codex-multi-auth-sync.js"); + const storageModule = await import("../lib/storage.js"); + + const results = await Promise.allSettled([ + syncFromCodexMultiAuth(testDir), + syncFromCodexMultiAuth(testDir), + ]); + + expect(results.every((result) => result.status === "fulfilled")).toBe(true); + + const loaded = await storageModule.loadAccounts(); + expect(loaded?.accounts).toHaveLength(1); + expect(loaded?.accounts[0]?.accountId).toBe("org-source-1"); + expect(new Set(loaded?.accounts.map((account) => account.refreshToken))).toEqual( + new Set(["rt-source-1"]), + ); + }); +}); diff --git a/test/codex-multi-auth-sync.test.ts b/test/codex-multi-auth-sync.test.ts index 5a5bd789..aa0fc558 100644 --- a/test/codex-multi-auth-sync.test.ts +++ b/test/codex-multi-auth-sync.test.ts @@ -517,6 +517,60 @@ describe("codex-multi-auth sync", () => { }); }); + 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, + }, + ], + }; + vi.mocked(storageModule.withAccountStorageTransaction).mockImplementationOnce(async (handler) => + handler(currentStorage, vi.fn(async () => {})), + ); + vi.mocked(storageModule.previewImportAccounts).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; @@ -940,6 +994,55 @@ describe("codex-multi-auth sync", () => { expect(persisted?.accounts[0]?.organizationId).toBe("org-sync"); }); + it("falls back to in-memory overlap cleanup state on transient Windows lock errors", async () => { + const storageModule = await import("../lib/storage.js"); + let persisted: AccountStorageV3 | null = null; + 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, + }, + ], + }, + vi.fn(async (next) => { + persisted = next; + }), + ), + ); + 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: 2, + removed: 0, + updated: 1, + }); + expect(persisted?.accounts).toHaveLength(2); + expect(persisted?.accounts[0]?.organizationId).toBe("org-sync"); + }); + it("limits overlap cleanup to accounts tagged from codex-multi-auth sync", async () => { const storageModule = await import("../lib/storage.js"); vi.mocked(storageModule.withAccountStorageTransaction).mockImplementationOnce(async (handler) => diff --git a/test/index.test.ts b/test/index.test.ts index 849358c5..1bc1a2d1 100644 --- a/test/index.test.ts +++ b/test/index.test.ts @@ -2748,6 +2748,13 @@ describe("OpenAIOAuthPlugin persistAccountPool", () => { const authResult = await autoMethod.authorize(); expect(authResult.instructions).toBe("Authentication cancelled"); + expect(vi.mocked(storageModule.createTimestampedBackupPath)).toHaveBeenCalledWith( + "codex-auto-repair-backup", + ); + expect(vi.mocked(storageModule.backupRawAccountsFile)).toHaveBeenCalledWith( + "/tmp/codex-auto-repair-backup-20260101-000000.json", + true, + ); expect(mockStorage.accounts).toHaveLength(1); expect(mockStorage.accounts[0]?.email).toBe("keep@example.com"); }); From 5610e87130f6ea01d6f5d259520b0829b922293e Mon Sep 17 00:00:00 2001 From: ndycode Date: Sun, 8 Mar 2026 10:40:32 +0800 Subject: [PATCH 46/81] fix: resolve remaining pr69 review findings Address the remaining sync source discovery, overlap cleanup, selector sanitization, and test coverage issues surfaced on PR #69. Co-authored-by: Codex --- index.ts | 34 +-------- lib/codex-multi-auth-sync.ts | 96 ++++++++++++++++--------- lib/config.ts | 8 +-- lib/ui/select.ts | 41 +++++++---- test/codex-multi-auth-sync.test.ts | 108 +++++++++++++++++++++++++++++ test/paths.test.ts | 9 +++ 6 files changed, 209 insertions(+), 87 deletions(-) diff --git a/index.ts b/index.ts index 02c76fd2..a0a73482 100644 --- a/index.ts +++ b/index.ts @@ -26,7 +26,7 @@ import { tool } from "@opencode-ai/plugin/tool"; import { promises as fsPromises } from "node:fs"; import { createInterface } from "node:readline/promises"; -import { dirname, join } from "node:path"; +import { dirname } from "node:path"; import type { Plugin, PluginInput } from "@opencode-ai/plugin"; import type { Auth } from "@opencode-ai/sdk"; import { @@ -1624,38 +1624,6 @@ export const OpenAIOAuthPlugin: Plugin = async ({ client }: PluginInput) => { ); }; - const getAccountIdentityKeys = ( - account: { - refreshToken: string; - organizationId?: string; - accountId?: string; - }, - ): string[] => { - const keys: string[] = []; - if (account.organizationId) keys.push(`org:${account.organizationId}`); - if (account.accountId) keys.push(`account:${account.accountId}`); - keys.push(`refresh:${account.refreshToken}`); - return keys; - }; - - const findAccountIndexByIdentityKeys = ( - accounts: AccountStorageV3["accounts"], - identityKeys: string[], - ): number => { - if (identityKeys.length === 0) return -1; - for (const identityKey of identityKeys) { - const index = accounts.findIndex((account) => - getAccountIdentityKeys({ - refreshToken: account.refreshToken, - organizationId: account.organizationId, - accountId: account.accountId, - }).includes(identityKey), - ); - if (index >= 0) return index; - } - return -1; - }; - const normalizeAccountTags = (raw: string): string[] => { return Array.from( new Set( diff --git a/lib/codex-multi-auth-sync.ts b/lib/codex-multi-auth-sync.ts index c2e42bb0..57b48266 100644 --- a/lib/codex-multi-auth-sync.ts +++ b/lib/codex-multi-auth-sync.ts @@ -1,4 +1,4 @@ -import { existsSync, promises as fs } from "node:fs"; +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"; @@ -145,14 +145,15 @@ async function withNormalizedImportFile( const runWithTempDir = async (tempDir: string): Promise => { await fs.chmod(tempDir, 0o700).catch(() => undefined); const tempPath = join(tempDir, "accounts.json"); - await fs.writeFile(tempPath, `${JSON.stringify(storage, null, 2)}\n`, { - encoding: "utf-8", - mode: 0o600, - flag: "wx", - }); - let result: T; try { - result = await handler(tempPath); + await fs.writeFile(tempPath, `${JSON.stringify(storage, null, 2)}\n`, { + encoding: "utf-8", + mode: 0o600, + flag: "wx", + }); + const result = await handler(tempPath); + await removeNormalizedImportTempDir(tempDir, tempPath, options); + return result; } catch (error) { try { await removeNormalizedImportTempDir(tempDir, tempPath, { postSuccessCleanupFailureMode: "warn" }); @@ -162,8 +163,6 @@ async function withNormalizedImportFile( } throw error; } - await removeNormalizedImportTempDir(tempDir, tempPath, options); - return result; }; const secureTempRoot = join(getResolvedUserHomeDir(), ".opencode", "tmp"); @@ -573,10 +572,30 @@ function hasStorageSignals(dir: string): boolean { 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) => { - return existsSync(join(dir, fileName)); - }); + return ( + EXTERNAL_ACCOUNT_FILE_NAMES.some((fileName) => existsSync(join(dir, fileName))) || + hasProjectScopedAccountsStorage(dir) + ); } function getCodexHomeDir(): string { @@ -684,28 +703,35 @@ function getGlobalAccountsPath(rootDir: string): string | undefined { } export function resolveCodexMultiAuthAccountsSource(projectPath = process.cwd()): CodexMultiAuthResolvedSource { - const rootDir = getCodexMultiAuthSourceRootDir(); - const projectScopedPath = getProjectScopedAccountsPath(rootDir, projectPath); - if (projectScopedPath) { - return { - rootDir, - accountsPath: projectScopedPath, - scope: "project", - }; - } + 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 globalPath = getGlobalAccountsPath(rootDir); + if (globalPath) { + return { + rootDir, + accountsPath: globalPath, + scope: "global", + }; + } } - throw new Error( - `No codex-multi-auth accounts file found under ${rootDir}`, - ); + 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 { @@ -867,9 +893,13 @@ function buildCodexMultiAuthOverlapCleanupPlan(existing: AccountStorageV3): { }, }; } + const filteredSyncedAccounts = filterSourceAccountsAgainstExistingEmails( + normalizedSyncedStorage, + preservedAccounts, + ).accounts; const normalized = { ...existing, - accounts: [...preservedAccounts, ...normalizedSyncedStorage.accounts], + accounts: [...preservedAccounts, ...filteredSyncedAccounts], } satisfies AccountStorageV3; const existingActiveKeys = extractCleanupActiveKeys(existing.accounts, existing.activeIndex); const mappedActiveIndex = (() => { diff --git a/lib/config.ts b/lib/config.ts index 7aae4fc8..3bcdb948 100644 --- a/lib/config.ts +++ b/lib/config.ts @@ -229,13 +229,7 @@ function isProcessAlive(pid: number): boolean { return true; } catch (error) { const code = (error as NodeJS.ErrnoException).code; - if (code === "ESRCH") { - return false; - } - if (process.platform === "win32" && code === "EPERM") { - return false; - } - return code === "EPERM"; + return code !== "ESRCH"; } } diff --git a/lib/ui/select.ts b/lib/ui/select.ts index 86e087d1..562f6f37 100644 --- a/lib/ui/select.ts +++ b/lib/ui/select.ts @@ -53,6 +53,7 @@ const ANSI_REGEX = new RegExp("\\x1b\\[[0-9;]*m", "g"); const ANSI_LEADING_REGEX = new RegExp("^\\x1b\\[[0-9;]*m"); const CSI_FINAL_KEYS = new Set(["A", "B", "C", "D", "H", "F"]); const CSI_TILDE_PATTERN = /^\d+~$/; +const CONTROL_CHAR_REGEX = /[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f]/g; export interface PendingInputSequence { value: string; @@ -105,6 +106,10 @@ function stripAnsi(input: string): string { return input.replace(ANSI_REGEX, ""); } +function sanitizeDisplayText(input: string): string { + return stripAnsi(input).replace(CONTROL_CHAR_REGEX, ""); +} + function truncateAnsi(input: string, maxVisibleChars: number): string { if (maxVisibleChars <= 0) return ""; const visible = stripAnsi(input); @@ -468,9 +473,11 @@ export async function select(items: MenuItem[], options: SelectOptions) const selectedGlyphColor = theme?.colors.success ?? ANSI.green; const selectedChip = selectedLabelStart(); - writeLine(`${border}+${reset} ${heading}${truncateAnsi(options.message, Math.max(1, columns - 4))}${reset}`); + const safeMessage = sanitizeDisplayText(options.message); + writeLine(`${border}+${reset} ${heading}${truncateAnsi(safeMessage, Math.max(1, columns - 4))}${reset}`); if (subtitleText) { - writeLine(` ${muted}${truncateAnsi(subtitleText, Math.max(1, columns - 2))}${reset}`); + const safeSubtitle = sanitizeDisplayText(subtitleText); + writeLine(` ${muted}${truncateAnsi(safeSubtitle, Math.max(1, columns - 2))}${reset}`); } writeLine(""); @@ -485,20 +492,26 @@ export async function select(items: MenuItem[], options: SelectOptions) } if (item.kind === "heading") { - const headingText = truncateAnsi(`${muted}${item.label}${reset}`, Math.max(1, columns - 2)); + const safeHeading = sanitizeDisplayText(item.label); + const headingText = truncateAnsi(`${muted}${safeHeading}${reset}`, Math.max(1, columns - 2)); writeLine(` ${headingText}`); continue; } const selected = itemIndex === cursor; + const safeLabel = sanitizeDisplayText(item.label); + const safeSelectedLabel = item.selectedLabel ? sanitizeDisplayText(item.selectedLabel) : safeLabel; + const safeHintLines = item.hint + ? item.hint.split("\n").map((line) => sanitizeDisplayText(line)).filter((line) => line.length > 0) + : []; if (selected) { const selectedText = item.selectedLabel - ? stripAnsi(item.selectedLabel) + ? safeSelectedLabel : item.disabled ? item.hideUnavailableSuffix - ? stripAnsi(item.label) - : `${stripAnsi(item.label)} (unavailable)` - : stripAnsi(item.label); + ? safeLabel + : `${safeLabel} (unavailable)` + : safeLabel; if (focusStyle === "row-invert") { const rowText = `${selectedGlyph} ${selectedText}`; const focusedRow = theme @@ -509,8 +522,8 @@ export async function select(items: MenuItem[], options: SelectOptions) const selectedLabel = `${selectedChip}${selectedText}${reset}`; writeLine(` ${selectedGlyphColor}${selectedGlyph}${reset} ${truncateAnsi(selectedLabel, Math.max(1, columns - 4))}`); } - if (item.hint) { - const detailLines = item.hint.split("\n").slice(0, 3); + if (safeHintLines.length > 0) { + const detailLines = safeHintLines.slice(0, 3); for (const detailLine of detailLines) { const detail = truncateAnsi(detailLine, Math.max(1, columns - 8)); writeLine(` ${muted}${detail}${reset}`); @@ -520,12 +533,12 @@ export async function select(items: MenuItem[], options: SelectOptions) const itemColor = codexColorCode(item.color); const labelText = item.disabled ? item.hideUnavailableSuffix - ? `${muted}${item.label}${reset}` - : `${muted}${item.label} (unavailable)${reset}` - : `${itemColor}${item.label}${reset}`; + ? `${muted}${safeLabel}${reset}` + : `${muted}${safeLabel} (unavailable)${reset}` + : `${itemColor}${safeLabel}${reset}`; writeLine(` ${muted}${unselectedGlyph}${reset} ${truncateAnsi(labelText, Math.max(1, columns - 4))}`); - if (item.hint && (options.showHintsForUnselected ?? true)) { - const detailLines = item.hint.split("\n").slice(0, 2); + if (safeHintLines.length > 0 && (options.showHintsForUnselected ?? true)) { + const detailLines = safeHintLines.slice(0, 2); for (const detailLine of detailLines) { const detail = truncateAnsi(`${muted}${detailLine}${reset}`, Math.max(1, columns - 8)); writeLine(` ${detail}`); diff --git a/test/codex-multi-auth-sync.test.ts b/test/codex-multi-auth-sync.test.ts index aa0fc558..e41672f3 100644 --- a/test/codex-multi-auth-sync.test.ts +++ b/test/codex-multi-auth-sync.test.ts @@ -3,6 +3,7 @@ import * as fs from "node:fs"; import * as os from "node:os"; import { join } from "node:path"; import { findProjectRoot, getProjectStorageKey, getProjectStorageKeyCandidates } from "../lib/storage/paths.js"; +import type { AccountStorageV3 } from "../lib/storage.js"; vi.mock("../lib/logger.js", () => ({ logWarn: vi.fn(), @@ -13,6 +14,7 @@ vi.mock("node:fs", async () => { return { ...actual, existsSync: vi.fn(), + readdirSync: vi.fn(() => []), readFileSync: vi.fn(), statSync: vi.fn(), }; @@ -64,6 +66,9 @@ vi.mock("../lib/storage.js", () => ({ 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 = { @@ -87,6 +92,18 @@ describe("codex-multi-auth sync", () => { beforeEach(() => { 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), @@ -389,6 +406,49 @@ describe("codex-multi-auth sync", () => { }); }); + it("prefers a later root with project-scoped accounts over an earlier settings-only root", async () => { + process.env.USERPROFILE = "C:\\Users\\tester"; + process.env.HOME = "C:\\Users\\tester"; + const projectRoot = findProjectRoot(process.cwd()) ?? process.cwd(); + const candidateKey = getProjectStorageKeyCandidates(projectRoot)[0] ?? "missing"; + const firstRootSettings = join("C:\\Users\\tester", "DevTools", "config", "codex", "multi-auth", "settings.json"); + const secondProjectsDir = join("C:\\Users\\tester", ".codex", "multi-auth", "projects"); + const repoPackageJson = join(process.cwd(), "package.json"); + const secondProjectPath = join( + "C:\\Users\\tester", + ".codex", + "multi-auth", + "projects", + candidateKey, + "openai-codex-accounts.json", + ); + mockExistsSync.mockImplementation((candidate) => { + const pathValue = String(candidate); + return pathValue === firstRootSettings || pathValue === secondProjectPath || pathValue === repoPackageJson; + }); + mockReaddirSync.mockImplementation((candidate) => { + if (String(candidate) === secondProjectsDir) { + return [ + { + name: candidateKey, + isDirectory: () => true, + }, + ] as ReturnType; + } + return []; + }); + + const { getCodexMultiAuthSourceRootDir, resolveCodexMultiAuthAccountsSource } = + await import("../lib/codex-multi-auth-sync.js"); + expect(getCodexMultiAuthSourceRootDir()).toBe(join("C:\\Users\\tester", ".codex", "multi-auth")); + const resolved = resolveCodexMultiAuthAccountsSource(process.cwd()); + expect(resolved).toEqual({ + rootDir: join("C:\\Users\\tester", ".codex", "multi-auth"), + accountsPath: secondProjectPath, + scope: "project", + }); + }); + it("fails preview 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; @@ -1099,6 +1159,54 @@ describe("codex-multi-auth sync", () => { }); }); + it("removes synced accounts that overlap preserved local accounts", async () => { + const storageModule = await import("../lib/storage.js"); + let persisted: AccountStorageV3 | null = null; + 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, + }, + ], + }, + vi.fn(async (next) => { + persisted = next; + }), + ), + ); + + const { cleanupCodexMultiAuthSyncedOverlaps } = await import("../lib/codex-multi-auth-sync.js"); + await expect(cleanupCodexMultiAuthSyncedOverlaps()).resolves.toEqual({ + before: 2, + after: 1, + removed: 1, + updated: 0, + }); + expect(persisted?.accounts).toHaveLength(1); + expect(persisted?.accounts[0]?.accountId).toBe("org-local"); + }); + it("remaps active indices when synced overlap cleanup reorders accounts", async () => { const storageModule = await import("../lib/storage.js"); let persisted: AccountStorageV3 | null = null; diff --git a/test/paths.test.ts b/test/paths.test.ts index 379724fa..b9440bf5 100644 --- a/test/paths.test.ts +++ b/test/paths.test.ts @@ -198,6 +198,15 @@ describe("Storage Paths Module", () => { findProjectRoot("/a/b/c/d/e"); expect(mockedExistsSync.mock.calls.length).toBeGreaterThan(callCount); }); + + it("returns the filesystem root when it contains a project marker", () => { + const root = path.parse(process.cwd()).root; + mockedExistsSync.mockImplementation((p) => { + return typeof p === "string" && p === path.join(root, ".git"); + }); + const nestedPath = path.join(root, "workspace", "repo", "src"); + expect(findProjectRoot(nestedPath)).toBe(root); + }); }); describe("resolvePath", () => { From 4d5032bc77731c53403e07b1157088d27aba081c Mon Sep 17 00:00:00 2001 From: ndycode Date: Sun, 8 Mar 2026 10:40:50 +0800 Subject: [PATCH 47/81] docs: add codex-doctor install note --- README.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/README.md b/README.md index c9d5a735..79814b95 100644 --- a/README.md +++ b/README.md @@ -68,6 +68,8 @@ npx -y oc-chatgpt-multi-auth@latest This writes the config to `~/.config/opencode/opencode.json`, backs up existing config, and clears the plugin cache. +After install, run `codex-doctor` once to confirm your local auth and account health are ready. + > Want legacy config (OpenCode v1.0.209 and below)? Add `--legacy` flag. **Option C: Manual setup** From 0679ca258f02c0eeb6c1e41931aa38ee6a1851e2 Mon Sep 17 00:00:00 2001 From: ndycode Date: Sun, 8 Mar 2026 11:22:03 +0800 Subject: [PATCH 48/81] fix: harden sync and maintenance race paths Address the remaining Greptile findings around Windows file-lock fallbacks, flagged-account identity precision, temp token hygiene, config-lock I/O, and missing regression coverage. Co-authored-by: Codex --- index.ts | 34 +++++- lib/codex-multi-auth-sync.ts | 112 ++++++++++++------- lib/config.ts | 70 +++++++----- lib/storage.ts | 28 ++--- lib/ui/select.ts | 21 +++- test/codex-multi-auth-sync.race.test.ts | 42 +++++++ test/codex-multi-auth-sync.test.ts | 136 +++++++++++++++++++---- test/index.test.ts | 141 ++++++++++++++++++++++++ test/plugin-config.race.test.ts | 77 +++++++++---- test/plugin-config.test.ts | 74 +++++++------ test/storage.race.test.ts | 78 +++++++++++++ test/storage.test.ts | 56 ++++++++++ test/ui-select.test.ts | 11 +- 13 files changed, 713 insertions(+), 167 deletions(-) diff --git a/index.ts b/index.ts index a0a73482..11ed752e 100644 --- a/index.ts +++ b/index.ts @@ -3982,7 +3982,20 @@ while (attempted.size < Math.max(1, accountCount)) { if (removedTargets.length === 0) { return; } - if (removedTargets.length !== targetKeySet.size) { + const matchedKeySet = new Set( + removedTargets.map((entry) => + getSyncRemovalTargetKey({ + refreshToken: entry.account.refreshToken, + organizationId: entry.account.organizationId, + accountId: entry.account.accountId, + }), + ), + ); + if ( + removedTargets.length !== targetKeySet.size || + matchedKeySet.size !== targetKeySet.size || + [...targetKeySet].some((key) => !matchedKeySet.has(key)) + ) { throw new Error("Selected accounts changed before removal. Re-run sync and confirm again."); } const activeAccountIdentity = { @@ -4041,13 +4054,26 @@ while (attempted.size < Math.max(1, accountCount)) { if (removedTargets.length === 0) { return; } - const removedRefreshTokens = new Set( - removedTargets.map((entry) => entry.account?.refreshToken).filter((token): token is string => Boolean(token)), + const removedFlaggedKeys = new Set( + removedTargets.map((entry) => + getSyncRemovalTargetKey({ + refreshToken: entry.account.refreshToken, + organizationId: entry.account.organizationId, + accountId: entry.account.accountId, + }), + ), ); await saveFlaggedAccounts({ version: 1, accounts: currentFlaggedStorage.accounts.filter( - (flagged) => !removedRefreshTokens.has(flagged.refreshToken), + (flagged) => + !removedFlaggedKeys.has( + getSyncRemovalTargetKey({ + refreshToken: flagged.refreshToken, + organizationId: flagged.organizationId, + accountId: flagged.accountId, + }), + ), ), }); invalidateAccountManagerCache(); diff --git a/lib/codex-multi-auth-sync.ts b/lib/codex-multi-auth-sync.ts index 57b48266..de4f6f6f 100644 --- a/lib/codex-multi-auth-sync.ts +++ b/lib/codex-multi-auth-sync.ts @@ -23,6 +23,8 @@ const EXTERNAL_ACCOUNT_FILE_NAMES = [ ]; 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 = 60 * 60 * 1000; export interface CodexMultiAuthResolvedSource { rootDir: string; @@ -169,10 +171,48 @@ async function withNormalizedImportFile( // 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 }); - const tempDir = await fs.mkdtemp(join(secureTempRoot, "oc-chatgpt-multi-auth-sync-")); + 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) { + const code = (error as NodeJS.ErrnoException).code; + if (code === "ENOENT") { + continue; + } + const message = error instanceof Error ? error.message : String(error); + 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, @@ -769,26 +809,43 @@ export async function loadCodexMultiAuthSourceStorage( }; } -async function loadPreparedCodexMultiAuthSourceStorage( - projectPath = process.cwd(), -): Promise { - const resolved = await loadCodexMultiAuthSourceStorage(projectPath); - const currentStorage = await withAccountStorageTransaction((current) => Promise.resolve(current)); - const preparedStorage = filterSourceAccountsAgainstExistingEmails( - resolved.storage, - currentStorage?.accounts ?? [], - ); +function createEmptyAccountStorage(): AccountStorageV3 { return { - ...resolved, - storage: preparedStorage, + version: 3, + accounts: [], + activeIndex: 0, + activeIndexByFamily: {}, }; } +async function prepareCodexMultiAuthPreviewStorage( + resolved: CodexMultiAuthResolvedSource & { storage: AccountStorageV3 }, +): Promise { + return withAccountStorageTransaction((current) => { + const existing = current ?? createEmptyAccountStorage(); + const preparedStorage = filterSourceAccountsAgainstExistingEmails( + resolved.storage, + existing.accounts, + ); + const maxAccounts = getSyncCapacityLimit(); + if (Number.isFinite(maxAccounts)) { + const details = computeSyncCapacityDetails(resolved, preparedStorage, existing, maxAccounts); + if (details) { + throw new CodexMultiAuthSyncCapacityError(details); + } + } + return Promise.resolve({ + ...resolved, + storage: preparedStorage, + }); + }); +} + export async function previewSyncFromCodexMultiAuth( projectPath = process.cwd(), ): Promise { - const resolved = await loadPreparedCodexMultiAuthSourceStorage(projectPath); - await assertSyncWithinCapacity(resolved); + const loadedSource = await loadCodexMultiAuthSourceStorage(projectPath); + const resolved = await prepareCodexMultiAuthPreviewStorage(loadedSource); const preview = await withNormalizedImportFile( resolved.storage, (filePath) => previewImportAccounts(filePath), @@ -804,8 +861,7 @@ export async function previewSyncFromCodexMultiAuth( export async function syncFromCodexMultiAuth( projectPath = process.cwd(), ): Promise { - const resolved = await loadPreparedCodexMultiAuthSourceStorage(projectPath); - await assertSyncWithinCapacity(resolved); + const resolved = await loadCodexMultiAuthSourceStorage(projectPath); const result: ImportAccountsResult = await withNormalizedImportFile( tagSyncedAccounts(resolved.storage), (filePath) => { @@ -1089,27 +1145,3 @@ export async function cleanupCodexMultiAuthSyncedOverlaps(): Promise { - // Unlimited remains the default, but a finite override keeps the sync prune/capacity - // path testable and available for operators who intentionally enforce a soft cap. - const maxAccounts = getSyncCapacityLimit(); - if (!Number.isFinite(maxAccounts)) { - return; - } - const details = await withAccountStorageTransaction((current) => { - const existing = current ?? { - version: 3 as const, - accounts: [], - activeIndex: 0, - activeIndexByFamily: {}, - }; - return Promise.resolve(computeSyncCapacityDetails(resolved, resolved.storage, existing, maxAccounts)); - }); - - if (details) { - throw new CodexMultiAuthSyncCapacityError(details); - } -} diff --git a/lib/config.ts b/lib/config.ts index 3bcdb948..53e04471 100644 --- a/lib/config.ts +++ b/lib/config.ts @@ -1,4 +1,4 @@ -import { readFileSync, existsSync, mkdirSync, renameSync, unlinkSync, writeFileSync } from "node:fs"; +import { readFileSync, existsSync, promises as fs } from "node:fs"; import { dirname, join } from "node:path"; import { homedir } from "node:os"; import type { PluginConfig } from "./types.js"; @@ -72,9 +72,7 @@ export function loadPluginConfig(): PluginConfig { return DEFAULT_CONFIG; } - const fileContent = readFileSync(CONFIG_PATH, "utf-8"); - const normalizedFileContent = stripUtf8Bom(fileContent); - const userConfig = JSON.parse(normalizedFileContent) as unknown; + const userConfig = readRawPluginConfig(false) as unknown; const hasFallbackEnvOverride = process.env.CODEX_AUTH_FALLBACK_UNSUPPORTED_MODEL !== undefined || process.env.CODEX_AUTH_FALLBACK_GPT53_TO_GPT52 !== undefined; @@ -131,12 +129,34 @@ function readRawPluginConfig(recoverInvalid = false): RawPluginConfig { } } +async function readRawPluginConfigAsync(recoverInvalid = false): Promise { + if (!existsSync(CONFIG_PATH)) { + return {}; + } + + try { + const fileContent = await fs.readFile(CONFIG_PATH, "utf-8"); + const normalizedFileContent = stripUtf8Bom(fileContent); + const parsed = JSON.parse(normalizedFileContent) as unknown; + if (!isRecord(parsed)) { + throw new Error("Plugin config root must be a JSON object"); + } + return { ...parsed }; + } catch (error) { + if (recoverInvalid) { + logWarn(`Failed to read raw plugin config from ${CONFIG_PATH}: ${(error as Error).message}`); + return {}; + } + throw error; + } +} + export async function savePluginConfigMutation( mutate: (current: RawPluginConfig) => RawPluginConfig, options: { recoverInvalidCurrent?: boolean } = {}, ): Promise { - await withPluginConfigLock(() => { - const current = readRawPluginConfig(options.recoverInvalidCurrent === true); + await withPluginConfigLock(async () => { + const current = await readRawPluginConfigAsync(options.recoverInvalidCurrent === true); const next = mutate({ ...current }); if (!isRecord(next)) { @@ -146,13 +166,13 @@ export async function savePluginConfigMutation( const tempPath = `${CONFIG_PATH}.${process.pid}.${Date.now()}.tmp`; let tempFilePresent = false; try { - writeFileSync(tempPath, `${JSON.stringify(next, null, 2)}\n`, { + await fs.writeFile(tempPath, `${JSON.stringify(next, null, 2)}\n`, { encoding: "utf-8", mode: 0o600, }); tempFilePresent = true; try { - renameSync(tempPath, CONFIG_PATH); + await fs.rename(tempPath, CONFIG_PATH); tempFilePresent = false; return; } catch (error) { @@ -165,12 +185,12 @@ export async function savePluginConfigMutation( const backupPath = `${CONFIG_PATH}.${process.pid}.${Date.now()}.bak`; let backupMoved = false; try { - renameSync(CONFIG_PATH, backupPath); + await fs.rename(CONFIG_PATH, backupPath); backupMoved = true; - renameSync(tempPath, CONFIG_PATH); + await fs.rename(tempPath, CONFIG_PATH); tempFilePresent = false; try { - unlinkSync(backupPath); + await fs.unlink(backupPath); } catch { // best effort backup cleanup } @@ -179,7 +199,7 @@ export async function savePluginConfigMutation( if (backupMoved) { try { if (!existsSync(CONFIG_PATH)) { - renameSync(backupPath, CONFIG_PATH); + await fs.rename(backupPath, CONFIG_PATH); backupMoved = false; } } catch { @@ -190,7 +210,7 @@ export async function savePluginConfigMutation( } finally { if (backupMoved) { try { - unlinkSync(backupPath); + await fs.unlink(backupPath); } catch { // best effort backup cleanup } @@ -202,7 +222,7 @@ export async function savePluginConfigMutation( } finally { if (tempFilePresent) { try { - unlinkSync(tempPath); + await fs.unlink(tempPath); } catch { // best effort temp cleanup } @@ -233,7 +253,7 @@ function isProcessAlive(pid: number): boolean { } } -function tryRecoverStalePluginConfigLock(rawLockContents: string): boolean { +async function tryRecoverStalePluginConfigLock(rawLockContents: string): Promise { const lockOwnerPid = Number.parseInt(rawLockContents.trim(), 10); if ( !Number.isFinite(lockOwnerPid) || @@ -245,17 +265,17 @@ function tryRecoverStalePluginConfigLock(rawLockContents: string): boolean { const staleLockPath = `${CONFIG_LOCK_PATH}.${lockOwnerPid}.${process.pid}.${Date.now()}.stale`; try { - renameSync(CONFIG_LOCK_PATH, staleLockPath); + await fs.rename(CONFIG_LOCK_PATH, staleLockPath); } catch { return false; } try { - const movedLockContents = readFileSync(staleLockPath, "utf-8"); + const movedLockContents = await fs.readFile(staleLockPath, "utf-8"); if (movedLockContents !== rawLockContents) { try { if (!existsSync(CONFIG_LOCK_PATH)) { - renameSync(staleLockPath, CONFIG_LOCK_PATH); + await fs.rename(staleLockPath, CONFIG_LOCK_PATH); } } catch { // best effort restore when a live lock was moved unexpectedly @@ -265,7 +285,7 @@ function tryRecoverStalePluginConfigLock(rawLockContents: string): boolean { } catch { try { if (!existsSync(CONFIG_LOCK_PATH)) { - renameSync(staleLockPath, CONFIG_LOCK_PATH); + await fs.rename(staleLockPath, CONFIG_LOCK_PATH); } } catch { // best effort restore when stale-lock verification fails @@ -274,7 +294,7 @@ function tryRecoverStalePluginConfigLock(rawLockContents: string): boolean { } try { - unlinkSync(staleLockPath); + await fs.unlink(staleLockPath); } catch { // best effort stale-lock cleanup } @@ -282,11 +302,11 @@ function tryRecoverStalePluginConfigLock(rawLockContents: string): boolean { } async function withPluginConfigLock(fn: () => T | Promise): Promise { - mkdirSync(dirname(CONFIG_PATH), { recursive: true }); + await fs.mkdir(dirname(CONFIG_PATH), { recursive: true }); const deadline = Date.now() + 2_000; while (true) { try { - writeFileSync(CONFIG_LOCK_PATH, `${process.pid}`, { encoding: "utf-8", flag: "wx" }); + await fs.writeFile(CONFIG_LOCK_PATH, `${process.pid}`, { encoding: "utf-8", flag: "wx" }); break; } catch (error) { const code = (error as NodeJS.ErrnoException).code; @@ -297,8 +317,8 @@ async function withPluginConfigLock(fn: () => T | Promise): Promise { } if (code === "EEXIST") { try { - const rawLockContents = readFileSync(CONFIG_LOCK_PATH, "utf-8"); - if (tryRecoverStalePluginConfigLock(rawLockContents)) { + const rawLockContents = await fs.readFile(CONFIG_LOCK_PATH, "utf-8"); + if (await tryRecoverStalePluginConfigLock(rawLockContents)) { continue; } } catch { @@ -313,7 +333,7 @@ async function withPluginConfigLock(fn: () => T | Promise): Promise { return await fn(); } finally { try { - unlinkSync(CONFIG_LOCK_PATH); + await fs.unlink(CONFIG_LOCK_PATH); } catch { // best effort cleanup } diff --git a/lib/storage.ts b/lib/storage.ts index 42a234f1..073666a3 100644 --- a/lib/storage.ts +++ b/lib/storage.ts @@ -603,7 +603,7 @@ async function loadDuplicateCleanupSourceStorage(): Promise { throw new Error("Invalid raw storage snapshot for duplicate cleanup."); } catch (error) { const code = (error as NodeJS.ErrnoException).code; - if (code === "ENOENT") { + if (code === "ENOENT" || code === "EBUSY" || code === "EACCES" || code === "EPERM") { return fallback ?? { version: 3, accounts: [], @@ -1441,21 +1441,23 @@ export async function exportAccounts(filePath: string, force = true): Promise { - const resolvedPath = resolvePath(filePath); + await withStorageLock(async () => { + const resolvedPath = resolvePath(filePath); - if (!force && existsSync(resolvedPath)) { - throw new Error(`File already exists: ${resolvedPath}`); - } + if (!force && existsSync(resolvedPath)) { + throw new Error(`File already exists: ${resolvedPath}`); + } - const storagePath = getStoragePath(); - if (!existsSync(storagePath)) { - throw new Error("No accounts to back up"); - } + 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 }); + 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 }); + }); } /** diff --git a/lib/ui/select.ts b/lib/ui/select.ts index 562f6f37..c4b57c70 100644 --- a/lib/ui/select.ts +++ b/lib/ui/select.ts @@ -78,14 +78,31 @@ function writeTuiAudit(event: Record): void { } } -function sanitizeAuditValue(key: string, value: unknown): unknown { +const AUDIT_REDACTED_STRING_KEYS = new Set([ + "label", + "message", + "utf8", + "bytesHex", + "token", + "normalizedInput", + "pending", + "hint", + "subtitle", +]); + +const AUDIT_SECRET_LIKE_PATTERN = /\b(?:Bearer\s+)?[A-Za-z0-9._-]{24,}(?:\.[A-Za-z0-9._-]{8,})*\b/; + +export function sanitizeAuditValue(key: string, value: unknown): unknown { if (typeof value === "string") { - if (["label", "message", "utf8", "bytesHex", "token", "normalizedInput", "pending"].includes(key)) { + if (AUDIT_REDACTED_STRING_KEYS.has(key)) { return `[redacted:${value.length}]`; } if (value.includes("@")) { return "[redacted-email]"; } + if (AUDIT_SECRET_LIKE_PATTERN.test(value)) { + return "[redacted-token]"; + } return value; } if (Array.isArray(value)) { diff --git a/test/codex-multi-auth-sync.race.test.ts b/test/codex-multi-auth-sync.race.test.ts index 5599e52b..87174dec 100644 --- a/test/codex-multi-auth-sync.race.test.ts +++ b/test/codex-multi-auth-sync.race.test.ts @@ -72,4 +72,46 @@ describe("codex-multi-auth sync race paths", () => { new Set(["rt-source-1"]), ); }); + + it("keeps synced-overlap cleanup stable under concurrent cleanup runs", async () => { + const { cleanupCodexMultiAuthSyncedOverlaps } = await import("../lib/codex-multi-auth-sync.js"); + const storageModule = await import("../lib/storage.js"); + + await storageModule.saveAccounts({ + version: 3, + activeIndex: 0, + activeIndexByFamily: {}, + accounts: [ + { + accountId: "org-local", + organizationId: "org-local", + accountIdSource: "org", + email: "shared@example.com", + refreshToken: "rt-local", + addedAt: 2, + lastUsed: 2, + }, + { + accountId: "org-sync", + organizationId: "org-sync", + accountIdSource: "org", + accountTags: ["codex-multi-auth-sync"], + email: "shared@example.com", + refreshToken: "rt-sync", + addedAt: 1, + lastUsed: 1, + }, + ], + }); + + const results = await Promise.allSettled([ + cleanupCodexMultiAuthSyncedOverlaps(), + cleanupCodexMultiAuthSyncedOverlaps(), + ]); + const loaded = await storageModule.loadAccounts(); + + expect(results.every((result) => result.status === "fulfilled")).toBe(true); + expect(loaded?.accounts).toHaveLength(1); + expect(loaded?.accounts[0]?.accountId).toBe("org-local"); + }); }); diff --git a/test/codex-multi-auth-sync.test.ts b/test/codex-multi-auth-sync.test.ts index e41672f3..144c3e4b 100644 --- a/test/codex-multi-auth-sync.test.ts +++ b/test/codex-multi-auth-sync.test.ts @@ -88,8 +88,30 @@ describe("codex-multi-auth sync", () => { ); }); }; + 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(() => { + beforeEach(async () => { vi.resetModules(); vi.clearAllMocks(); mockExistsSync.mockReset(); @@ -108,6 +130,23 @@ describe("codex-multi-auth sync", () => { 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.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.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; }); @@ -475,6 +514,47 @@ describe("codex-multi-auth sync", () => { } }); + 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"); + 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"); + const oldTime = new Date(Date.now() - (65 * 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", + }); + + await expect(fs.promises.stat(staleDir)).rejects.toThrow(); + } finally { + 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; @@ -546,10 +626,11 @@ describe("codex-multi-auth sync", () => { ]); return { imported: 1, skipped: 0, total: 1 }; }); - vi.mocked(storageModule.importAccounts).mockImplementationOnce(async (filePath) => { + vi.mocked(storageModule.importAccounts).mockImplementationOnce(async (filePath, _options, prepare) => { 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([ + 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 { @@ -818,27 +899,36 @@ describe("codex-multi-auth sync", () => { ); const storageModule = await import("../lib/storage.js"); - vi.mocked(storageModule.withAccountStorageTransaction).mockImplementationOnce(async (handler) => - handler( + const currentStorage = { + version: 3 as const, + activeIndex: 0, + activeIndexByFamily: {}, + accounts: [ { - 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, - }, - ], + accountId: "org-existing", + organizationId: "org-existing", + accountIdSource: "org", + email: "existing@example.com", + refreshToken: "rt-existing", + addedAt: 10, + lastUsed: 10, }, - vi.fn(async () => {}), - ), - ); + ], + }; + 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( diff --git a/test/index.test.ts b/test/index.test.ts index 1bc1a2d1..85d6f669 100644 --- a/test/index.test.ts +++ b/test/index.test.ts @@ -3273,6 +3273,147 @@ describe("OpenAIOAuthPlugin persistAccountPool", () => { } }); + it("removes only exact flagged identities during sync prune cleanup", async () => { + const cliModule = await import("../lib/cli.js"); + const storageModule = await import("../lib/storage.js"); + const syncModule = await import("../lib/codex-multi-auth-sync.js"); + const configModule = await import("../lib/config.js"); + const confirmModule = await import("../lib/ui/confirm.js"); + + const tempDir = await fs.mkdtemp(join(tmpdir(), "oc-sync-prune-flagged-")); + try { + mockStorage.accounts = [ + { + accountId: "org-prune", + organizationId: "org-prune", + accountIdSource: "org", + email: "prune@example.com", + refreshToken: "refresh-shared", + }, + { + accountId: "org-keep", + organizationId: "org-keep", + accountIdSource: "org", + email: "keep@example.com", + refreshToken: "refresh-keep", + }, + ]; + mockStorage.activeIndex = 1; + mockStorage.activeIndexByFamily = { codex: 1 }; + + vi.mocked(cliModule.promptLoginMode) + .mockResolvedValueOnce({ mode: "experimental-sync-now" }) + .mockResolvedValueOnce({ mode: "cancel" }); + vi.mocked(cliModule.promptCodexMultiAuthSyncPrune).mockResolvedValueOnce([0]); + vi.mocked(configModule.getSyncFromCodexMultiAuthEnabled).mockReturnValue(true); + vi.mocked(confirmModule.confirm).mockReset(); + vi.mocked(confirmModule.confirm).mockResolvedValue(true); + vi.mocked(storageModule.loadFlaggedAccounts).mockReset(); + vi.mocked(storageModule.saveFlaggedAccounts).mockReset(); + vi.mocked(storageModule.saveFlaggedAccounts).mockResolvedValue(undefined); + vi.mocked(storageModule.loadFlaggedAccounts).mockResolvedValue({ + version: 1, + accounts: [ + { + refreshToken: "refresh-shared", + organizationId: "org-other", + accountId: "org-other", + flaggedAt: 1, + }, + { + refreshToken: "refresh-shared", + organizationId: "org-prune", + accountId: "org-prune", + flaggedAt: 2, + }, + { + refreshToken: "refresh-keep", + organizationId: "org-keep", + accountId: "org-keep", + flaggedAt: 3, + }, + ], + }); + + vi.mocked(storageModule.createTimestampedBackupPath).mockImplementation((prefix?: string) => + join(tempDir, `${prefix ?? "codex-backup"}.json`), + ); + vi.mocked(storageModule.exportAccounts).mockImplementation(async (filePath: string) => { + await fs.writeFile( + filePath, + JSON.stringify({ + version: mockStorage.version, + activeIndex: mockStorage.activeIndex, + activeIndexByFamily: { ...mockStorage.activeIndexByFamily }, + accounts: mockStorage.accounts.map((account) => ({ ...account })), + }), + "utf8", + ); + }); + vi.mocked(storageModule.saveAccounts).mockImplementation(async (nextStorage) => { + mockStorage.version = nextStorage.version; + mockStorage.activeIndex = nextStorage.activeIndex; + mockStorage.activeIndexByFamily = { ...nextStorage.activeIndexByFamily }; + mockStorage.accounts = nextStorage.accounts.map((account) => ({ ...account })); + }); + + const capacityError = new syncModule.CodexMultiAuthSyncCapacityError({ + rootDir: tempDir, + accountsPath: join(tempDir, "openai-codex-accounts.json"), + scope: "global", + currentCount: 2, + sourceCount: 2, + sourceDedupedTotal: 3, + dedupedTotal: 3, + maxAccounts: 2, + needToRemove: 1, + importableNewAccounts: 1, + skippedOverlaps: 1, + suggestedRemovals: [ + { + index: 0, + email: "prune@example.com", + accountLabel: "Workspace prune", + isCurrentAccount: false, + score: 180, + reason: "disabled", + }, + ], + }); + + vi.mocked(syncModule.previewSyncFromCodexMultiAuth).mockRejectedValueOnce(capacityError); + + const mockClient = createMockClient(); + const { OpenAIOAuthPlugin } = await import("../index.js"); + const plugin = (await OpenAIOAuthPlugin({ client: mockClient } as never)) as unknown as PluginType; + const autoMethod = plugin.auth.methods[0] as unknown as { + authorize: (inputs?: Record) => Promise<{ instructions: string }>; + }; + + const authResult = await autoMethod.authorize(); + expect(authResult.instructions).toBe("Authentication cancelled"); + expect(vi.mocked(storageModule.saveFlaggedAccounts)).toHaveBeenCalledWith({ + version: 1, + accounts: [ + { + refreshToken: "refresh-shared", + organizationId: "org-other", + accountId: "org-other", + flaggedAt: 1, + }, + { + refreshToken: "refresh-keep", + organizationId: "org-keep", + accountId: "org-keep", + flaggedAt: 3, + }, + ], + }); + } finally { + await fs.rm(tempDir, { recursive: true, force: true }); + } + }); + it("remaps sync-prune active pointers by exact identity when sibling accounts share fragments", async () => { const cliModule = await import("../lib/cli.js"); const storageModule = await import("../lib/storage.js"); diff --git a/test/plugin-config.race.test.ts b/test/plugin-config.race.test.ts index 3a6e86a9..b553f4ad 100644 --- a/test/plugin-config.race.test.ts +++ b/test/plugin-config.race.test.ts @@ -9,11 +9,14 @@ vi.mock("node:fs", async () => { return { ...actual, existsSync: vi.fn(), - readFileSync: vi.fn(), - mkdirSync: vi.fn(), - renameSync: vi.fn(), - unlinkSync: vi.fn(), - writeFileSync: vi.fn(), + promises: { + ...actual.promises, + mkdir: vi.fn(), + readFile: vi.fn(), + rename: vi.fn(), + unlink: vi.fn(), + writeFile: vi.fn(), + }, }; }); @@ -27,21 +30,22 @@ vi.mock("../lib/logger.js", async () => { describe("plugin config lock retry", () => { const mockExistsSync = vi.mocked(fs.existsSync); - const mockReadFileSync = vi.mocked(fs.readFileSync); - const mockMkdirSync = vi.mocked(fs.mkdirSync); - const mockRenameSync = vi.mocked(fs.renameSync); - const mockUnlinkSync = vi.mocked(fs.unlinkSync); - const mockWriteFileSync = vi.mocked(fs.writeFileSync); + const mockMkdir = vi.mocked(fs.promises.mkdir); + const mockReadFile = vi.mocked(fs.promises.readFile); + const mockRename = vi.mocked(fs.promises.rename); + const mockUnlink = vi.mocked(fs.promises.unlink); + const mockWriteFile = vi.mocked(fs.promises.writeFile); const originalPlatform = process.platform; beforeEach(() => { vi.resetModules(); vi.clearAllMocks(); mockExistsSync.mockReturnValue(false); - mockReadFileSync.mockReturnValue("{}"); - mockMkdirSync.mockImplementation(() => undefined); - mockRenameSync.mockImplementation(() => undefined); - mockUnlinkSync.mockImplementation(() => undefined); + mockReadFile.mockResolvedValue("{}"); + mockMkdir.mockResolvedValue(undefined); + mockRename.mockResolvedValue(undefined); + mockUnlink.mockResolvedValue(undefined); + mockWriteFile.mockResolvedValue(undefined); }); afterEach(() => { @@ -53,7 +57,7 @@ describe("plugin config lock retry", () => { Object.defineProperty(process, "platform", { value: "win32" }); let lockAttempts = 0; - mockWriteFileSync.mockImplementation((filePath) => { + mockWriteFile.mockImplementation(async (filePath) => { const path = String(filePath); if (path.endsWith(".lock")) { lockAttempts += 1; @@ -76,7 +80,7 @@ describe("plugin config lock retry", () => { ).resolves.toBeUndefined(); expect(lockAttempts).toBeGreaterThanOrEqual(2); - expect(mockWriteFileSync).toHaveBeenCalled(); + expect(mockWriteFile).toHaveBeenCalled(); expect(vi.mocked(logger.logWarn)).not.toHaveBeenCalled(); }); @@ -96,7 +100,7 @@ describe("plugin config lock retry", () => { }); mockExistsSync.mockImplementation((filePath) => String(filePath) === lockPath && lockFilePresent); - mockReadFileSync.mockImplementation((filePath: fs.PathOrFileDescriptor) => { + mockReadFile.mockImplementation(async (filePath: fs.PathLike | number) => { const path = String(filePath); if (path === lockPath) { return lockAttempts === 1 ? "111" : "{}"; @@ -106,7 +110,7 @@ describe("plugin config lock retry", () => { } return "{}"; }); - mockRenameSync.mockImplementation((source, destination) => { + mockRename.mockImplementation(async (source, destination) => { if (String(source) === lockPath) { lockFilePresent = false; } @@ -115,7 +119,7 @@ describe("plugin config lock retry", () => { } return undefined; }); - mockWriteFileSync.mockImplementation((filePath) => { + mockWriteFile.mockImplementation(async (filePath) => { const path = String(filePath); if (path === lockPath) { lockAttempts += 1; @@ -137,7 +141,7 @@ describe("plugin config lock retry", () => { experimental: { syncFromCodexMultiAuth: { enabled: true } }, })), ).resolves.toBeUndefined(); - const lockRenameCalls = mockRenameSync.mock.calls.filter( + const lockRenameCalls = mockRename.mock.calls.filter( ([source, destination]) => String(source) === lockPath || String(destination) === lockPath, ); @@ -163,7 +167,7 @@ describe("plugin config lock retry", () => { }); mockExistsSync.mockImplementation((filePath) => String(filePath) === lockPath && lockFilePresent); - mockReadFileSync.mockImplementation((filePath: fs.PathOrFileDescriptor) => { + mockReadFile.mockImplementation(async (filePath: fs.PathLike | number) => { const pathValue = String(filePath); if (pathValue === lockPath) { return "111"; @@ -173,7 +177,7 @@ describe("plugin config lock retry", () => { } return "{}"; }); - mockRenameSync.mockImplementation((source, destination) => { + mockRename.mockImplementation(async (source, destination) => { if (String(source) === lockPath) { lockFilePresent = false; } @@ -182,7 +186,7 @@ describe("plugin config lock retry", () => { } return undefined; }); - mockWriteFileSync.mockImplementation((filePath) => { + mockWriteFile.mockImplementation(async (filePath) => { const pathValue = String(filePath); if (pathValue === lockPath) { lockAttempts += 1; @@ -205,9 +209,34 @@ describe("plugin config lock retry", () => { })), ).resolves.toBeUndefined(); expect(killSpy).toHaveBeenCalledWith(111, 0); - expect(mockRenameSync).toHaveBeenCalled(); + expect(mockRename).toHaveBeenCalled(); } finally { killSpy.mockRestore(); } }); + + it("fails cleanly when config rename stays EBUSY on Windows", async () => { + Object.defineProperty(process, "platform", { value: "win32" }); + const configPath = path.join(os.homedir(), ".opencode", "openai-codex-auth-config.json"); + + mockExistsSync.mockReturnValue(false); + mockRename.mockImplementation(async (source, destination) => { + if (String(source).includes(".tmp") && String(destination) === configPath) { + const error = new Error("busy") as NodeJS.ErrnoException; + error.code = "EBUSY"; + throw error; + } + return undefined; + }); + + const { savePluginConfigMutation } = await import("../lib/config.js"); + + await expect( + savePluginConfigMutation((current) => ({ + ...current, + experimental: { syncFromCodexMultiAuth: { enabled: true } }, + })), + ).rejects.toThrow("busy"); + expect(mockUnlink).toHaveBeenCalledWith(expect.stringContaining(".tmp")); + }); }); diff --git a/test/plugin-config.test.ts b/test/plugin-config.test.ts index 9cb9a410..181a8676 100644 --- a/test/plugin-config.test.ts +++ b/test/plugin-config.test.ts @@ -37,10 +37,14 @@ vi.mock('node:fs', async () => { ...actual, existsSync: vi.fn(), readFileSync: vi.fn(), - mkdirSync: vi.fn(), - renameSync: vi.fn(), - unlinkSync: vi.fn(), - writeFileSync: vi.fn(), + promises: { + ...actual.promises, + mkdir: vi.fn(), + readFile: vi.fn(), + rename: vi.fn(), + unlink: vi.fn(), + writeFile: vi.fn(), + }, }; }); @@ -56,10 +60,11 @@ vi.mock('../lib/logger.js', async () => { describe('Plugin Configuration', () => { const mockExistsSync = vi.mocked(fs.existsSync); const mockReadFileSync = vi.mocked(fs.readFileSync); - const mockMkdirSync = vi.mocked(fs.mkdirSync); - const mockRenameSync = vi.mocked(fs.renameSync); - const mockUnlinkSync = vi.mocked(fs.unlinkSync); - const mockWriteFileSync = vi.mocked(fs.writeFileSync); + const mockMkdir = vi.mocked(fs.promises.mkdir); + const mockReadFile = vi.mocked(fs.promises.readFile); + const mockRename = vi.mocked(fs.promises.rename); + const mockUnlink = vi.mocked(fs.promises.unlink); + const mockWriteFile = vi.mocked(fs.promises.writeFile); const envKeys = [ 'CODEX_MODE', 'CODEX_TUI_V2', @@ -84,10 +89,11 @@ describe('Plugin Configuration', () => { vi.clearAllMocks(); mockExistsSync.mockReturnValue(false); mockReadFileSync.mockReturnValue('{}'); - mockMkdirSync.mockImplementation(() => undefined); - mockRenameSync.mockImplementation(() => undefined); - mockUnlinkSync.mockImplementation(() => undefined); - mockWriteFileSync.mockImplementation(() => undefined); + mockMkdir.mockResolvedValue(undefined); + mockReadFile.mockResolvedValue('{}'); + mockRename.mockResolvedValue(undefined); + mockUnlink.mockResolvedValue(undefined); + mockWriteFile.mockResolvedValue(undefined); }); afterEach(() => { @@ -783,7 +789,7 @@ describe('Plugin Configuration', () => { it('persists sync-from-codex-multi-auth while preserving unrelated keys', async () => { mockExistsSync.mockReturnValue(true); - mockReadFileSync.mockReturnValue( + mockReadFile.mockResolvedValue( JSON.stringify({ codexMode: false, customKey: 'keep-me', @@ -792,15 +798,15 @@ describe('Plugin Configuration', () => { await setSyncFromCodexMultiAuthEnabled(true); - expect(mockMkdirSync).toHaveBeenCalledWith( + expect(mockMkdir).toHaveBeenCalledWith( path.join(os.homedir(), '.opencode'), { recursive: true }, ); - expect(mockWriteFileSync).toHaveBeenCalledTimes(2); + expect(mockWriteFile).toHaveBeenCalledTimes(2); // calls[0] is the lock file write, calls[1] is the temp config write - const [writtenPath, writtenContent] = mockWriteFileSync.mock.calls[1] ?? []; + const [writtenPath, writtenContent] = mockWriteFile.mock.calls[1] ?? []; expect(String(writtenPath)).toContain('.tmp'); - expect(mockRenameSync).toHaveBeenCalled(); + expect(mockRename).toHaveBeenCalled(); expect(JSON.parse(String(writtenContent))).toEqual({ codexMode: false, customKey: 'keep-me', @@ -810,7 +816,7 @@ describe('Plugin Configuration', () => { }, }, }); - expect(mockUnlinkSync).not.toHaveBeenCalledWith( + expect(mockUnlink).not.toHaveBeenCalledWith( path.join(os.homedir(), '.opencode', 'openai-codex-auth-config.json'), ); }); @@ -820,7 +826,7 @@ describe('Plugin Configuration', () => { await setSyncFromCodexMultiAuthEnabled(true); - const [, writtenContent] = mockWriteFileSync.mock.calls[1] ?? []; + const [, writtenContent] = mockWriteFile.mock.calls[1] ?? []; expect(JSON.parse(String(writtenContent))).toEqual({ experimental: { syncFromCodexMultiAuth: { @@ -832,15 +838,15 @@ describe('Plugin Configuration', () => { it('throws when mutating an invalid existing config file to avoid clobbering it', async () => { mockExistsSync.mockReturnValue(true); - mockReadFileSync.mockReturnValue('invalid json'); + mockReadFile.mockResolvedValue('invalid json'); await expect(savePluginConfigMutation((current) => current)).rejects.toThrow(); - expect(mockRenameSync).not.toHaveBeenCalled(); + expect(mockRename).not.toHaveBeenCalled(); }); it('rejects array roots when reading raw plugin config', async () => { mockExistsSync.mockReturnValue(true); - mockReadFileSync.mockReturnValue('[]'); + mockReadFile.mockResolvedValue('[]'); await expect(savePluginConfigMutation((current) => current)).rejects.toThrow( 'Plugin config root must be a JSON object', @@ -849,20 +855,18 @@ describe('Plugin Configuration', () => { it('throws when toggling sync setting on malformed config to preserve existing settings', async () => { mockExistsSync.mockReturnValue(true); - mockReadFileSync.mockReturnValue('invalid json'); + mockReadFile.mockResolvedValue('invalid json'); await expect(setSyncFromCodexMultiAuthEnabled(true)).rejects.toThrow(); - expect(mockRenameSync).not.toHaveBeenCalled(); + expect(mockRename).not.toHaveBeenCalled(); }); it('cleans up temp config files when the initial rename fails', async () => { mockExistsSync.mockReturnValue(false); - mockRenameSync.mockImplementation(() => { - throw Object.assign(new Error('rename failed'), { code: 'EACCES' }); - }); + mockRename.mockRejectedValueOnce(Object.assign(new Error('rename failed'), { code: 'EACCES' })); await expect(setSyncFromCodexMultiAuthEnabled(true)).rejects.toThrow('rename failed'); - expect(mockUnlinkSync).toHaveBeenCalledWith(expect.stringContaining('.tmp')); + expect(mockUnlink).toHaveBeenCalledWith(expect.stringContaining('.tmp')); }); it('cleans up temp config files when the Windows fallback retry fails', async () => { @@ -872,7 +876,7 @@ describe('Plugin Configuration', () => { String(filePath).endsWith('openai-codex-auth-config.json'), ); let renameCalls = 0; - mockRenameSync.mockImplementation((source, destination) => { + mockRename.mockImplementation(async (source, destination) => { if (String(source).includes('.tmp') && String(destination).endsWith('openai-codex-auth-config.json')) { renameCalls += 1; if (renameCalls <= 2) { @@ -884,7 +888,7 @@ describe('Plugin Configuration', () => { try { await expect(setSyncFromCodexMultiAuthEnabled(true)).rejects.toThrow('rename failed'); - expect(mockUnlinkSync).toHaveBeenCalledWith(expect.stringContaining('.tmp')); + expect(mockUnlink).toHaveBeenCalledWith(expect.stringContaining('.tmp')); } finally { Object.defineProperty(process, 'platform', { value: originalPlatform }); } @@ -899,7 +903,7 @@ describe('Plugin Configuration', () => { throw error; }); mockExistsSync.mockReturnValue(true); - mockReadFileSync.mockImplementation((filePath: fs.PathOrFileDescriptor) => { + mockReadFile.mockImplementation(async (filePath: fs.PathLike | number) => { if (String(filePath) === lockPath) { return '424242'; } @@ -908,8 +912,8 @@ describe('Plugin Configuration', () => { } return JSON.stringify({ codexMode: false }); }); - mockWriteFileSync.mockImplementation((filePath: fs.PathOrFileDescriptor) => { - if (String(filePath) === lockPath && mockWriteFileSync.mock.calls.length === 1) { + mockWriteFile.mockImplementation(async (filePath) => { + if (String(filePath) === lockPath && mockWriteFile.mock.calls.length === 1) { const error = new Error('exists') as NodeJS.ErrnoException; error.code = 'EEXIST'; throw error; @@ -919,9 +923,9 @@ describe('Plugin Configuration', () => { try { await expect(setSyncFromCodexMultiAuthEnabled(true)).resolves.toBeUndefined(); - expect(mockUnlinkSync).toHaveBeenCalledWith(expect.stringContaining('.stale')); + expect(mockUnlink).toHaveBeenCalledWith(expect.stringContaining('.stale')); expect(killSpy).toHaveBeenCalledWith(424242, 0); - expect(mockRenameSync).toHaveBeenCalled(); + expect(mockRename).toHaveBeenCalled(); } finally { killSpy.mockRestore(); } diff --git a/test/storage.race.test.ts b/test/storage.race.test.ts index 8848ecb2..4767ec73 100644 --- a/test/storage.race.test.ts +++ b/test/storage.race.test.ts @@ -55,4 +55,82 @@ describe("storage race paths", () => { expect(loaded?.accounts).toHaveLength(1); expect(loaded?.accounts[0]?.accountId).toBe("race-import"); }); + + it("keeps duplicate-email cleanup stable under concurrent cleanup runs", async () => { + const storageModule = await import("../lib/storage.js"); + + storageModule.setStoragePathDirect(join(testDir, "accounts.json")); + await fs.writeFile( + join(testDir, "accounts.json"), + JSON.stringify({ + version: 3, + activeIndex: 0, + activeIndexByFamily: {}, + accounts: [ + { email: "shared@example.com", refreshToken: "older", addedAt: 1, lastUsed: 1 }, + { email: "shared@example.com", refreshToken: "newer", addedAt: 2, lastUsed: 2 }, + { email: "unique@example.com", refreshToken: "unique", addedAt: 3, lastUsed: 3 }, + ], + }), + "utf8", + ); + + const results = await Promise.allSettled([ + storageModule.cleanupDuplicateEmailAccounts(), + storageModule.cleanupDuplicateEmailAccounts(), + ]); + const loaded = await storageModule.loadAccounts(); + + expect(results.every((result) => result.status === "fulfilled")).toBe(true); + expect(loaded?.accounts).toHaveLength(2); + expect(loaded?.accounts[0]?.refreshToken).toBe("newer"); + expect(loaded?.accounts[1]?.refreshToken).toBe("unique"); + }); + + it("serializes raw backups behind the storage lock during concurrent saves", async () => { + const storageModule = await import("../lib/storage.js"); + const originalRename = fs.rename.bind(fs); + const storagePath = join(testDir, "accounts.json"); + const backupPath = join(testDir, "backup.json"); + let releaseRename: (() => void) | null = null; + let backupFinished = false; + + storageModule.setStoragePathDirect(storagePath); + await storageModule.saveAccounts({ + version: 3, + activeIndex: 0, + activeIndexByFamily: {}, + accounts: [{ accountId: "before", refreshToken: "before", addedAt: 1, lastUsed: 1 }], + }); + + vi.spyOn(fs, "rename").mockImplementation(async (source, destination) => { + if (String(destination) === storagePath && releaseRename === null) { + await new Promise((resolve) => { + releaseRename = resolve; + }); + } + return originalRename(source, destination); + }); + + const savePromise = storageModule.saveAccounts({ + version: 3, + activeIndex: 0, + activeIndexByFamily: {}, + accounts: [{ accountId: "after", refreshToken: "after", addedAt: 2, lastUsed: 2 }], + }); + const backupPromise = storageModule.backupRawAccountsFile(backupPath).then(() => { + backupFinished = true; + }); + + await new Promise((resolve) => setTimeout(resolve, 50)); + expect(backupFinished).toBe(false); + releaseRename?.(); + + await Promise.all([savePromise, backupPromise]); + + const backup = JSON.parse(await fs.readFile(backupPath, "utf8")) as { + accounts: Array<{ accountId?: string }>; + }; + expect(backup.accounts[0]?.accountId).toBe("after"); + }); }); diff --git a/test/storage.test.ts b/test/storage.test.ts index 01957716..bd8e908e 100644 --- a/test/storage.test.ts +++ b/test/storage.test.ts @@ -225,6 +225,62 @@ describe("storage", () => { await fs.rm(testStoragePath, { force: true }); } }); + + it.each(["EBUSY", "EACCES", "EPERM"] as const)( + "falls back to the current duplicate-cleanup snapshot when raw storage read fails with %s", + async (errorCode) => { + const testStoragePath = join( + tmpdir(), + `codex-clean-duplicate-email-fallback-${errorCode}-${Math.random().toString(36).slice(2)}.json`, + ); + setStoragePathDirect(testStoragePath); + + try { + await fs.writeFile( + testStoragePath, + JSON.stringify({ + version: 3, + activeIndex: 0, + activeIndexByFamily: {}, + accounts: [ + { email: "shared@example.com", refreshToken: "older", addedAt: 1, lastUsed: 1 }, + { email: "shared@example.com", refreshToken: "newer", addedAt: 2, lastUsed: 2 }, + ], + }), + "utf8", + ); + + const originalReadFile = fs.readFile.bind(fs); + let readAttempts = 0; + const readSpy = vi.spyOn(fs, "readFile").mockImplementation(async (path, options) => { + readAttempts += 1; + if (String(path) === testStoragePath && readAttempts === 2) { + const error = new Error("locked") as NodeJS.ErrnoException; + error.code = errorCode; + throw error; + } + return originalReadFile(path, options as never); + }); + + try { + await expect(cleanupDuplicateEmailAccounts()).resolves.toEqual({ + before: 1, + after: 1, + removed: 0, + }); + } finally { + readSpy.mockRestore(); + } + + const loaded = await loadAccounts(); + expect(loaded?.accounts).toHaveLength(1); + expect(loaded?.accounts[0]?.refreshToken).toBe("newer"); + } finally { + setStoragePathDirect(null); + await fs.rm(testStoragePath, { force: true }); + } + }, + ); }); describe("import/export (TDD)", () => { diff --git a/test/ui-select.test.ts b/test/ui-select.test.ts index 997af0c9..c4afe8fc 100644 --- a/test/ui-select.test.ts +++ b/test/ui-select.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { coalesceTerminalInput, tokenizeTerminalInput, type PendingInputSequence } from "../lib/ui/select.js"; +import { coalesceTerminalInput, sanitizeAuditValue, tokenizeTerminalInput, type PendingInputSequence } from "../lib/ui/select.js"; describe("ui-select", () => { it("reconstructs orphan bracket arrow chunks", () => { @@ -65,4 +65,13 @@ describe("ui-select", () => { it("tokenizes packed SS3 arrow sequences", () => { expect(tokenizeTerminalInput("\u001bOA\u001bOB")).toEqual(["\u001bOA", "\u001bOB"]); }); + + it("redacts subtitle and hint audit fields by key", () => { + expect(sanitizeAuditValue("subtitle", "sensitive subtitle")).toBe("[redacted:18]"); + expect(sanitizeAuditValue("hint", "sensitive hint")).toBe("[redacted:14]"); + }); + + it("redacts secret-like audit strings even when the key is not prelisted", () => { + expect(sanitizeAuditValue("custom", "sk-live-abcdefghijklmnopqrstuvwxyz0123456789")).toBe("[redacted-token]"); + }); }); From 61de1dc1c617c323021ee3fb7d26880bd3de84a8 Mon Sep 17 00:00:00 2001 From: ndycode Date: Sun, 8 Mar 2026 15:54:49 +0800 Subject: [PATCH 49/81] fix: harden codex sync preview and path handling --- lib/codex-multi-auth-sync.ts | 75 +++++++++++++++++------------------- lib/storage.ts | 55 ++++++++++++++++---------- 2 files changed, 71 insertions(+), 59 deletions(-) diff --git a/lib/codex-multi-auth-sync.ts b/lib/codex-multi-auth-sync.ts index de4f6f6f..27f73927 100644 --- a/lib/codex-multi-auth-sync.ts +++ b/lib/codex-multi-auth-sync.ts @@ -8,12 +8,14 @@ import { deduplicateAccountsByEmail, getStoragePath, importAccounts, + loadAccounts, normalizeAccountStorage, - previewImportAccounts, + previewImportAccountsWithExistingStorage, withAccountStorageTransaction, type AccountStorageV3, type ImportAccountsResult, } from "./storage.js"; +import { migrateV1ToV3, type AccountStorageV1 } from "./storage/migrations.js"; import { findProjectRoot, getProjectStorageKeyCandidates } from "./storage/paths.js"; const EXTERNAL_ROOT_SUFFIX = "multi-auth"; @@ -24,7 +26,7 @@ const EXTERNAL_ACCOUNT_FILE_NAMES = [ 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 = 60 * 60 * 1000; +const STALE_NORMALIZED_IMPORT_MAX_AGE_MS = 10 * 60 * 1000; export interface CodexMultiAuthResolvedSource { rootDir: string; @@ -662,10 +664,11 @@ function validateCodexMultiAuthRootDir(pathValue: string): string { } if (process.platform === "win32") { const normalized = trimmed.replace(/\//g, "\\"); - if (normalized.startsWith("\\\\") || normalized.startsWith("\\?\\") || normalized.startsWith("\\.\\")) { - throw new Error("CODEX_MULTI_AUTH_DIR must use a local absolute path on Windows"); + 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)) { + if (!/^[a-zA-Z]:\\/.test(normalized) && !isExtendedDrivePath) { throw new Error("CODEX_MULTI_AUTH_DIR must be an absolute local path"); } return normalized; @@ -821,24 +824,23 @@ function createEmptyAccountStorage(): AccountStorageV3 { async function prepareCodexMultiAuthPreviewStorage( resolved: CodexMultiAuthResolvedSource & { storage: AccountStorageV3 }, ): Promise { - return withAccountStorageTransaction((current) => { - const existing = current ?? createEmptyAccountStorage(); - const preparedStorage = filterSourceAccountsAgainstExistingEmails( - resolved.storage, - existing.accounts, - ); - const maxAccounts = getSyncCapacityLimit(); - if (Number.isFinite(maxAccounts)) { - const details = computeSyncCapacityDetails(resolved, preparedStorage, existing, maxAccounts); - if (details) { - throw new CodexMultiAuthSyncCapacityError(details); - } + const current = await loadAccounts(); + const existing = current ?? createEmptyAccountStorage(); + const preparedStorage = filterSourceAccountsAgainstExistingEmails( + resolved.storage, + existing.accounts, + ); + const maxAccounts = getSyncCapacityLimit(); + if (Number.isFinite(maxAccounts)) { + const details = computeSyncCapacityDetails(resolved, preparedStorage, existing, maxAccounts); + if (details) { + throw new CodexMultiAuthSyncCapacityError(details); } - return Promise.resolve({ - ...resolved, - storage: preparedStorage, - }); - }); + } + return { + ...resolved, + storage: preparedStorage, + }; } export async function previewSyncFromCodexMultiAuth( @@ -846,9 +848,10 @@ export async function previewSyncFromCodexMultiAuth( ): Promise { const loadedSource = await loadCodexMultiAuthSourceStorage(projectPath); const resolved = await prepareCodexMultiAuthPreviewStorage(loadedSource); + const current = await loadAccounts(); const preview = await withNormalizedImportFile( resolved.storage, - (filePath) => previewImportAccounts(filePath), + (filePath) => previewImportAccountsWithExistingStorage(filePath, current), ); return { rootDir: resolved.rootDir, @@ -1016,27 +1019,21 @@ function normalizeOverlapCleanupSourceStorage(data: unknown): AccountStorageV3 | return null; } - const record = data as { - accounts: unknown[]; - activeIndex?: unknown; - activeIndexByFamily?: unknown; - }; - const accounts = record.accounts.filter((account): account is AccountStorageV3["accounts"][number] => { - return ( - typeof account === "object" && - account !== null && - typeof (account as { refreshToken?: unknown }).refreshToken === "string" && - (account as { refreshToken: string }).refreshToken.trim().length > 0 - ); + const baseRecord = + (data as { version?: unknown }).version === 1 + ? migrateV1ToV3(data as AccountStorageV1) + : (data as AccountStorageV3); + const accounts = baseRecord.accounts.filter((account): account is AccountStorageV3["accounts"][number] => { + return typeof account.refreshToken === "string" && account.refreshToken.trim().length > 0; }); const activeIndexValue = - typeof record.activeIndex === "number" && Number.isFinite(record.activeIndex) - ? record.activeIndex + typeof baseRecord.activeIndex === "number" && Number.isFinite(baseRecord.activeIndex) + ? baseRecord.activeIndex : 0; const activeIndex = Math.max(0, Math.min(accounts.length - 1, activeIndexValue)); const rawActiveIndexByFamily = - record.activeIndexByFamily && typeof record.activeIndexByFamily === "object" - ? record.activeIndexByFamily + baseRecord.activeIndexByFamily && typeof baseRecord.activeIndexByFamily === "object" + ? baseRecord.activeIndexByFamily : {}; const activeIndexByFamily = Object.fromEntries( Object.entries(rawActiveIndexByFamily).flatMap(([family, value]) => { diff --git a/lib/storage.ts b/lib/storage.ts index 073666a3..a4065676 100644 --- a/lib/storage.ts +++ b/lib/storage.ts @@ -1391,28 +1391,43 @@ export async function previewImportAccounts( const { normalized } = await readAndNormalizeImportFile(filePath); return withAccountStorageTransaction((existing) => { - const existingAccounts = existing?.accounts ?? []; - const merged = [...existingAccounts, ...normalized.accounts]; - const hasFiniteAccountLimit = Number.isFinite(ACCOUNT_LIMITS.MAX_ACCOUNTS); - - if (hasFiniteAccountLimit && 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]; + const hasFiniteAccountLimit = Number.isFinite(ACCOUNT_LIMITS.MAX_ACCOUNTS); + + if (hasFiniteAccountLimit && 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 = deduplicatedAccounts.length - existingAccounts.length; + const skipped = normalized.accounts.length - imported; + return { + imported, + total: deduplicatedAccounts.length, + skipped, + }; } /** From b9ef0b9b48c6532fde58701c6a0d2dab38e1efb8 Mon Sep 17 00:00:00 2001 From: ndycode Date: Sun, 8 Mar 2026 15:54:54 +0800 Subject: [PATCH 50/81] test: cover codex sync preview and overlap regressions --- test/codex-multi-auth-sync.test.ts | 276 ++++++++++++++++++++++------- 1 file changed, 212 insertions(+), 64 deletions(-) diff --git a/test/codex-multi-auth-sync.test.ts b/test/codex-multi-auth-sync.test.ts index 144c3e4b..b3265821 100644 --- a/test/codex-multi-auth-sync.test.ts +++ b/test/codex-multi-auth-sync.test.ts @@ -24,9 +24,32 @@ 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, @@ -133,6 +156,12 @@ describe("codex-multi-auth sync", () => { 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, @@ -141,6 +170,8 @@ describe("codex-multi-auth sync", () => { 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(); @@ -306,8 +337,9 @@ describe("codex-multi-auth sync", () => { backupStatus: "created", }); - expect(vi.mocked(storageModule.previewImportAccounts)).toHaveBeenCalledWith( + 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-"), @@ -328,6 +360,55 @@ describe("codex-multi-auth sync", () => { 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("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; @@ -342,11 +423,13 @@ describe("codex-multi-auth sync", () => { ); const storageModule = await import("../lib/storage.js"); - vi.mocked(storageModule.previewImportAccounts).mockRejectedValueOnce(new Error("preview failed")); + 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.previewImportAccounts)).toHaveBeenCalledTimes(1); + expect(vi.mocked(storageModule.previewImportAccountsWithExistingStorage)).toHaveBeenCalledTimes(1); }); it("surfaces secure temp directory creation failures instead of falling back to system tmpdir", async () => { @@ -524,6 +607,8 @@ describe("codex-multi-auth sync", () => { 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, @@ -538,9 +623,14 @@ describe("codex-multi-auth sync", () => { try { await fs.promises.mkdir(staleDir, { recursive: true }); await fs.promises.writeFile(staleFile, "sensitive", "utf8"); - const oldTime = new Date(Date.now() - (65 * 60 * 1000)); + 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({ @@ -550,6 +640,7 @@ describe("codex-multi-auth sync", () => { }); 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 }); } @@ -614,11 +705,9 @@ describe("codex-multi-auth sync", () => { }, ], }; - vi.mocked(storageModule.withAccountStorageTransaction) - .mockImplementationOnce(async (handler) => handler(currentStorage, vi.fn(async () => {}))) - .mockImplementationOnce(async (handler) => handler(currentStorage, vi.fn(async () => {}))); + vi.mocked(storageModule.loadAccounts).mockResolvedValue(currentStorage); - vi.mocked(storageModule.previewImportAccounts).mockImplementationOnce(async (filePath) => { + 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([ @@ -692,10 +781,8 @@ describe("codex-multi-auth sync", () => { }, ], }; - vi.mocked(storageModule.withAccountStorageTransaction).mockImplementationOnce(async (handler) => - handler(currentStorage, vi.fn(async () => {})), - ); - vi.mocked(storageModule.previewImportAccounts).mockImplementationOnce(async (filePath) => { + 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); @@ -745,7 +832,7 @@ describe("codex-multi-auth sync", () => { const storageModule = await import("../lib/storage.js"); vi.mocked(storageModule.deduplicateAccounts).mockImplementationOnce((accounts) => [accounts[1]]); - vi.mocked(storageModule.previewImportAccounts).mockImplementationOnce(async (filePath) => { + 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); @@ -835,27 +922,22 @@ describe("codex-multi-auth sync", () => { ); const storageModule = await import("../lib/storage.js"); - vi.mocked(storageModule.withAccountStorageTransaction).mockImplementationOnce(async (handler) => - handler( + vi.mocked(storageModule.loadAccounts).mockResolvedValue({ + version: 3, + activeIndex: 0, + activeIndexByFamily: {}, + accounts: [ { - 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, - }, - ], + accountId: "org-existing", + organizationId: "org-existing", + accountIdSource: "org", + email: "existing@example.com", + refreshToken: "rt-existing", + addedAt: 10, + lastUsed: 10, }, - vi.fn(async () => {}), - ), - ); + ], + }); const { previewSyncFromCodexMultiAuth, CodexMultiAuthSyncCapacityError } = await import("../lib/codex-multi-auth-sync.js"); await expect(previewSyncFromCodexMultiAuth(process.cwd())).rejects.toBeInstanceOf( @@ -981,17 +1063,12 @@ describe("codex-multi-auth sync", () => { ); const storageModule = await import("../lib/storage.js"); - vi.mocked(storageModule.withAccountStorageTransaction).mockImplementationOnce(async (handler) => - handler( - { - version: 3, - activeIndex: 0, - activeIndexByFamily: {}, - accounts: [], - }, - vi.fn(async () => {}), - ), - ); + 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; @@ -1144,6 +1221,82 @@ describe("codex-multi-auth sync", () => { expect(persisted?.accounts[0]?.organizationId).toBe("org-sync"); }); + it("migrates v1 raw overlap snapshots without collapsing duplicate tagged rows before cleanup", async () => { + const storageModule = await import("../lib/storage.js"); + let persisted: AccountStorageV3 | null = null; + 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, + }, + ], + }, + vi.fn(async (next) => { + persisted = next; + }), + ), + ); + 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) => { + const record = value as { + version: 3; + activeIndex: number; + activeIndexByFamily: Record; + accounts: Array>; + }; + 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, + }); + expect(persisted?.accounts).toHaveLength(1); + expect(persisted?.accounts[0]?.organizationId).toBe("org-sync"); + expect(persisted?.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"); let persisted: AccountStorageV3 | null = null; @@ -1460,28 +1613,23 @@ describe("codex-multi-auth sync", () => { ); const storageModule = await import("../lib/storage.js"); - vi.mocked(storageModule.withAccountStorageTransaction).mockImplementationOnce(async (handler) => - handler( + vi.mocked(storageModule.loadAccounts).mockResolvedValue({ + version: 3, + activeIndex: 0, + activeIndexByFamily: {}, + accounts: [ { - version: 3, - activeIndex: 0, - activeIndexByFamily: {}, - accounts: [ - { - accountId: "org-local", - organizationId: "org-local", - accountIdSource: "org", - email: "local@example.com", - refreshToken: "rt-local", - addedAt: 1, - lastUsed: 1, - }, - ], + accountId: "org-local", + organizationId: "org-local", + accountIdSource: "org", + email: "local@example.com", + refreshToken: "rt-local", + addedAt: 1, + lastUsed: 1, }, - vi.fn(async () => {}), - ), - ); - vi.mocked(storageModule.previewImportAccounts).mockImplementationOnce(async (filePath) => { + ], + }); + vi.mocked(storageModule.previewImportAccountsWithExistingStorage).mockImplementationOnce(async (filePath) => { const raw = await fs.promises.readFile(filePath, "utf8"); const parsed = JSON.parse(raw) as { accounts: Array<{ accountId?: string }> }; expect(parsed.accounts).toHaveLength(21); @@ -1522,7 +1670,7 @@ describe("codex-multi-auth sync", () => { }), ); const storageModule = await import("../lib/storage.js"); - vi.mocked(storageModule.previewImportAccounts).mockImplementationOnce(async (filePath) => { + vi.mocked(storageModule.previewImportAccountsWithExistingStorage).mockImplementationOnce(async (filePath) => { const raw = await fs.promises.readFile(filePath, "utf8"); const parsed = JSON.parse(raw) as { accounts: Array<{ accountId?: string }> }; expect(parsed.accounts).toHaveLength(50); From 21df23f83564f68e640ddc81a359a493fa764072 Mon Sep 17 00:00:00 2001 From: ndycode Date: Sun, 8 Mar 2026 16:45:46 +0800 Subject: [PATCH 51/81] fix: align preview and config lock cleanup --- lib/codex-multi-auth-sync.ts | 21 +++++++++++++++------ lib/config.ts | 35 ++++++++++++++++++++++++++++++++--- lib/constants.ts | 2 +- 3 files changed, 48 insertions(+), 10 deletions(-) diff --git a/lib/codex-multi-auth-sync.ts b/lib/codex-multi-auth-sync.ts index 27f73927..4e7b43da 100644 --- a/lib/codex-multi-auth-sync.ts +++ b/lib/codex-multi-auth-sync.ts @@ -109,6 +109,11 @@ type NormalizedImportFileOptions = { 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; function sleepAsync(ms: number): Promise { @@ -823,7 +828,7 @@ function createEmptyAccountStorage(): AccountStorageV3 { async function prepareCodexMultiAuthPreviewStorage( resolved: CodexMultiAuthResolvedSource & { storage: AccountStorageV3 }, -): Promise { +): Promise { const current = await loadAccounts(); const existing = current ?? createEmptyAccountStorage(); const preparedStorage = filterSourceAccountsAgainstExistingEmails( @@ -831,6 +836,7 @@ async function prepareCodexMultiAuthPreviewStorage( 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) { @@ -838,8 +844,11 @@ async function prepareCodexMultiAuthPreviewStorage( } } return { - ...resolved, - storage: preparedStorage, + resolved: { + ...resolved, + storage: preparedStorage, + }, + existing, }; } @@ -847,11 +856,10 @@ export async function previewSyncFromCodexMultiAuth( projectPath = process.cwd(), ): Promise { const loadedSource = await loadCodexMultiAuthSourceStorage(projectPath); - const resolved = await prepareCodexMultiAuthPreviewStorage(loadedSource); - const current = await loadAccounts(); + const { resolved, existing } = await prepareCodexMultiAuthPreviewStorage(loadedSource); const preview = await withNormalizedImportFile( resolved.storage, - (filePath) => previewImportAccountsWithExistingStorage(filePath, current), + (filePath) => previewImportAccountsWithExistingStorage(filePath, existing), ); return { rootDir: resolved.rootDir, @@ -880,6 +888,7 @@ export async function syncFromCodexMultiAuth( normalizedStorage, existing?.accounts ?? [], ); + // Infinity is the sentinel for the default unlimited-account mode. if (Number.isFinite(maxAccounts)) { const details = computeSyncCapacityDetails( resolved, diff --git a/lib/config.ts b/lib/config.ts index 53e04471..23946872 100644 --- a/lib/config.ts +++ b/lib/config.ts @@ -1,5 +1,5 @@ import { readFileSync, existsSync, promises as fs } from "node:fs"; -import { dirname, join } from "node:path"; +import { basename, dirname, join } from "node:path"; import { homedir } from "node:os"; import type { PluginConfig } from "./types.js"; import { @@ -12,6 +12,7 @@ import { PluginConfigSchema, getValidationErrors } from "./schemas.js"; const CONFIG_PATH = join(homedir(), ".opencode", "openai-codex-auth-config.json"); const CONFIG_LOCK_PATH = `${CONFIG_PATH}.lock`; +const STALE_CONFIG_LOCK_MAX_AGE_MS = 24 * 60 * 60 * 1000; const TUI_COLOR_PROFILES = new Set(["truecolor", "ansi16", "ansi256"]); const TUI_GLYPH_MODES = new Set(["ascii", "unicode", "auto"]); const REQUEST_TRANSFORM_MODES = new Set(["native", "legacy"]); @@ -253,6 +254,32 @@ function isProcessAlive(pid: number): boolean { } } +async function cleanupStalePluginConfigLockArtifacts(): Promise { + const lockDir = dirname(CONFIG_LOCK_PATH); + const staleLockPrefix = `${basename(CONFIG_LOCK_PATH)}.`; + try { + const entries = await fs.readdir(lockDir, { withFileTypes: true }); + for (const entry of entries) { + if (!entry.isFile() || !entry.name.startsWith(staleLockPrefix) || !entry.name.endsWith(".stale")) { + continue; + } + const stalePath = join(lockDir, entry.name); + try { + const stats = await fs.stat(stalePath); + if (Date.now() - stats.mtimeMs < STALE_CONFIG_LOCK_MAX_AGE_MS) { + continue; + } + await fs.unlink(stalePath); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + logWarn(`Failed to remove stale plugin config lock artifact ${stalePath}: ${message}`); + } + } + } catch { + // best effort stale-lock cleanup only + } +} + async function tryRecoverStalePluginConfigLock(rawLockContents: string): Promise { const lockOwnerPid = Number.parseInt(rawLockContents.trim(), 10); if ( @@ -295,14 +322,16 @@ async function tryRecoverStalePluginConfigLock(rawLockContents: string): Promise try { await fs.unlink(staleLockPath); - } catch { - // best effort stale-lock cleanup + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + logWarn(`Failed to remove stale plugin config lock artifact ${staleLockPath}: ${message}`); } return true; } async function withPluginConfigLock(fn: () => T | Promise): Promise { await fs.mkdir(dirname(CONFIG_PATH), { recursive: true }); + await cleanupStalePluginConfigLockArtifacts(); const deadline = Date.now() + 2_000; while (true) { try { diff --git a/lib/constants.ts b/lib/constants.ts index c242c85f..a77aa89c 100644 --- a/lib/constants.ts +++ b/lib/constants.ts @@ -85,7 +85,7 @@ export const AUTH_LABELS = { /** Multi-account configuration */ export const ACCOUNT_LIMITS = { - /** Maximum number of OAuth accounts that can be registered */ + /** Maximum number of OAuth accounts that can be registered. Infinity means unlimited by default. */ MAX_ACCOUNTS: Number.POSITIVE_INFINITY, /** Cooldown period (ms) after auth failure before retrying account */ AUTH_FAILURE_COOLDOWN_MS: 30_000, From e55d0af11971aa92090bb2f41f75cc7532cc859b Mon Sep 17 00:00:00 2001 From: ndycode Date: Sun, 8 Mar 2026 16:45:52 +0800 Subject: [PATCH 52/81] test: cover preview snapshot and stale lock sweep --- test/codex-multi-auth-sync.test.ts | 55 +++++++++++++++++++++++++ test/plugin-config.test.ts | 64 ++++++++++++++++++++++++++++++ 2 files changed, 119 insertions(+) diff --git a/test/codex-multi-auth-sync.test.ts b/test/codex-multi-auth-sync.test.ts index b3265821..50b3f210 100644 --- a/test/codex-multi-auth-sync.test.ts +++ b/test/codex-multi-auth-sync.test.ts @@ -409,6 +409,61 @@ describe("codex-multi-auth sync", () => { }); }); + 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("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; diff --git a/test/plugin-config.test.ts b/test/plugin-config.test.ts index 181a8676..badec49c 100644 --- a/test/plugin-config.test.ts +++ b/test/plugin-config.test.ts @@ -41,7 +41,9 @@ vi.mock('node:fs', async () => { ...actual.promises, mkdir: vi.fn(), readFile: vi.fn(), + readdir: vi.fn(), rename: vi.fn(), + stat: vi.fn(), unlink: vi.fn(), writeFile: vi.fn(), }, @@ -62,9 +64,12 @@ describe('Plugin Configuration', () => { const mockReadFileSync = vi.mocked(fs.readFileSync); const mockMkdir = vi.mocked(fs.promises.mkdir); const mockReadFile = vi.mocked(fs.promises.readFile); + const mockReaddir = vi.mocked(fs.promises.readdir); const mockRename = vi.mocked(fs.promises.rename); + const mockStat = vi.mocked(fs.promises.stat); const mockUnlink = vi.mocked(fs.promises.unlink); const mockWriteFile = vi.mocked(fs.promises.writeFile); + const mockLogWarn = vi.mocked(logger.logWarn); const envKeys = [ 'CODEX_MODE', 'CODEX_TUI_V2', @@ -91,7 +96,9 @@ describe('Plugin Configuration', () => { mockReadFileSync.mockReturnValue('{}'); mockMkdir.mockResolvedValue(undefined); mockReadFile.mockResolvedValue('{}'); + mockReaddir.mockResolvedValue([]); mockRename.mockResolvedValue(undefined); + mockStat.mockResolvedValue({ mtimeMs: Date.now() } as fs.Stats); mockUnlink.mockResolvedValue(undefined); mockWriteFile.mockResolvedValue(undefined); }); @@ -930,6 +937,63 @@ describe('Plugin Configuration', () => { killSpy.mockRestore(); } }); + + it('sweeps old stale lock artifacts before acquiring the config lock', async () => { + const configPath = path.join(os.homedir(), '.opencode', 'openai-codex-auth-config.json'); + const stalePath = `${configPath}.lock.424242.777777.1700000000000.stale`; + mockReaddir.mockResolvedValue([ + { isFile: () => true, name: path.basename(stalePath) } as fs.Dirent, + ]); + mockStat.mockResolvedValue({ + mtimeMs: Date.now() - (25 * 60 * 60 * 1000), + } as fs.Stats); + + await expect(setSyncFromCodexMultiAuthEnabled(true)).resolves.toBeUndefined(); + expect(mockUnlink).toHaveBeenCalledWith(stalePath); + }); + + it('warns when stale lock cleanup cannot remove a recovered stale file', async () => { + const configPath = path.join(os.homedir(), '.opencode', 'openai-codex-auth-config.json'); + const lockPath = `${configPath}.lock`; + const killSpy = vi.spyOn(process, 'kill').mockImplementation(() => { + const error = new Error('process not found') as NodeJS.ErrnoException; + error.code = 'ESRCH'; + throw error; + }); + mockExistsSync.mockReturnValue(true); + mockReadFile.mockImplementation(async (filePath: fs.PathLike | number) => { + if (String(filePath) === lockPath) { + return '424242'; + } + if (String(filePath).includes('.stale')) { + return '424242'; + } + return JSON.stringify({ codexMode: false }); + }); + mockWriteFile.mockImplementation(async (filePath) => { + if (String(filePath) === lockPath && mockWriteFile.mock.calls.length === 1) { + const error = new Error('exists') as NodeJS.ErrnoException; + error.code = 'EEXIST'; + throw error; + } + return undefined; + }); + mockUnlink.mockImplementation(async (filePath) => { + if (String(filePath).includes('.stale')) { + throw new Error('stale unlink blocked'); + } + return undefined; + }); + + try { + await expect(setSyncFromCodexMultiAuthEnabled(true)).resolves.toBeUndefined(); + expect(mockLogWarn).toHaveBeenCalledWith( + expect.stringContaining('Failed to remove stale plugin config lock artifact'), + ); + } finally { + killSpy.mockRestore(); + } + }); }); }); From d092d585c35315ecc4419fcf0a9b71c90f74a483 Mon Sep 17 00:00:00 2001 From: ndycode Date: Sun, 8 Mar 2026 18:34:18 +0800 Subject: [PATCH 53/81] fix: harden sync cleanup reporting --- lib/codex-multi-auth-sync.ts | 19 +++++++------- test/codex-multi-auth-sync.test.ts | 41 +++++++++++++++++++++++++++--- 2 files changed, 46 insertions(+), 14 deletions(-) diff --git a/lib/codex-multi-auth-sync.ts b/lib/codex-multi-auth-sync.ts index 4e7b43da..3361d1ce 100644 --- a/lib/codex-multi-auth-sync.ts +++ b/lib/codex-multi-auth-sync.ts @@ -910,6 +910,7 @@ export async function syncFromCodexMultiAuth( }, ); }, + { postSuccessCleanupFailureMode: "warn" }, ); return { rootDir: resolved.rootDir, @@ -1123,16 +1124,14 @@ export class CodexMultiAuthSyncCapacityError extends Error { } 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; - }); + const current = await loadAccounts(); + const existing = current ?? { + version: 3 as const, + accounts: [], + activeIndex: 0, + activeIndexByFamily: {}, + }; + return buildCodexMultiAuthOverlapCleanupPlan(existing).result; } export async function cleanupCodexMultiAuthSyncedOverlaps(): Promise { diff --git a/test/codex-multi-auth-sync.test.ts b/test/codex-multi-auth-sync.test.ts index 50b3f210..64cd9cc7 100644 --- a/test/codex-multi-auth-sync.test.ts +++ b/test/codex-multi-auth-sync.test.ts @@ -409,6 +409,37 @@ describe("codex-multi-auth sync", () => { }); }); + it("keeps overlap cleanup preview on the read-only path without the storage transaction lock", async () => { + const storageModule = await import("../lib/storage.js"); + vi.mocked(storageModule.withAccountStorageTransaction).mockImplementationOnce(async () => { + throw new Error("overlap preview should not take write transaction lock"); + }); + vi.mocked(storageModule.loadAccounts).mockResolvedValue({ + version: 3, + activeIndex: 0, + 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, + }); + }); + 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; @@ -1614,7 +1645,7 @@ describe("codex-multi-auth sync", () => { }); }); - it("fails sync when temporary import cleanup cannot remove sensitive data after apply", async () => { + 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"); @@ -1634,9 +1665,11 @@ describe("codex-multi-auth sync", () => { try { const { syncFromCodexMultiAuth } = await import("../lib/codex-multi-auth-sync.js"); - await expect(syncFromCodexMultiAuth(process.cwd())).rejects.toThrow( - /Failed to remove temporary codex sync directory/, - ); + 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"), ); From 555894196720c854cf88b6f367dee25c03d3fc71 Mon Sep 17 00:00:00 2001 From: ndycode Date: Sun, 8 Mar 2026 19:19:24 +0800 Subject: [PATCH 54/81] fix: close final greptile review gaps --- index.ts | 21 +-- lib/codex-multi-auth-sync.ts | 31 ++++- lib/config.ts | 10 ++ lib/sync-prune-backup.ts | 36 +++++ test/codex-multi-auth-sync.test.ts | 215 +++++++++++++++++++++++------ test/plugin-config.test.ts | 65 ++++++++- test/sync-prune-backup.test.ts | 36 +++++ 7 files changed, 347 insertions(+), 67 deletions(-) create mode 100644 lib/sync-prune-backup.ts create mode 100644 test/sync-prune-backup.test.ts diff --git a/index.ts b/index.ts index 11ed752e..b812241b 100644 --- a/index.ts +++ b/index.ts @@ -197,10 +197,12 @@ 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 @@ -3869,18 +3871,8 @@ while (attempted.size < Math.max(1, accountCount)) { const currentFlaggedStorage = await loadFlaggedAccounts(); const backupPath = createTimestampedBackupPath("codex-sync-prune-backup"); await fsPromises.mkdir(dirname(backupPath), { recursive: true }); - const backupPayload = { - version: 1 as const, - accounts: { - ...currentAccountsStorage, - accounts: currentAccountsStorage.accounts.map((account) => ({ ...account })), - activeIndexByFamily: { ...(currentAccountsStorage.activeIndexByFamily ?? {}) }, - }, - flagged: { - ...currentFlaggedStorage, - accounts: currentFlaggedStorage.accounts.map((flagged) => ({ ...flagged })), - }, - }; + const backupPayload = createSyncPruneBackupPayload(currentAccountsStorage, currentFlaggedStorage); + // On Windows, mode bits are ignored and the backup relies on the parent directory ACLs. await fsPromises.writeFile(backupPath, `${JSON.stringify(backupPayload, null, 2)}\n`, { encoding: "utf-8", mode: 0o600, @@ -4143,7 +4135,8 @@ while (attempted.size < Math.max(1, accountCount)) { }; while (true) { try { - const preview = await previewSyncFromCodexMultiAuth(process.cwd()); + 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}`); @@ -4179,7 +4172,7 @@ while (attempted.size < Math.max(1, accountCount)) { return; } - const result = await syncFromCodexMultiAuth(process.cwd()); + const result = await syncFromCodexMultiAuth(process.cwd(), loadedSource); pruneBackup = null; invalidateAccountManagerCache(); const backupLabel = diff --git a/lib/codex-multi-auth-sync.ts b/lib/codex-multi-auth-sync.ts index 3361d1ce..0257abc1 100644 --- a/lib/codex-multi-auth-sync.ts +++ b/lib/codex-multi-auth-sync.ts @@ -34,6 +34,10 @@ export interface CodexMultiAuthResolvedSource { scope: "project" | "global"; } +export interface LoadedCodexMultiAuthSourceStorage extends CodexMultiAuthResolvedSource { + storage: AccountStorageV3; +} + export interface CodexMultiAuthSyncPreview extends CodexMultiAuthResolvedSource { imported: number; skipped: number; @@ -146,6 +150,19 @@ async function removeNormalizedImportTempDir( } } +function stableSerialize(value: unknown): string { + if (Array.isArray(value)) { + return `[${value.map((entry) => stableSerialize(entry)).join(",")}]`; + } + if (value && typeof value === "object") { + const entries = Object.entries(value as Record).sort(([left], [right]) => + left.localeCompare(right), + ); + return `{${entries.map(([key, entryValue]) => `${JSON.stringify(key)}:${stableSerialize(entryValue)}`).join(",")}}`; + } + return JSON.stringify(value); +} + async function withNormalizedImportFile( storage: AccountStorageV3, handler: (filePath: string) => Promise, @@ -796,7 +813,7 @@ function getSyncCapacityLimit(): number { export async function loadCodexMultiAuthSourceStorage( projectPath = process.cwd(), -): Promise { +): Promise { const resolved = resolveCodexMultiAuthAccountsSource(projectPath); const raw = await fs.readFile(resolved.accountsPath, "utf-8"); let parsed: unknown; @@ -854,9 +871,10 @@ async function prepareCodexMultiAuthPreviewStorage( export async function previewSyncFromCodexMultiAuth( projectPath = process.cwd(), + loadedSource?: LoadedCodexMultiAuthSourceStorage, ): Promise { - const loadedSource = await loadCodexMultiAuthSourceStorage(projectPath); - const { resolved, existing } = await prepareCodexMultiAuthPreviewStorage(loadedSource); + const source = loadedSource ?? (await loadCodexMultiAuthSourceStorage(projectPath)); + const { resolved, existing } = await prepareCodexMultiAuthPreviewStorage(source); const preview = await withNormalizedImportFile( resolved.storage, (filePath) => previewImportAccountsWithExistingStorage(filePath, existing), @@ -871,8 +889,9 @@ export async function previewSyncFromCodexMultiAuth( export async function syncFromCodexMultiAuth( projectPath = process.cwd(), + loadedSource?: LoadedCodexMultiAuthSourceStorage, ): Promise { - const resolved = await loadCodexMultiAuthSourceStorage(projectPath); + const resolved = loadedSource ?? (await loadCodexMultiAuthSourceStorage(projectPath)); const result: ImportAccountsResult = await withNormalizedImportFile( tagSyncedAccounts(resolved.storage), (filePath) => { @@ -1001,10 +1020,10 @@ function buildCodexMultiAuthOverlapCleanupPlan(existing: AccountStorageV3): { if (!key) return count; const original = originalAccountsByKey.get(key); if (!original) return count; - return JSON.stringify(original) === JSON.stringify(account) ? count : count + 1; + return stableSerialize(original) === stableSerialize(account) ? count : count + 1; }, 0); const changed = - removed > 0 || after !== before || JSON.stringify(normalized) !== JSON.stringify(existing); + removed > 0 || after !== before || stableSerialize(normalized) !== stableSerialize(existing); return { result: { diff --git a/lib/config.ts b/lib/config.ts index 23946872..01383ac6 100644 --- a/lib/config.ts +++ b/lib/config.ts @@ -320,6 +320,16 @@ async function tryRecoverStalePluginConfigLock(rawLockContents: string): Promise return false; } + if (existsSync(CONFIG_LOCK_PATH)) { + try { + await fs.unlink(staleLockPath); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + logWarn(`Failed to remove stale plugin config lock artifact ${staleLockPath}: ${message}`); + } + return false; + } + try { await fs.unlink(staleLockPath); } catch (error) { diff --git a/lib/sync-prune-backup.ts b/lib/sync-prune-backup.ts new file mode 100644 index 00000000..8d9f89c1 --- /dev/null +++ b/lib/sync-prune-backup.ts @@ -0,0 +1,36 @@ +import type { AccountStorageV3 } from "./storage.js"; + +type FlaggedSnapshot = { + version: 1; + accounts: TAccount[]; +}; + +export function createSyncPruneBackupPayload( + currentAccountsStorage: AccountStorageV3, + currentFlaggedStorage: FlaggedSnapshot, +): { + version: 1; + accounts: AccountStorageV3; + flagged: FlaggedSnapshot; +} { + return { + version: 1, + accounts: { + ...currentAccountsStorage, + accounts: currentAccountsStorage.accounts.map((account) => { + const clone = { ...account }; + delete clone.accessToken; + return clone; + }), + activeIndexByFamily: { ...(currentAccountsStorage.activeIndexByFamily ?? {}) }, + }, + flagged: { + ...currentFlaggedStorage, + accounts: currentFlaggedStorage.accounts.map((flagged) => { + const clone = { ...(flagged as TFlaggedAccount & { accessToken?: unknown }) }; + delete clone.accessToken; + return clone as TFlaggedAccount; + }), + }, + }; +} diff --git a/test/codex-multi-auth-sync.test.ts b/test/codex-multi-auth-sync.test.ts index 64cd9cc7..83df24ab 100644 --- a/test/codex-multi-auth-sync.test.ts +++ b/test/codex-multi-auth-sync.test.ts @@ -495,6 +495,73 @@ describe("codex-multi-auth sync", () => { 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("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; @@ -641,7 +708,7 @@ describe("codex-multi-auth sync", () => { name: candidateKey, isDirectory: () => true, }, - ] as ReturnType; + ] as unknown as ReturnType; } return []; }); @@ -790,7 +857,7 @@ describe("codex-multi-auth sync", () => { lastUsed: 10, }, ], - }; + } satisfies AccountStorageV3; vi.mocked(storageModule.loadAccounts).mockResolvedValue(currentStorage); vi.mocked(storageModule.previewImportAccountsWithExistingStorage).mockImplementationOnce(async (filePath) => { @@ -866,7 +933,7 @@ describe("codex-multi-auth sync", () => { 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"); @@ -1082,7 +1149,7 @@ describe("codex-multi-auth sync", () => { 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; @@ -1206,12 +1273,12 @@ describe("codex-multi-auth sync", () => { vi.fn(async () => {}), ), ); - vi.mocked(storageModule.normalizeAccountStorage).mockImplementationOnce((value: unknown) => { + vi.mocked(storageModule.normalizeAccountStorage).mockImplementationOnce((value: unknown): AccountStorageV3 => { const record = value as { version: 3; activeIndex: number; activeIndexByFamily: Record; - accounts: Array>; + accounts: AccountStorageV3["accounts"]; }; return { ...record, @@ -1229,7 +1296,7 @@ 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"); - let persisted: AccountStorageV3 | null = null; + const persist = vi.fn(async (_next: AccountStorageV3) => {}); vi.mocked(storageModule.withAccountStorageTransaction).mockImplementationOnce(async (handler) => handler( { @@ -1249,9 +1316,7 @@ describe("codex-multi-auth sync", () => { }, ], }, - vi.fn(async (next) => { - persisted = next; - }), + persist, ), ); mockSourceStorageFile( @@ -1283,12 +1348,12 @@ describe("codex-multi-auth sync", () => { ], }), ); - vi.mocked(storageModule.normalizeAccountStorage).mockImplementationOnce((value: unknown) => { + vi.mocked(storageModule.normalizeAccountStorage).mockImplementationOnce((value: unknown): AccountStorageV3 => { const record = value as { version: 3; activeIndex: number; activeIndexByFamily: Record; - accounts: Array>; + accounts: AccountStorageV3["accounts"]; }; return { ...record, @@ -1303,13 +1368,71 @@ describe("codex-multi-auth sync", () => { removed: 1, updated: 0, }); - expect(persisted?.accounts).toHaveLength(1); - expect(persisted?.accounts[0]?.organizationId).toBe("org-sync"); + 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"); - let persisted: AccountStorageV3 | null = null; + const persist = vi.fn(async (_next: AccountStorageV3) => {}); vi.mocked(storageModule.withAccountStorageTransaction).mockImplementationOnce(async (handler) => handler( { @@ -1327,9 +1450,7 @@ describe("codex-multi-auth sync", () => { }, ], }, - vi.fn(async (next) => { - persisted = next; - }), + persist, ), ); mockSourceStorageFile( @@ -1358,12 +1479,12 @@ describe("codex-multi-auth sync", () => { ], }), ); - vi.mocked(storageModule.normalizeAccountStorage).mockImplementationOnce((value: unknown) => { + vi.mocked(storageModule.normalizeAccountStorage).mockImplementationOnce((value: unknown): AccountStorageV3 => { const record = value as { version: 3; activeIndex: number; activeIndexByFamily: Record; - accounts: Array>; + accounts: AccountStorageV3["accounts"]; }; return { ...record, @@ -1378,14 +1499,18 @@ describe("codex-multi-auth sync", () => { removed: 1, updated: 0, }); - expect(persisted?.accounts).toHaveLength(1); - expect(persisted?.accounts[0]?.organizationId).toBe("org-sync"); - expect(persisted?.activeIndexByFamily?.codex).toBe(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"); - let persisted: AccountStorageV3 | null = null; + const persist = vi.fn(async (_next: AccountStorageV3) => {}); vi.mocked(storageModule.withAccountStorageTransaction).mockImplementationOnce(async (handler) => handler( { @@ -1412,9 +1537,7 @@ describe("codex-multi-auth sync", () => { }, ], }, - vi.fn(async (next) => { - persisted = next; - }), + persist, ), ); mockReadFile.mockRejectedValueOnce(Object.assign(new Error("busy"), { code: "EBUSY" })); @@ -1428,8 +1551,12 @@ describe("codex-multi-auth sync", () => { removed: 0, updated: 1, }); - expect(persisted?.accounts).toHaveLength(2); - expect(persisted?.accounts[0]?.organizationId).toBe("org-sync"); + const saved = persist.mock.calls[0]?.[0]; + if (!saved) { + throw new Error("Expected persisted overlap cleanup result"); + } + expect(saved.accounts).toHaveLength(2); + expect(saved.accounts[0]?.organizationId).toBe("org-sync"); }); it("limits overlap cleanup to accounts tagged from codex-multi-auth sync", async () => { @@ -1490,7 +1617,7 @@ describe("codex-multi-auth sync", () => { it("removes synced accounts that overlap preserved local accounts", async () => { const storageModule = await import("../lib/storage.js"); - let persisted: AccountStorageV3 | null = null; + const persist = vi.fn(async (_next: AccountStorageV3) => {}); vi.mocked(storageModule.withAccountStorageTransaction).mockImplementationOnce(async (handler) => handler( { @@ -1519,9 +1646,7 @@ describe("codex-multi-auth sync", () => { }, ], }, - vi.fn(async (next) => { - persisted = next; - }), + persist, ), ); @@ -1532,13 +1657,17 @@ describe("codex-multi-auth sync", () => { removed: 1, updated: 0, }); - expect(persisted?.accounts).toHaveLength(1); - expect(persisted?.accounts[0]?.accountId).toBe("org-local"); + 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"); - let persisted: AccountStorageV3 | null = null; + const persist = vi.fn(async (_next: AccountStorageV3) => {}); vi.mocked(storageModule.withAccountStorageTransaction).mockImplementationOnce(async (handler) => handler( { @@ -1567,18 +1696,20 @@ describe("codex-multi-auth sync", () => { }, ], }, - vi.fn(async (next) => { - persisted = next; - }), + persist, ), ); const { cleanupCodexMultiAuthSyncedOverlaps } = await import("../lib/codex-multi-auth-sync.js"); await cleanupCodexMultiAuthSyncedOverlaps(); - expect(persisted?.accounts.map((account) => account.accountId)).toEqual(["org-local", "org-sync"]); - expect(persisted?.activeIndex).toBe(1); - expect(persisted?.activeIndexByFamily.codex).toBe(1); + 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("does not block preview when account limit is unlimited", async () => { diff --git a/test/plugin-config.test.ts b/test/plugin-config.test.ts index badec49c..fe433a4e 100644 --- a/test/plugin-config.test.ts +++ b/test/plugin-config.test.ts @@ -910,7 +910,7 @@ describe('Plugin Configuration', () => { throw error; }); mockExistsSync.mockReturnValue(true); - mockReadFile.mockImplementation(async (filePath: fs.PathLike | number) => { + mockReadFile.mockImplementation(async (filePath: Parameters[0]) => { if (String(filePath) === lockPath) { return '424242'; } @@ -941,9 +941,11 @@ describe('Plugin Configuration', () => { it('sweeps old stale lock artifacts before acquiring the config lock', async () => { const configPath = path.join(os.homedir(), '.opencode', 'openai-codex-auth-config.json'); const stalePath = `${configPath}.lock.424242.777777.1700000000000.stale`; - mockReaddir.mockResolvedValue([ - { isFile: () => true, name: path.basename(stalePath) } as fs.Dirent, - ]); + mockReaddir.mockResolvedValue( + [ + { isFile: () => true, name: path.basename(stalePath) } as unknown as fs.Dirent, + ] as unknown as Awaited>, + ); mockStat.mockResolvedValue({ mtimeMs: Date.now() - (25 * 60 * 60 * 1000), } as fs.Stats); @@ -961,7 +963,7 @@ describe('Plugin Configuration', () => { throw error; }); mockExistsSync.mockReturnValue(true); - mockReadFile.mockImplementation(async (filePath: fs.PathLike | number) => { + mockReadFile.mockImplementation(async (filePath: Parameters[0]) => { if (String(filePath) === lockPath) { return '424242'; } @@ -994,6 +996,59 @@ describe('Plugin Configuration', () => { killSpy.mockRestore(); } }); + + it('backs off when a live lock reappears during stale-lock recovery', async () => { + const configPath = path.join(os.homedir(), '.opencode', 'openai-codex-auth-config.json'); + const lockPath = `${configPath}.lock`; + const killSpy = vi.spyOn(process, 'kill').mockImplementation(() => { + const error = new Error('process not found') as NodeJS.ErrnoException; + error.code = 'ESRCH'; + throw error; + }); + let lockExistsChecks = 0; + mockExistsSync.mockImplementation((filePath) => { + const candidate = String(filePath); + if (candidate === configPath) { + return true; + } + if (candidate === lockPath) { + lockExistsChecks += 1; + return lockExistsChecks >= 1; + } + return false; + }); + mockReadFile.mockImplementation(async (filePath: Parameters[0]) => { + if (String(filePath) === lockPath || String(filePath).includes('.stale')) { + return '424242'; + } + return JSON.stringify({ codexMode: false }); + }); + let lockWriteAttempts = 0; + mockWriteFile.mockImplementation(async (filePath) => { + if (String(filePath) === lockPath) { + lockWriteAttempts += 1; + if (lockWriteAttempts === 1) { + const error = new Error('exists') as NodeJS.ErrnoException; + error.code = 'EEXIST'; + throw error; + } + } + return undefined; + }); + + try { + await expect(setSyncFromCodexMultiAuthEnabled(true)).resolves.toBeUndefined(); + expect(lockWriteAttempts).toBeGreaterThan(1); + expect( + mockRename.mock.calls.some( + ([source, destination]) => + String(source).includes('.stale') && String(destination) === lockPath, + ), + ).toBe(false); + } finally { + killSpy.mockRestore(); + } + }); }); }); diff --git a/test/sync-prune-backup.test.ts b/test/sync-prune-backup.test.ts new file mode 100644 index 00000000..e90015b9 --- /dev/null +++ b/test/sync-prune-backup.test.ts @@ -0,0 +1,36 @@ +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 access 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.flagged.accounts[0]).not.toHaveProperty("accessToken"); + }); +}); From 26d064982e35f8eccc108264f5d3d4b45802b48e Mon Sep 17 00:00:00 2001 From: ndycode Date: Sun, 8 Mar 2026 19:42:10 +0800 Subject: [PATCH 55/81] fix: restore sync prune state on cancellation --- index.ts | 1 + lib/codex-multi-auth-sync.ts | 3 ++- test/codex-multi-auth-sync.test.ts | 23 +++++++++++++++-------- 3 files changed, 18 insertions(+), 9 deletions(-) diff --git a/index.ts b/index.ts index b812241b..3edcaf7b 100644 --- a/index.ts +++ b/index.ts @@ -4232,6 +4232,7 @@ while (attempted.size < Math.max(1, accountCount)) { details.suggestedRemovals, ); if (!indexesToRemove || indexesToRemove.length === 0) { + await restorePruneBackup(); console.log("Sync cancelled.\n"); return; } diff --git a/lib/codex-multi-auth-sync.ts b/lib/codex-multi-auth-sync.ts index 0257abc1..c25f8349 100644 --- a/lib/codex-multi-auth-sync.ts +++ b/lib/codex-multi-auth-sync.ts @@ -985,9 +985,10 @@ function buildCodexMultiAuthOverlapCleanupPlan(existing: AccountStorageV3): { normalizedSyncedStorage, preservedAccounts, ).accounts; + const deduplicatedSyncedAccounts = deduplicateAccounts(filteredSyncedAccounts); const normalized = { ...existing, - accounts: [...preservedAccounts, ...filteredSyncedAccounts], + accounts: [...preservedAccounts, ...deduplicatedSyncedAccounts], } satisfies AccountStorageV3; const existingActiveKeys = extractCleanupActiveKeys(existing.accounts, existing.activeIndex); const mappedActiveIndex = (() => { diff --git a/test/codex-multi-auth-sync.test.ts b/test/codex-multi-auth-sync.test.ts index 83df24ab..172a39c0 100644 --- a/test/codex-multi-auth-sync.test.ts +++ b/test/codex-multi-auth-sync.test.ts @@ -183,10 +183,14 @@ describe("codex-multi-auth sync", () => { }); afterEach(() => { - process.env.CODEX_MULTI_AUTH_DIR = originalEnv.CODEX_MULTI_AUTH_DIR; - process.env.CODEX_HOME = originalEnv.CODEX_HOME; - process.env.USERPROFILE = originalEnv.USERPROFILE; - process.env.HOME = originalEnv.HOME; + 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; }); @@ -1511,6 +1515,9 @@ describe("codex-multi-auth sync", () => { it("falls back to in-memory overlap cleanup state on transient Windows lock errors", async () => { const storageModule = await import("../lib/storage.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( { @@ -1547,15 +1554,15 @@ 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, - updated: 1, + 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(2); + expect(saved.accounts).toHaveLength(1); expect(saved.accounts[0]?.organizationId).toBe("org-sync"); }); From e3863b2f46d1dba272e71dc52704c40d330b5ee2 Mon Sep 17 00:00:00 2001 From: ndycode Date: Sun, 8 Mar 2026 19:42:14 +0800 Subject: [PATCH 56/81] fix: harden plugin config lock recovery --- lib/config.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/lib/config.ts b/lib/config.ts index 01383ac6..1686aec9 100644 --- a/lib/config.ts +++ b/lib/config.ts @@ -201,11 +201,11 @@ export async function savePluginConfigMutation( try { if (!existsSync(CONFIG_PATH)) { await fs.rename(backupPath, CONFIG_PATH); - backupMoved = false; } } catch { // best effort config restore } + backupMoved = false; } throw retryError; } finally { @@ -342,7 +342,7 @@ async function tryRecoverStalePluginConfigLock(rawLockContents: string): Promise async function withPluginConfigLock(fn: () => T | Promise): Promise { await fs.mkdir(dirname(CONFIG_PATH), { recursive: true }); await cleanupStalePluginConfigLockArtifacts(); - const deadline = Date.now() + 2_000; + const deadline = Date.now() + 5_000; while (true) { try { await fs.writeFile(CONFIG_LOCK_PATH, `${process.pid}`, { encoding: "utf-8", flag: "wx" }); @@ -354,7 +354,7 @@ async function withPluginConfigLock(fn: () => T | Promise): Promise { if (!retryableLockError || Date.now() >= deadline) { throw error; } - if (code === "EEXIST") { + if (existsSync(CONFIG_LOCK_PATH) && (code === "EEXIST" || (process.platform === "win32" && (code === "EPERM" || code === "EBUSY")))) { try { const rawLockContents = await fs.readFile(CONFIG_LOCK_PATH, "utf-8"); if (await tryRecoverStalePluginConfigLock(rawLockContents)) { From 94664fec8e9d20ddd701238e99a34e03061885b6 Mon Sep 17 00:00:00 2001 From: ndycode Date: Sun, 8 Mar 2026 19:42:18 +0800 Subject: [PATCH 57/81] fix: improve interactive fallback handling --- lib/cli.ts | 2 +- lib/ui/auth-menu.ts | 7 ++++++- test/cli.test.ts | 9 +++++++++ 3 files changed, 16 insertions(+), 2 deletions(-) diff --git a/lib/cli.ts b/lib/cli.ts index a99ca827..5789670d 100644 --- a/lib/cli.ts +++ b/lib/cli.ts @@ -263,7 +263,7 @@ async function promptLoginModeFallback( const answer = await rl.question(UI_COPY.fallback.selectModePrompt); const normalized = answer.trim().toLowerCase(); if (normalized === "a" || normalized === "add") return { mode: "add" }; - if (normalized === "b" || normalized === "forecast") return { mode: "forecast" }; + if (normalized === "b" || normalized === "best" || normalized === "forecast") return { mode: "forecast" }; if (normalized === "x" || normalized === "fix") return { mode: "fix" }; if (normalized === "s" || normalized === "settings") { const settingsResult = await promptSettingsModeFallback( diff --git a/lib/ui/auth-menu.ts b/lib/ui/auth-menu.ts index 26a1141a..07f8191f 100644 --- a/lib/ui/auth-menu.ts +++ b/lib/ui/auth-menu.ts @@ -208,7 +208,12 @@ async function promptSearchQuery(current: string): Promise { const rl = createInterface({ input, output }); try { const suffix = current ? ` (${current})` : ""; - const answer = await rl.question(`Search${suffix} (blank clears): `); + let answer: string; + try { + answer = await rl.question(`Search${suffix} (blank clears): `); + } catch { + return current; + } return answer.trim().toLowerCase(); } finally { rl.close(); diff --git a/test/cli.test.ts b/test/cli.test.ts index 7653d83c..263a9834 100644 --- a/test/cli.test.ts +++ b/test/cli.test.ts @@ -164,6 +164,15 @@ describe("CLI Module", () => { expect(result).toEqual({ mode: "add" }); }); + it("accepts 'best' for the forecast action", async () => { + mockRl.question.mockResolvedValueOnce("best"); + + const { promptLoginMode } = await import("../lib/cli.js"); + const result = await promptLoginMode([{ index: 0 }]); + + expect(result).toEqual({ mode: "forecast" }); + }); + it("re-prompts on invalid input then accepts valid", async () => { mockRl.question .mockResolvedValueOnce("invalid") From 865fbfdc38b06f3c954f1884b1c50662cef44ba5 Mon Sep 17 00:00:00 2001 From: ndycode Date: Sun, 8 Mar 2026 19:42:22 +0800 Subject: [PATCH 58/81] test: tighten storage and path regressions --- lib/storage.ts | 2 +- test/paths.test.ts | 2 +- test/storage.test.ts | 45 ++++++++++++++++++++++++++++++++++++++------ 3 files changed, 41 insertions(+), 8 deletions(-) diff --git a/lib/storage.ts b/lib/storage.ts index a4065676..09754fab 100644 --- a/lib/storage.ts +++ b/lib/storage.ts @@ -1421,7 +1421,7 @@ function previewImportAccountsAgainstExistingNormalized( } const deduplicatedAccounts = deduplicateAccountsForStorage(merged); - const imported = deduplicatedAccounts.length - existingAccounts.length; + const imported = Math.max(0, deduplicatedAccounts.length - existingAccounts.length); const skipped = normalized.accounts.length - imported; return { imported, diff --git a/test/paths.test.ts b/test/paths.test.ts index b9440bf5..28bc80d8 100644 --- a/test/paths.test.ts +++ b/test/paths.test.ts @@ -65,7 +65,7 @@ describe("Storage Paths Module", () => { it("preserves the legacy lowercase key prefix on Windows paths", () => { const projectPath = "C:\\Users\\Test\\MyProject"; - expect(getProjectStorageKey(projectPath)).toMatch(/^myproject-[a-f0-9]{12}$/); + expect(getProjectStorageKey(projectPath)).toMatch(/^[Mm]y[Pp]roject-[a-f0-9]{12}$/); }); it("uses the canonical git identity for same-repo worktrees", () => { diff --git a/test/storage.test.ts b/test/storage.test.ts index bd8e908e..5b244403 100644 --- a/test/storage.test.ts +++ b/test/storage.test.ts @@ -1221,17 +1221,50 @@ describe("storage", () => { expect(result?.activeIndex).toBe(0); }); - it("clamps out-of-bounds activeIndex", () => { - const data = { + it("clamps out-of-bounds activeIndex", () => { + const data = { version: 3, activeIndex: 100, accounts: [{ refreshToken: "t1", accountId: "A" }, { refreshToken: "t2", accountId: "B" }], }; const result = normalizeAccountStorage(data); - expect(result?.activeIndex).toBe(1); - }); - - it("filters out accounts with empty refreshToken", () => { + expect(result?.activeIndex).toBe(1); + }); + + it("preview import never reports a negative imported count after deduplication", async () => { + const { previewImportAccountsWithExistingStorage } = await import("../lib/storage.js"); + const tempDir = await fs.mkdtemp(join(tmpdir(), "storage-preview-")); + const filePath = join(tempDir, "accounts.json"); + await fs.writeFile( + filePath, + JSON.stringify({ + version: 3, + activeIndex: 0, + activeIndexByFamily: {}, + accounts: [ + { accountId: "org-a", organizationId: "org-a", accountIdSource: "org", refreshToken: "rt-a", addedAt: 1, lastUsed: 1 }, + ], + }), + "utf8", + ); + try { + const result = await previewImportAccountsWithExistingStorage(filePath, { + version: 3, + activeIndex: 0, + activeIndexByFamily: {}, + accounts: [ + { accountId: "org-a", organizationId: "org-a", accountIdSource: "org", refreshToken: "rt-a", addedAt: 2, lastUsed: 2 }, + { accountId: "org-a", organizationId: "org-a", accountIdSource: "org", refreshToken: "rt-a", addedAt: 3, lastUsed: 3 }, + ], + }); + expect(result.imported).toBe(0); + expect(result.skipped).toBeGreaterThanOrEqual(1); + } finally { + await fs.rm(tempDir, { recursive: true, force: true }); + } + }); + + it("filters out accounts with empty refreshToken", () => { const data = { version: 3, accounts: [ From c611cca32f64241a6a381e934283420ef728c484 Mon Sep 17 00:00:00 2001 From: ndycode Date: Sun, 8 Mar 2026 20:18:33 +0800 Subject: [PATCH 59/81] fix: close sync flow review gaps Unify synced-overlap preview with the locked raw cleanup path and restore finite-only account ceiling guards in the add-account flow. Co-authored-by: Codex --- index.ts | 9 +++- lib/codex-multi-auth-sync.ts | 18 ++++--- test/codex-multi-auth-sync.test.ts | 87 ++++++++++++++++++++++++++++-- test/index.test.ts | 15 ++++++ 4 files changed, 115 insertions(+), 14 deletions(-) diff --git a/index.ts b/index.ts index 3edcaf7b..94feb5c0 100644 --- a/index.ts +++ b/index.ts @@ -4769,7 +4769,9 @@ while (attempted.size < Math.max(1, accountCount)) { ? 1 : startFresh ? ACCOUNT_LIMITS.MAX_ACCOUNTS - : ACCOUNT_LIMITS.MAX_ACCOUNTS - existingCount; + : Number.isFinite(ACCOUNT_LIMITS.MAX_ACCOUNTS) + ? ACCOUNT_LIMITS.MAX_ACCOUNTS - existingCount + : Number.POSITIVE_INFINITY; if (availableSlots <= 0) { return { @@ -4888,7 +4890,10 @@ while (attempted.size < Math.max(1, accountCount)) { }); } - if (accounts.length >= ACCOUNT_LIMITS.MAX_ACCOUNTS) { + if ( + Number.isFinite(ACCOUNT_LIMITS.MAX_ACCOUNTS) && + accounts.length >= ACCOUNT_LIMITS.MAX_ACCOUNTS + ) { break; } diff --git a/lib/codex-multi-auth-sync.ts b/lib/codex-multi-auth-sync.ts index c25f8349..57e4e3f3 100644 --- a/lib/codex-multi-auth-sync.ts +++ b/lib/codex-multi-auth-sync.ts @@ -1144,14 +1144,16 @@ export class CodexMultiAuthSyncCapacityError extends Error { } export async function previewCodexMultiAuthSyncedOverlapCleanup(): Promise { - const current = await loadAccounts(); - const existing = current ?? { - version: 3 as const, - accounts: [], - activeIndex: 0, - activeIndexByFamily: {}, - }; - return buildCodexMultiAuthOverlapCleanupPlan(existing).result; + 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 { diff --git a/test/codex-multi-auth-sync.test.ts b/test/codex-multi-auth-sync.test.ts index 172a39c0..d34e663d 100644 --- a/test/codex-multi-auth-sync.test.ts +++ b/test/codex-multi-auth-sync.test.ts @@ -413,11 +413,29 @@ describe("codex-multi-auth sync", () => { }); }); - it("keeps overlap cleanup preview on the read-only path without the storage transaction lock", async () => { + 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 () => { - throw new Error("overlap preview should not take write transaction lock"); - }); + 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, @@ -442,6 +460,8 @@ describe("codex-multi-auth sync", () => { 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 () => { @@ -566,6 +586,65 @@ describe("codex-multi-auth sync", () => { }); }); + 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; diff --git a/test/index.test.ts b/test/index.test.ts index 85d6f669..0cc4c6ba 100644 --- a/test/index.test.ts +++ b/test/index.test.ts @@ -2759,6 +2759,21 @@ describe("OpenAIOAuthPlugin persistAccountPool", () => { expect(mockStorage.accounts[0]?.email).toBe("keep@example.com"); }); + it("supports add-account flow when max accounts is unlimited", async () => { + const mockClient = createMockClient(); + const { OpenAIOAuthPlugin } = await import("../index.js"); + const plugin = await OpenAIOAuthPlugin({ client: mockClient } as never) as unknown as PluginType; + const autoMethod = plugin.auth.methods[0] as unknown as { + authorize: (inputs?: Record) => Promise<{ method: string; instructions: string }>; + }; + + await expect( + autoMethod.authorize({ loginMode: "add", accountCount: "2" }), + ).resolves.toMatchObject({ + method: "auto", + }); + }); + it("runs legacy duplicate email cleanup from maintenance settings with confirmation and backup", async () => { const cliModule = await import("../lib/cli.js"); const storageModule = await import("../lib/storage.js"); From e10a481468230820406ccba4075a41016c2c2f61 Mon Sep 17 00:00:00 2001 From: ndycode Date: Sun, 8 Mar 2026 20:25:51 +0800 Subject: [PATCH 60/81] test: enforce windows legacy key prefix Co-authored-by: Codex --- test/paths.test.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/test/paths.test.ts b/test/paths.test.ts index 28bc80d8..b27c71d9 100644 --- a/test/paths.test.ts +++ b/test/paths.test.ts @@ -23,6 +23,7 @@ import { const mockedExistsSync = vi.mocked(existsSync); const mockedReadFileSync = vi.mocked(readFileSync); const mockedStatSync = vi.mocked(statSync); +const originalPlatform = process.platform; describe("Storage Paths Module", () => { beforeEach(() => { @@ -30,6 +31,7 @@ describe("Storage Paths Module", () => { }); afterEach(() => { + Object.defineProperty(process, "platform", { value: originalPlatform }); vi.resetAllMocks(); }); @@ -64,8 +66,9 @@ describe("Storage Paths Module", () => { }); it("preserves the legacy lowercase key prefix on Windows paths", () => { + Object.defineProperty(process, "platform", { value: "win32" }); const projectPath = "C:\\Users\\Test\\MyProject"; - expect(getProjectStorageKey(projectPath)).toMatch(/^[Mm]y[Pp]roject-[a-f0-9]{12}$/); + expect(getProjectStorageKey(projectPath)).toMatch(/^myproject-[a-f0-9]{12}$/); }); it("uses the canonical git identity for same-repo worktrees", () => { From 3f2abb79b00cd0734208b26d16df8fdf60db54c0 Mon Sep 17 00:00:00 2001 From: ndycode Date: Sun, 8 Mar 2026 20:45:31 +0800 Subject: [PATCH 61/81] fix: harden fallback destructive auth flows Co-authored-by: Codex --- lib/cli.ts | 22 +++++++++++--- test/cli.test.ts | 28 +++++++++++++---- test/index.test.ts | 76 +++++++++++++++++++++++++++++++++++++++++++++- 3 files changed, 114 insertions(+), 12 deletions(-) diff --git a/lib/cli.ts b/lib/cli.ts index 5789670d..c190ee51 100644 --- a/lib/cli.ts +++ b/lib/cli.ts @@ -206,13 +206,19 @@ function resolveAccountSourceIndex(account: ExistingAccountInfo): number { return -1; } -async function promptDeleteAllTypedConfirm(): Promise { - const rl = createInterface({ input, output }); - try { +async function promptDeleteAllTypedConfirm( + rl?: ReturnType, +): Promise { + if (rl) { const answer = await rl.question("Type DELETE to remove all saved accounts: "); return answer.trim() === "DELETE"; + } + const localRl = createInterface({ input, output }); + try { + const answer = await localRl.question("Type DELETE to remove all saved accounts: "); + return answer.trim() === "DELETE"; } finally { - rl.close(); + localRl.close(); } } @@ -273,7 +279,13 @@ async function promptLoginModeFallback( if (settingsResult) return settingsResult; continue; } - if (normalized === "f" || normalized === "fresh") return { mode: "fresh", deleteAll: true }; + if (normalized === "f" || normalized === "fresh") { + if (!(await promptDeleteAllTypedConfirm(rl))) { + console.log("\nDelete all cancelled.\n"); + continue; + } + return { mode: "fresh", deleteAll: true }; + } if (normalized === "c" || normalized === "check") return { mode: "check" }; if (normalized === "d" || normalized === "deep") return { mode: "deep-check" }; if (normalized === "g" || normalized === "verify" || normalized === "problem") return { mode: "verify-flagged" }; diff --git a/test/cli.test.ts b/test/cli.test.ts index 263a9834..f5ce6a1b 100644 --- a/test/cli.test.ts +++ b/test/cli.test.ts @@ -124,8 +124,10 @@ describe("CLI Module", () => { expect(result).toEqual({ mode: "add" }); }); - it("returns 'fresh' for 'f' input", async () => { - mockRl.question.mockResolvedValueOnce("f"); + it("returns 'fresh' for 'f' input after typed confirmation", async () => { + mockRl.question + .mockResolvedValueOnce("f") + .mockResolvedValueOnce("DELETE"); const { promptLoginMode } = await import("../lib/cli.js"); const result = await promptLoginMode([{ index: 0 }]); @@ -133,8 +135,10 @@ describe("CLI Module", () => { expect(result).toEqual({ mode: "fresh", deleteAll: true }); }); - it("returns 'fresh' for 'fresh' input", async () => { - mockRl.question.mockResolvedValueOnce("fresh"); + it("returns 'fresh' for 'fresh' input after typed confirmation", async () => { + mockRl.question + .mockResolvedValueOnce("fresh") + .mockResolvedValueOnce("DELETE"); const { promptLoginMode } = await import("../lib/cli.js"); const result = await promptLoginMode([{ index: 0 }]); @@ -142,6 +146,18 @@ describe("CLI Module", () => { expect(result).toEqual({ mode: "fresh", deleteAll: true }); }); + it("cancels fallback delete-all when typed confirmation is missing", async () => { + mockRl.question + .mockResolvedValueOnce("fresh") + .mockResolvedValueOnce("nope") + .mockResolvedValueOnce("q"); + + const { promptLoginMode } = await import("../lib/cli.js"); + const result = await promptLoginMode([{ index: 0 }]); + + expect(result).toEqual({ mode: "cancel" }); + }); + it("routes fallback settings input to experimental sync actions", async () => { mockRl.question .mockResolvedValueOnce("s") @@ -200,7 +216,7 @@ describe("CLI Module", () => { }); it("displays account with accountId suffix when no email", async () => { - mockRl.question.mockResolvedValueOnce("f"); + mockRl.question.mockResolvedValueOnce("a"); const consoleSpy = vi.spyOn(console, "log"); const { promptLoginMode } = await import("../lib/cli.js"); @@ -212,7 +228,7 @@ describe("CLI Module", () => { }); it("displays plain Account N when no email or accountId", async () => { - mockRl.question.mockResolvedValueOnce("f"); + mockRl.question.mockResolvedValueOnce("a"); const consoleSpy = vi.spyOn(console, "log"); const { promptLoginMode } = await import("../lib/cli.js"); diff --git a/test/index.test.ts b/test/index.test.ts index 0cc4c6ba..71bcc112 100644 --- a/test/index.test.ts +++ b/test/index.test.ts @@ -203,6 +203,17 @@ vi.mock("../lib/codex-multi-auth-sync.js", () => ({ removed: 0, updated: 0, })), + loadCodexMultiAuthSourceStorage: vi.fn(async () => ({ + rootDir: "/tmp/codex-root", + accountsPath: "/tmp/codex-root/openai-codex-accounts.json", + scope: "global", + storage: { + version: 3 as const, + accounts: [], + activeIndex: 0, + activeIndexByFamily: {}, + }, + })), syncFromCodexMultiAuth: vi.fn(async () => ({ rootDir: "/tmp/codex-root", accountsPath: "/tmp/codex-root/openai-codex-accounts.json", @@ -2768,7 +2779,7 @@ describe("OpenAIOAuthPlugin persistAccountPool", () => { }; await expect( - autoMethod.authorize({ loginMode: "add", accountCount: "2" }), + autoMethod.authorize({ loginMode: "add", accountCount: "101" }), ).resolves.toMatchObject({ method: "auto", }); @@ -3006,6 +3017,27 @@ describe("OpenAIOAuthPlugin persistAccountPool", () => { mockStorage.activeIndexByFamily = { ...nextStorage.activeIndexByFamily }; mockStorage.accounts = nextStorage.accounts.map((account) => ({ ...account })); }); + vi.mocked(storageModule.loadAccounts).mockImplementation(async () => ({ + ...mockStorage, + accounts: mockStorage.accounts.map((account) => ({ ...account })), + activeIndexByFamily: { ...mockStorage.activeIndexByFamily }, + })); + vi.mocked(storageModule.withAccountStorageTransaction).mockImplementation( + async (callback) => { + const loadedStorage = { + ...mockStorage, + accounts: mockStorage.accounts.map((account) => ({ ...account })), + activeIndexByFamily: { ...mockStorage.activeIndexByFamily }, + }; + const persist = async (nextStorage: typeof mockStorage) => { + mockStorage.version = nextStorage.version; + mockStorage.accounts = nextStorage.accounts.map((account) => ({ ...account })); + mockStorage.activeIndex = nextStorage.activeIndex; + mockStorage.activeIndexByFamily = { ...nextStorage.activeIndexByFamily }; + }; + await callback(loadedStorage, persist); + }, + ); const capacityError = new syncModule.CodexMultiAuthSyncCapacityError({ rootDir: tempDir, @@ -3371,6 +3403,27 @@ describe("OpenAIOAuthPlugin persistAccountPool", () => { mockStorage.activeIndexByFamily = { ...nextStorage.activeIndexByFamily }; mockStorage.accounts = nextStorage.accounts.map((account) => ({ ...account })); }); + vi.mocked(storageModule.loadAccounts).mockImplementation(async () => ({ + ...mockStorage, + accounts: mockStorage.accounts.map((account) => ({ ...account })), + activeIndexByFamily: { ...mockStorage.activeIndexByFamily }, + })); + vi.mocked(storageModule.withAccountStorageTransaction).mockImplementation( + async (callback) => { + const loadedStorage = { + ...mockStorage, + accounts: mockStorage.accounts.map((account) => ({ ...account })), + activeIndexByFamily: { ...mockStorage.activeIndexByFamily }, + }; + const persist = async (nextStorage: typeof mockStorage) => { + mockStorage.version = nextStorage.version; + mockStorage.accounts = nextStorage.accounts.map((account) => ({ ...account })); + mockStorage.activeIndex = nextStorage.activeIndex; + mockStorage.activeIndexByFamily = { ...nextStorage.activeIndexByFamily }; + }; + await callback(loadedStorage, persist); + }, + ); const capacityError = new syncModule.CodexMultiAuthSyncCapacityError({ rootDir: tempDir, @@ -3493,6 +3546,27 @@ describe("OpenAIOAuthPlugin persistAccountPool", () => { mockStorage.activeIndexByFamily = { ...nextStorage.activeIndexByFamily }; mockStorage.accounts = nextStorage.accounts.map((account) => ({ ...account })); }); + vi.mocked(storageModule.loadAccounts).mockImplementation(async () => ({ + ...mockStorage, + accounts: mockStorage.accounts.map((account) => ({ ...account })), + activeIndexByFamily: { ...mockStorage.activeIndexByFamily }, + })); + vi.mocked(storageModule.withAccountStorageTransaction).mockImplementation( + async (callback) => { + const loadedStorage = { + ...mockStorage, + accounts: mockStorage.accounts.map((account) => ({ ...account })), + activeIndexByFamily: { ...mockStorage.activeIndexByFamily }, + }; + const persist = async (nextStorage: typeof mockStorage) => { + mockStorage.version = nextStorage.version; + mockStorage.accounts = nextStorage.accounts.map((account) => ({ ...account })); + mockStorage.activeIndex = nextStorage.activeIndex; + mockStorage.activeIndexByFamily = { ...nextStorage.activeIndexByFamily }; + }; + await callback(loadedStorage, persist); + }, + ); const capacityError = new syncModule.CodexMultiAuthSyncCapacityError({ rootDir: tempDir, From ed1b168ac77e31db655cbb98bf39bca6b6ec4a91 Mon Sep 17 00:00:00 2001 From: ndycode Date: Sun, 8 Mar 2026 20:50:18 +0800 Subject: [PATCH 62/81] fix: tighten review follow-up regressions Co-authored-by: Codex --- index.ts | 4 +-- lib/sync-prune-backup.ts | 18 ++++++------ test/cli.test.ts | 34 +++++++++++++++++++++-- test/storage.test.ts | 14 +++++----- test/sync-prune-backup.test.ts | 50 ++++++++++++++++++++++++++++++++++ 5 files changed, 99 insertions(+), 21 deletions(-) diff --git a/index.ts b/index.ts index 94feb5c0..624f78e6 100644 --- a/index.ts +++ b/index.ts @@ -3359,7 +3359,6 @@ while (attempted.size < Math.max(1, accountCount)) { ? `Checking ${workingStorage.accounts.length} account(s) with full refresh + live validation` : `Checking ${workingStorage.accounts.length} account(s) with quota validation`, ); - const emailCounts = buildEmailCountMap(workingStorage.accounts); let screenFinished = false; const emit = ( index: number, @@ -3447,6 +3446,7 @@ while (attempted.size < Math.max(1, accountCount)) { if (!accessToken) { const cached = await lookupCodexCliTokensByEmail(account.email); const cachedTokenAccountId = cached ? extractAccountId(cached.accessToken) : undefined; + const emailCounts = buildEmailCountMap(workingStorage.accounts); if ( cached && canHydrateCachedTokenForAccount( @@ -3692,7 +3692,6 @@ while (attempted.size < Math.max(1, accountCount)) { ...(activeStorage?.accounts ?? []), ...flaggedStorage.accounts, ]; - const restoreEmailCounts = buildEmailCountMap(restoreContext); if (flaggedStorage.accounts.length === 0) { emit("No flagged accounts to verify."); if (screen && !screenOverride) { @@ -3725,6 +3724,7 @@ while (attempted.size < Math.max(1, accountCount)) { const cached = await lookupCodexCliTokensByEmail(flagged.email); const now = Date.now(); const cachedTokenAccountId = cached ? extractAccountId(cached.accessToken) : undefined; + const restoreEmailCounts = buildEmailCountMap(restoreContext); if ( cached && canHydrateCachedTokenForAccount( diff --git a/lib/sync-prune-backup.ts b/lib/sync-prune-backup.ts index 8d9f89c1..b1557923 100644 --- a/lib/sync-prune-backup.ts +++ b/lib/sync-prune-backup.ts @@ -5,6 +5,12 @@ type FlaggedSnapshot = { accounts: TAccount[]; }; +function cloneWithoutAccessToken(account: TAccount): TAccount { + const clone = structuredClone(account) as TAccount & { accessToken?: unknown }; + delete clone.accessToken; + return clone as TAccount; +} + export function createSyncPruneBackupPayload( currentAccountsStorage: AccountStorageV3, currentFlaggedStorage: FlaggedSnapshot, @@ -17,20 +23,12 @@ export function createSyncPruneBackupPayload( version: 1, accounts: { ...currentAccountsStorage, - accounts: currentAccountsStorage.accounts.map((account) => { - const clone = { ...account }; - delete clone.accessToken; - return clone; - }), + accounts: currentAccountsStorage.accounts.map((account) => cloneWithoutAccessToken(account)), activeIndexByFamily: { ...(currentAccountsStorage.activeIndexByFamily ?? {}) }, }, flagged: { ...currentFlaggedStorage, - accounts: currentFlaggedStorage.accounts.map((flagged) => { - const clone = { ...(flagged as TFlaggedAccount & { accessToken?: unknown }) }; - delete clone.accessToken; - return clone as TFlaggedAccount; - }), + accounts: currentFlaggedStorage.accounts.map((flagged) => cloneWithoutAccessToken(flagged)), }, }; } diff --git a/test/cli.test.ts b/test/cli.test.ts index f5ce6a1b..e7e937ee 100644 --- a/test/cli.test.ts +++ b/test/cli.test.ts @@ -543,7 +543,16 @@ describe("CLI Module", () => { }); it("promptLoginMode returns add immediately when TTY is unavailable without env overrides", async () => { + const originalEnv = { + OPENCODE_TUI: process.env.OPENCODE_TUI, + OPENCODE_DESKTOP: process.env.OPENCODE_DESKTOP, + TERM_PROGRAM: process.env.TERM_PROGRAM, + ELECTRON_RUN_AS_NODE: process.env.ELECTRON_RUN_AS_NODE, + }; delete process.env.OPENCODE_TUI; + delete process.env.OPENCODE_DESKTOP; + delete process.env.TERM_PROGRAM; + delete process.env.ELECTRON_RUN_AS_NODE; const { stdin, stdout } = await import("node:process"); const origInputTTY = stdin.isTTY; const origOutputTTY = stdout.isTTY; @@ -558,12 +567,27 @@ describe("CLI Module", () => { } finally { Object.defineProperty(stdin, "isTTY", { value: origInputTTY, writable: true, configurable: true }); Object.defineProperty(stdout, "isTTY", { value: origOutputTTY, writable: true, configurable: true }); - process.env.OPENCODE_TUI = "1"; + for (const [key, value] of Object.entries(originalEnv)) { + if (value === undefined) { + delete process.env[key]; + } else { + process.env[key] = value; + } + } } }); it("promptCodexMultiAuthSyncPrune returns null when TTY is unavailable without env overrides", async () => { + const originalEnv = { + OPENCODE_TUI: process.env.OPENCODE_TUI, + OPENCODE_DESKTOP: process.env.OPENCODE_DESKTOP, + TERM_PROGRAM: process.env.TERM_PROGRAM, + ELECTRON_RUN_AS_NODE: process.env.ELECTRON_RUN_AS_NODE, + }; delete process.env.OPENCODE_TUI; + delete process.env.OPENCODE_DESKTOP; + delete process.env.TERM_PROGRAM; + delete process.env.ELECTRON_RUN_AS_NODE; const { stdin, stdout } = await import("node:process"); const origInputTTY = stdin.isTTY; const origOutputTTY = stdout.isTTY; @@ -580,7 +604,13 @@ describe("CLI Module", () => { } finally { Object.defineProperty(stdin, "isTTY", { value: origInputTTY, writable: true, configurable: true }); Object.defineProperty(stdout, "isTTY", { value: origOutputTTY, writable: true, configurable: true }); - process.env.OPENCODE_TUI = "1"; + for (const [key, value] of Object.entries(originalEnv)) { + if (value === undefined) { + delete process.env[key]; + } else { + process.env[key] = value; + } + } } }); }); diff --git a/test/storage.test.ts b/test/storage.test.ts index 5b244403..8a645bdd 100644 --- a/test/storage.test.ts +++ b/test/storage.test.ts @@ -2075,13 +2075,6 @@ describe("storage", () => { "utf-8", ); - const preview = await previewDuplicateEmailCleanup(); - expect(preview).toEqual({ - before: 1, - after: 1, - removed: 0, - }); - const result = await cleanupDuplicateEmailAccounts(); expect(result).toEqual({ before: 1, @@ -2093,6 +2086,13 @@ describe("storage", () => { const migrated = await loadAccounts(); expect(migrated?.accounts).toHaveLength(1); expect(migrated?.accounts[0]?.refreshToken).toBe("legacy-newer"); + + const preview = await previewDuplicateEmailCleanup(); + expect(preview).toEqual({ + before: 1, + after: 1, + removed: 0, + }); }); it("loads global storage as fallback when project-scoped storage is missing", async () => { diff --git a/test/sync-prune-backup.test.ts b/test/sync-prune-backup.test.ts index e90015b9..4c38d1a2 100644 --- a/test/sync-prune-backup.test.ts +++ b/test/sync-prune-backup.test.ts @@ -33,4 +33,54 @@ describe("sync prune backup payload", () => { expect(payload.accounts.accounts[0]).not.toHaveProperty("accessToken"); expect(payload.flagged.accounts[0]).not.toHaveProperty("accessToken"); }); + + 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({ + refreshToken: "refresh-token", + metadata: { + source: "flagged", + }, + }); + }); }); From 94e6f636d75fe8c5856bb15865293eda68c24c96 Mon Sep 17 00:00:00 2001 From: ndycode Date: Sun, 8 Mar 2026 21:25:33 +0800 Subject: [PATCH 63/81] fix: close final greptile blockers Co-authored-by: Codex --- index.ts | 11 +++-- lib/cli.ts | 5 ++ lib/codex-multi-auth-sync.ts | 10 +++- test/codex-multi-auth-sync.test.ts | 76 ++++++++++++++++++++++++++++++ 4 files changed, 95 insertions(+), 7 deletions(-) diff --git a/index.ts b/index.ts index 624f78e6..df02ed77 100644 --- a/index.ts +++ b/index.ts @@ -3688,10 +3688,6 @@ while (attempted.size < Math.max(1, accountCount)) { try { const flaggedStorage = await loadFlaggedAccounts(); const activeStorage = await loadAccounts(); - const restoreContext = [ - ...(activeStorage?.accounts ?? []), - ...flaggedStorage.accounts, - ]; if (flaggedStorage.accounts.length === 0) { emit("No flagged accounts to verify."); if (screen && !screenOverride) { @@ -3724,7 +3720,12 @@ while (attempted.size < Math.max(1, accountCount)) { const cached = await lookupCodexCliTokensByEmail(flagged.email); const now = Date.now(); const cachedTokenAccountId = cached ? extractAccountId(cached.accessToken) : undefined; - const restoreEmailCounts = buildEmailCountMap(restoreContext); + const restoreEmailCounts = buildEmailCountMap([ + ...(activeStorage?.accounts ?? []), + ...remaining, + flagged, + ...flaggedStorage.accounts.slice(i + 1).filter(Boolean), + ]); if ( cached && canHydrateCachedTokenForAccount( diff --git a/lib/cli.ts b/lib/cli.ts index c190ee51..cb826f7d 100644 --- a/lib/cli.ts +++ b/lib/cli.ts @@ -345,6 +345,7 @@ export async function promptLoginMode( case "set-current-account": { const index = resolveAccountSourceIndex(action.account); if (index >= 0) return { mode: "manage", switchAccountIndex: index }; + console.log("\nUnable to resolve the selected account. Refresh the menu and try again.\n"); continue; } case "select-account": { @@ -352,21 +353,25 @@ export async function promptLoginMode( if (accountAction === "delete") { const index = resolveAccountSourceIndex(action.account); if (index >= 0) return { mode: "manage", deleteAccountIndex: index }; + console.log("\nUnable to resolve the selected account. Refresh the menu and try again.\n"); continue; } if (accountAction === "set-current") { const index = resolveAccountSourceIndex(action.account); if (index >= 0) return { mode: "manage", switchAccountIndex: index }; + console.log("\nUnable to resolve the selected account. Refresh the menu and try again.\n"); continue; } if (accountAction === "refresh") { const index = resolveAccountSourceIndex(action.account); if (index >= 0) return { mode: "manage", refreshAccountIndex: index }; + console.log("\nUnable to resolve the selected account. Refresh the menu and try again.\n"); continue; } if (accountAction === "toggle") { const index = resolveAccountSourceIndex(action.account); if (index >= 0) return { mode: "manage", toggleAccountIndex: index }; + console.log("\nUnable to resolve the selected account. Refresh the menu and try again.\n"); continue; } continue; diff --git a/lib/codex-multi-auth-sync.ts b/lib/codex-multi-auth-sync.ts index 57e4e3f3..a27521f0 100644 --- a/lib/codex-multi-auth-sync.ts +++ b/lib/codex-multi-auth-sync.ts @@ -137,7 +137,10 @@ async function removeNormalizedImportTempDir( } catch (cleanupError) { lastMessage = cleanupError instanceof Error ? cleanupError.message : String(cleanupError); if (attempt < TEMP_CLEANUP_RETRY_DELAYS_MS.length) { - await sleepAsync(TEMP_CLEANUP_RETRY_DELAYS_MS[attempt] ?? 0); + const delayMs = TEMP_CLEANUP_RETRY_DELAYS_MS[attempt]; + if (delayMs !== undefined) { + await sleepAsync(delayMs); + } continue; } } @@ -805,9 +808,12 @@ function getSyncCapacityLimit(): number { return ACCOUNT_LIMITS.MAX_ACCOUNTS; } const parsed = Number(override); - if (Number.isFinite(parsed) && parsed >= 0) { + if (Number.isFinite(parsed) && parsed > 0) { return parsed; } + logWarn( + `${SYNC_MAX_ACCOUNTS_OVERRIDE_ENV} override value "${override}" is not a positive finite number; ignoring.`, + ); return ACCOUNT_LIMITS.MAX_ACCOUNTS; } diff --git a/test/codex-multi-auth-sync.test.ts b/test/codex-multi-auth-sync.test.ts index d34e663d..62849a6d 100644 --- a/test/codex-multi-auth-sync.test.ts +++ b/test/codex-multi-auth-sync.test.ts @@ -724,6 +724,45 @@ describe("codex-multi-auth sync", () => { } }); + 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("finds the project-scoped codex-multi-auth source across same-repo worktrees", async () => { const rootDir = join(process.cwd(), ".tmp-codex-multi-auth"); process.env.CODEX_MULTI_AUTH_DIR = rootDir; @@ -1254,6 +1293,43 @@ describe("codex-multi-auth sync", () => { ); }); + 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; From 3d1a0b9fe15d173d7a3814ae7d4033d6a1471040 Mon Sep 17 00:00:00 2001 From: ndycode Date: Sun, 8 Mar 2026 21:41:32 +0800 Subject: [PATCH 64/81] fix: tighten windows sync diagnostics Co-authored-by: Codex --- index.ts | 19 +++++++++++++------ lib/codex-multi-auth-sync.ts | 22 +++++++++++++++++----- 2 files changed, 30 insertions(+), 11 deletions(-) diff --git a/index.ts b/index.ts index df02ed77..8cb9b775 100644 --- a/index.ts +++ b/index.ts @@ -3366,14 +3366,15 @@ while (attempted.size < Math.max(1, accountCount)) { tone: "normal" | "muted" | "success" | "warning" | "danger" | "accent" = "normal", ) => { const account = workingStorage.accounts[index]; - const label = formatCommandAccountLabel(account, index); + const label = sanitizeScreenText(formatCommandAccountLabel(account, index)); + const safeDetail = sanitizeScreenText(detail); const prefix = tone === "danger" ? getStatusMarker(ui, "error") : tone === "warning" ? getStatusMarker(ui, "warning") : getStatusMarker(ui, "ok"); - const line = `${prefix} ${label} | ${detail}`; + const line = sanitizeScreenText(`${prefix} ${label} | ${safeDetail}`); if (screen) { screen.push(line, tone); return; @@ -3678,11 +3679,12 @@ while (attempted.size < Math.max(1, accountCount)) { line: string, tone: OperationTone = "normal", ) => { + const safeLine = sanitizeScreenText(line); if (screen) { - screen.push(line, tone); + screen.push(safeLine, tone); return; } - console.log(line); + console.log(safeLine); }; let screenFinished = false; try { @@ -3720,8 +3722,12 @@ while (attempted.size < Math.max(1, accountCount)) { const cached = await lookupCodexCliTokensByEmail(flagged.email); const now = Date.now(); const cachedTokenAccountId = cached ? extractAccountId(cached.accessToken) : undefined; + const restoredIdentityContext = restored.map((entry) => ({ + email: sanitizeEmail(extractAccountEmail(entry.access, entry.idToken)), + })); const restoreEmailCounts = buildEmailCountMap([ ...(activeStorage?.accounts ?? []), + ...restoredIdentityContext, ...remaining, flagged, ...flaggedStorage.accounts.slice(i + 1).filter(Boolean), @@ -4469,11 +4475,12 @@ while (attempted.size < Math.max(1, accountCount)) { line: string, tone: OperationTone = "normal", ) => { + const safeLine = sanitizeScreenText(line); if (screen) { - screen.push(line, tone); + screen.push(safeLine, tone); return; } - console.log(line); + console.log(safeLine); }; try { const initialStorage = await loadAccounts(); diff --git a/lib/codex-multi-auth-sync.ts b/lib/codex-multi-auth-sync.ts index a27521f0..aaf6cdbf 100644 --- a/lib/codex-multi-auth-sync.ts +++ b/lib/codex-multi-auth-sync.ts @@ -129,14 +129,16 @@ async function removeNormalizedImportTempDir( tempPath: string, options: NormalizedImportFileOptions, ): Promise { + const retryableCodes = new Set(["EBUSY", "EAGAIN", "ENOTEMPTY"]); 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 (attempt < TEMP_CLEANUP_RETRY_DELAYS_MS.length) { + 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); @@ -811,9 +813,13 @@ function getSyncCapacityLimit(): number { if (Number.isFinite(parsed) && parsed > 0) { return parsed; } - logWarn( - `${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 finite number; ignoring.`; + logWarn(message); + try { + process.stderr.write(`${message}\n`); + } catch { + // best-effort warning for non-interactive shells + } return ACCOUNT_LIMITS.MAX_ACCOUNTS; } @@ -1101,7 +1107,13 @@ async function loadRawCodexMultiAuthOverlapCleanupStorage( throw new Error("Invalid raw storage snapshot for synced overlap cleanup."); } catch (error) { const code = (error as NodeJS.ErrnoException).code; - if (code === "ENOENT" || code === "EBUSY" || code === "EACCES" || code === "EPERM") { + if (code === "ENOENT" || code === "EBUSY") { + return fallback; + } + if (code === "EACCES" || code === "EPERM") { + logWarn( + `Permission denied reading raw storage snapshot for synced overlap cleanup (${code}); using transaction snapshot fallback.`, + ); return fallback; } const message = error instanceof Error ? error.message : String(error); From 0a1816517980d3f29bcc06a9d4c95106cfe898ab Mon Sep 17 00:00:00 2001 From: ndycode Date: Mon, 9 Mar 2026 06:29:54 +0800 Subject: [PATCH 65/81] fix: close remaining review comments Co-authored-by: Codex --- index.ts | 22 +++++++++++++++------ lib/cli.ts | 14 +++++++++++--- lib/codex-multi-auth-sync.ts | 25 +++++++++++++++++++----- lib/storage.ts | 31 ++++++++++++++++++++++++++++-- lib/ui/auth-menu.ts | 2 +- lib/ui/select.ts | 8 ++++++-- test/codex-multi-auth-sync.test.ts | 22 +++++++++++++-------- 7 files changed, 97 insertions(+), 27 deletions(-) diff --git a/index.ts b/index.ts index 8cb9b775..95d910d2 100644 --- a/index.ts +++ b/index.ts @@ -3724,6 +3724,7 @@ while (attempted.size < Math.max(1, accountCount)) { const cachedTokenAccountId = cached ? extractAccountId(cached.accessToken) : undefined; const restoredIdentityContext = restored.map((entry) => ({ email: sanitizeEmail(extractAccountEmail(entry.access, entry.idToken)), + accountId: entry.accountIdOverride ?? extractAccountId(entry.access), })); const restoreEmailCounts = buildEmailCountMap([ ...(activeStorage?.accounts ?? []), @@ -4140,6 +4141,15 @@ while (attempted.size < Math.max(1, accountCount)) { await currentBackup.restore(); pruneBackup = null; }; + const safeRestorePruneBackup = async (context: string): Promise => { + try { + await restorePruneBackup(); + } catch (restoreError) { + const message = + restoreError instanceof Error ? restoreError.message : String(restoreError); + console.log(`\nFailed to restore pruned accounts during ${context}: ${message}\n`); + } + }; while (true) { try { const loadedSource = await loadCodexMultiAuthSourceStorage(process.cwd()); @@ -4174,7 +4184,7 @@ while (attempted.size < Math.max(1, accountCount)) { `Import ${preview.imported} new account(s) from codex-multi-auth?`, ); if (!confirmed) { - await restorePruneBackup(); + await safeRestorePruneBackup("sync cancellation"); console.log("\nSync cancelled.\n"); return; } @@ -4212,7 +4222,7 @@ while (attempted.size < Math.max(1, accountCount)) { console.log(`Importable new accounts: ${details.importableNewAccounts}`); console.log(`Maximum allowed: ${details.maxAccounts}`); if (isCodexMultiAuthSourceTooLargeForCapacity(details)) { - await restorePruneBackup(); + await safeRestorePruneBackup("capacity handling"); console.log( `Source alone exceeds the configured maximum. Reduce the source set or raise CODEX_AUTH_SYNC_MAX_ACCOUNTS before retrying.`, ); @@ -4239,7 +4249,7 @@ while (attempted.size < Math.max(1, accountCount)) { details.suggestedRemovals, ); if (!indexesToRemove || indexesToRemove.length === 0) { - await restorePruneBackup(); + await safeRestorePruneBackup("sync cancellation"); console.log("Sync cancelled.\n"); return; } @@ -4252,7 +4262,7 @@ while (attempted.size < Math.max(1, accountCount)) { } catch (planError) { const message = planError instanceof Error ? planError.message : String(planError); - await restorePruneBackup(); + await safeRestorePruneBackup("removal planning"); console.log(`\nSync failed: ${message}\n`); return; } @@ -4265,7 +4275,7 @@ while (attempted.size < Math.max(1, accountCount)) { `Remove ${indexesToRemove.length} selected account(s) and retry sync?`, ); if (!confirmed) { - await restorePruneBackup(); + await safeRestorePruneBackup("sync cancellation"); console.log("Sync cancelled.\n"); return; } @@ -4276,7 +4286,7 @@ while (attempted.size < Math.max(1, accountCount)) { continue; } const message = error instanceof Error ? error.message : String(error); - await restorePruneBackup(); + await safeRestorePruneBackup("sync failure"); console.log(`\nSync failed: ${message}\n`); return; } diff --git a/lib/cli.ts b/lib/cli.ts index cb826f7d..8667764c 100644 --- a/lib/cli.ts +++ b/lib/cli.ts @@ -11,6 +11,9 @@ import { } from "./ui/auth-menu.js"; import { UI_COPY } from "./ui/copy.js"; +const ANSI_CSI_REGEX = new RegExp("\\x1b\\[[0-?]*[ -/]*[@-~]", "g"); +const CONTROL_CHAR_REGEX = new RegExp("[\\u0000-\\u001f\\u007f]", "g"); + export function isNonInteractiveMode(): boolean { if (process.env.FORCE_INTERACTIVE_MODE === "1") return false; if (!input.isTTY || !output.isTTY) return true; @@ -180,9 +183,14 @@ export interface LoginMenuResult { function formatAccountLabel(account: ExistingAccountInfo, index: number): string { const num = account.quickSwitchNumber ?? (index + 1); - const label = account.accountLabel?.trim(); - const email = account.email?.trim(); - const accountId = account.accountId?.trim(); + const sanitizeFallbackLabel = (value: string | undefined): string | undefined => { + if (!value) return undefined; + const sanitized = value.replace(ANSI_CSI_REGEX, "").replace(CONTROL_CHAR_REGEX, "").trim(); + return sanitized.length > 0 ? sanitized : undefined; + }; + const label = sanitizeFallbackLabel(account.accountLabel); + const email = sanitizeFallbackLabel(account.email); + const accountId = sanitizeFallbackLabel(account.accountId); const accountIdDisplay = accountId && accountId.length > 14 ? `${accountId.slice(0, 8)}...${accountId.slice(-6)}` diff --git a/lib/codex-multi-auth-sync.ts b/lib/codex-multi-auth-sync.ts index aaf6cdbf..2e23483d 100644 --- a/lib/codex-multi-auth-sync.ts +++ b/lib/codex-multi-auth-sync.ts @@ -525,9 +525,11 @@ function buildSourceIdentitySet(storage: AccountStorageV3): Set { const organizationId = normalizeIdentity(account.organizationId); const accountId = normalizeIdentity(account.accountId); const email = normalizeIdentity(account.email); + const refreshToken = normalizeIdentity(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; } @@ -536,10 +538,12 @@ function accountMatchesSource(account: AccountStorageV3["accounts"][number], sou const organizationId = normalizeIdentity(account.organizationId); const accountId = normalizeIdentity(account.accountId); const email = normalizeIdentity(account.email); + const refreshToken = normalizeIdentity(account.refreshToken); return ( (organizationId ? sourceIdentities.has(`org:${organizationId}`) : false) || (accountId ? sourceIdentities.has(`account:${accountId}`) : false) || - (email ? sourceIdentities.has(`email:${email}`) : false) + (email ? sourceIdentities.has(`email:${email}`) : false) || + (refreshToken ? sourceIdentities.has(`refresh:${refreshToken}`) : false) ); } @@ -890,6 +894,7 @@ export async function previewSyncFromCodexMultiAuth( const preview = await withNormalizedImportFile( resolved.storage, (filePath) => previewImportAccountsWithExistingStorage(filePath, existing), + { postSuccessCleanupFailureMode: "warn" }, ); return { rootDir: resolved.rootDir, @@ -1065,14 +1070,23 @@ function normalizeOverlapCleanupSourceStorage(data: unknown): AccountStorageV3 | (data as { version?: unknown }).version === 1 ? migrateV1ToV3(data as AccountStorageV1) : (data as AccountStorageV3); - const accounts = baseRecord.accounts.filter((account): account is AccountStorageV3["accounts"][number] => { - return typeof account.refreshToken === "string" && account.refreshToken.trim().length > 0; + 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 activeIndex = Math.max(0, Math.min(accounts.length - 1, activeIndexValue)); + 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 @@ -1082,7 +1096,8 @@ function normalizeOverlapCleanupSourceStorage(data: unknown): AccountStorageV3 | if (typeof value !== "number" || !Number.isFinite(value)) { return []; } - return [[family, Math.max(0, Math.min(accounts.length - 1, value))]]; + const remappedValue = originalToFilteredIndex.get(value) ?? value; + return [[family, Math.max(0, Math.min(accounts.length - 1, remappedValue))]]; }), ) as AccountStorageV3["activeIndexByFamily"]; diff --git a/lib/storage.ts b/lib/storage.ts index 09754fab..2b8413ad 100644 --- a/lib/storage.ts +++ b/lib/storage.ts @@ -503,8 +503,15 @@ function buildDuplicateEmailCleanupPlan(existing: AccountStorageV3): { const byIdentity = findAccountIndexByIdentityKeys(deduplicatedAccounts, existingActiveKeys); if (byIdentity >= 0) return byIdentity; } - const byEmail = findAccountIndexByNormalizedEmail(deduplicatedAccounts, existingActiveEmail); + const byEmail = + existingActiveKeys.length === 0 + ? findComparableAccountIndexByNormalizedEmail(deduplicatedAccounts, existingActiveEmail) + : findAccountIndexByNormalizedEmail(deduplicatedAccounts, existingActiveEmail); if (byEmail >= 0) return byEmail; + if (existingActiveKeys.length === 0) { + const fallbackByEmail = findAccountIndexByNormalizedEmail(deduplicatedAccounts, existingActiveEmail); + if (fallbackByEmail >= 0) return fallbackByEmail; + } return clampIndex(existingActiveIndex, deduplicatedAccounts.length); })(); @@ -531,9 +538,17 @@ function buildDuplicateEmailCleanupPlan(existing: AccountStorageV3): { } } - const byEmail = findAccountIndexByNormalizedEmail(deduplicatedAccounts, familyEmail); + const byEmail = + familyKeys.length === 0 + ? findComparableAccountIndexByNormalizedEmail(deduplicatedAccounts, familyEmail) + : findAccountIndexByNormalizedEmail(deduplicatedAccounts, familyEmail); if (byEmail >= 0) { mappedIndex = byEmail; + } else if (familyKeys.length === 0) { + const fallbackByEmail = findAccountIndexByNormalizedEmail(deduplicatedAccounts, familyEmail); + if (fallbackByEmail >= 0) { + mappedIndex = fallbackByEmail; + } } activeIndexByFamily[family] = mappedIndex; } @@ -791,6 +806,17 @@ function findAccountIndexByNormalizedEmail( return accounts.findIndex((account) => normalizeEmailIdentity(account.email) === normalizedEmail); } +function findComparableAccountIndexByNormalizedEmail( + accounts: AccountMetadataV3[], + normalizedEmail: string | undefined, +): number { + if (!normalizedEmail) return -1; + return accounts.findIndex((account) => { + if (normalizeEmailIdentity(account.email) !== normalizedEmail) return false; + return extractActiveKeys([account], 0).length === 0; + }); +} + /** * Normalizes and validates account storage data, migrating from v1 to v3 if needed. * Handles deduplication, index clamping, and per-family active index mapping. @@ -1463,6 +1489,7 @@ export async function backupRawAccountsFile(filePath: string, force = true): Pro throw new Error(`File already exists: ${resolvedPath}`); } + await migrateLegacyProjectStorageIfNeeded(saveAccountsUnlocked); const storagePath = getStoragePath(); if (!existsSync(storagePath)) { throw new Error("No accounts to back up"); diff --git a/lib/ui/auth-menu.ts b/lib/ui/auth-menu.ts index 07f8191f..5b25462f 100644 --- a/lib/ui/auth-menu.ts +++ b/lib/ui/auth-menu.ts @@ -142,7 +142,7 @@ function statusBadge(status: AccountStatus | undefined): string { } function formatAccountIdSuffix(accountId: string | undefined): string | undefined { - const trimmed = accountId?.trim(); + const trimmed = sanitizeTerminalText(accountId); if (!trimmed) return undefined; return trimmed.length > 14 ? `${trimmed.slice(0, 8)}...${trimmed.slice(-6)}` : trimmed; } diff --git a/lib/ui/select.ts b/lib/ui/select.ts index c4b57c70..0ac84d93 100644 --- a/lib/ui/select.ts +++ b/lib/ui/select.ts @@ -215,10 +215,14 @@ export function coalesceTerminalInput( if (nextPending.hasEscape && base === "\x1b[" && canCompleteCsi(nextInput)) { return { normalizedInput: `\x1b[${nextInput}`, pending: null }; } - if (nextPending.hasEscape && /^\x1b\[\d+$/.test(base) && canCompleteCsi(nextInput)) { + if (nextPending.hasEscape && /^\x1b\[[\d;]+$/.test(base) && canCompleteCsi(nextInput)) { return { normalizedInput: `${base}${nextInput}`, pending: null }; } - if (nextPending.hasEscape && (base === "\x1b[" || /^\x1b\[\d+$/.test(base)) && /^\d+$/.test(nextInput)) { + if ( + nextPending.hasEscape && + (base === "\x1b[" || /^\x1b\[[\d;]+$/.test(base)) && + /^[\d;]+$/.test(nextInput) + ) { return { normalizedInput: null, pending: { value: `${base}${nextInput}`, hasEscape: true } }; } if (nextPending.hasEscape && base === "\x1bO" && CSI_FINAL_KEYS.has(nextInput)) { diff --git a/test/codex-multi-auth-sync.test.ts b/test/codex-multi-auth-sync.test.ts index 62849a6d..077f5fa7 100644 --- a/test/codex-multi-auth-sync.test.ts +++ b/test/codex-multi-auth-sync.test.ts @@ -695,7 +695,7 @@ describe("codex-multi-auth sync", () => { } }); - it("fails closed and logs a warning when secure temp cleanup fails", async () => { + 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"); @@ -713,9 +713,12 @@ describe("codex-multi-auth sync", () => { try { const { previewSyncFromCodexMultiAuth } = await import("../lib/codex-multi-auth-sync.js"); - await expect(previewSyncFromCodexMultiAuth(process.cwd())).rejects.toThrow( - /Failed to remove temporary codex sync directory/, - ); + 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"), ); @@ -846,7 +849,7 @@ describe("codex-multi-auth sync", () => { }); }); - it("fails preview when secure temp cleanup leaves sync data on disk", async () => { + 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"); @@ -864,9 +867,12 @@ describe("codex-multi-auth sync", () => { try { const { previewSyncFromCodexMultiAuth } = await import("../lib/codex-multi-auth-sync.js"); - await expect(previewSyncFromCodexMultiAuth(process.cwd())).rejects.toThrow( - /Failed to remove temporary codex sync directory/, - ); + await expect(previewSyncFromCodexMultiAuth(process.cwd())).resolves.toMatchObject({ + accountsPath: globalPath, + imported: 2, + skipped: 0, + total: 4, + }); } finally { rmSpy.mockRestore(); } From ee903b06505a286250204265cbc8f011ba0b8434 Mon Sep 17 00:00:00 2001 From: ndycode Date: Mon, 9 Mar 2026 15:59:01 -0700 Subject: [PATCH 66/81] fix: stop retrying non-retryable sync cleanup errors --- lib/codex-multi-auth-sync.ts | 1 + test/codex-multi-auth-sync.test.ts | 44 +++++++++++++++++++++++++++--- 2 files changed, 41 insertions(+), 4 deletions(-) diff --git a/lib/codex-multi-auth-sync.ts b/lib/codex-multi-auth-sync.ts index 2e23483d..a101baaa 100644 --- a/lib/codex-multi-auth-sync.ts +++ b/lib/codex-multi-auth-sync.ts @@ -145,6 +145,7 @@ async function removeNormalizedImportTempDir( } continue; } + break; } } diff --git a/test/codex-multi-auth-sync.test.ts b/test/codex-multi-auth-sync.test.ts index 077f5fa7..a603e84d 100644 --- a/test/codex-multi-auth-sync.test.ts +++ b/test/codex-multi-auth-sync.test.ts @@ -681,15 +681,14 @@ describe("codex-multi-auth sync", () => { }), ); - const mkdtempSpy = vi.spyOn(fs.promises, "mkdtemp").mockRejectedValueOnce(new Error("mkdtemp failed")); + 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(vi.mocked(storageModule.previewImportAccounts)).not.toHaveBeenCalledWith( - expect.stringContaining(os.tmpdir()), - ); + expect(mkdtempSpy).toHaveBeenCalledTimes(1); + expect(vi.mocked(storageModule.previewImportAccountsWithExistingStorage)).not.toHaveBeenCalled(); } finally { mkdtempSpy.mockRestore(); } @@ -727,6 +726,43 @@ describe("codex-multi-auth sync", () => { } }); + it("fails fast on non-retryable Windows-style 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("permission denied"), { code: "EACCES" })); + 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(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; From 848d1b6d503ffd6b4e4fc0a15ddec0c9a5bfa18e Mon Sep 17 00:00:00 2001 From: ndycode Date: Mon, 9 Mar 2026 15:59:12 -0700 Subject: [PATCH 67/81] fix: harden prune backup rollback safety --- index.ts | 77 +++++++++++++++++++------ test/index.test.ts | 138 +++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 197 insertions(+), 18 deletions(-) diff --git a/index.ts b/index.ts index 95d910d2..7c551e58 100644 --- a/index.ts +++ b/index.ts @@ -1589,6 +1589,29 @@ export const OpenAIOAuthPlugin: Plugin = async ({ client }: PluginInput) => { return counts; }; + const updateEmailCountMap = ( + emailCounts: Map, + previousEmail: string | undefined, + nextEmail: string | undefined, + ): void => { + const previousNormalized = sanitizeEmail(previousEmail); + const nextNormalized = sanitizeEmail(nextEmail); + if (previousNormalized === nextNormalized) { + return; + } + if (previousNormalized) { + const nextCount = (emailCounts.get(previousNormalized) ?? 0) - 1; + if (nextCount > 0) { + emailCounts.set(previousNormalized, nextCount); + } else { + emailCounts.delete(previousNormalized); + } + } + if (nextNormalized) { + emailCounts.set(nextNormalized, (emailCounts.get(nextNormalized) ?? 0) + 1); + } + }; + const canHydrateCachedTokenForAccount = ( emailCounts: Map, account: { email?: string; accountId?: string }, @@ -3402,6 +3425,7 @@ while (attempted.size < Math.max(1, accountCount)) { let ok = 0; let disabled = 0; let errors = 0; + const workingEmailCounts = buildEmailCountMap(workingStorage.accounts); for (let i = 0; i < total; i += 1) { const account = workingStorage.accounts[i]; @@ -3447,11 +3471,10 @@ while (attempted.size < Math.max(1, accountCount)) { if (!accessToken) { const cached = await lookupCodexCliTokensByEmail(account.email); const cachedTokenAccountId = cached ? extractAccountId(cached.accessToken) : undefined; - const emailCounts = buildEmailCountMap(workingStorage.accounts); if ( cached && canHydrateCachedTokenForAccount( - emailCounts, + workingEmailCounts, account, cachedTokenAccountId, ) && @@ -3479,6 +3502,7 @@ while (attempted.size < Math.max(1, accountCount)) { extractAccountEmail(cached.accessToken), ); if (hydratedEmail && hydratedEmail !== account.email) { + updateEmailCountMap(workingEmailCounts, account.email, hydratedEmail); account.email = hydratedEmail; storageChanged = true; } @@ -3555,6 +3579,7 @@ while (attempted.size < Math.max(1, accountCount)) { extractAccountEmail(refreshResult.access, refreshResult.idToken), ); if (hydratedEmail && hydratedEmail !== account.email) { + updateEmailCountMap(workingEmailCounts, account.email, hydratedEmail); account.email = hydratedEmail; storageChanged = true; } @@ -3907,20 +3932,25 @@ while (attempted.size < Math.max(1, accountCount)) { ) { throw new Error("Prune backup flagged snapshot failed validation."); } - const liveAccountsBeforeRestore = - (await loadAccounts()) ?? - ({ - version: 3, - accounts: [], - activeIndex: 0, - activeIndexByFamily: {}, - } satisfies AccountStorageV3); - try { - await saveAccounts(normalizedAccounts); - } catch (error) { - const message = error instanceof Error ? error.message : String(error); - throw new Error(`Failed to restore account storage from prune backup: ${message}`); - } + const emptyAccountsStorage = { + version: 3, + accounts: [], + activeIndex: 0, + activeIndexByFamily: {}, + } satisfies AccountStorageV3; + const restoredAccountsSnapshot = JSON.stringify(normalizedAccounts); + const liveAccountsBeforeRestore = await withAccountStorageTransaction( + async (current, persist) => { + const snapshot = current ?? emptyAccountsStorage; + try { + await persist(normalizedAccounts); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + throw new Error(`Failed to restore account storage from prune backup: ${message}`); + } + return snapshot; + }, + ); try { await saveFlaggedAccounts( flaggedSnapshot as { version: 1; accounts: FlaggedAccountMetadataV1[] }, @@ -3928,7 +3958,18 @@ while (attempted.size < Math.max(1, accountCount)) { } catch (error) { const message = error instanceof Error ? error.message : String(error); try { - await saveAccounts(liveAccountsBeforeRestore); + let rolledBack = false; + await withAccountStorageTransaction(async (current, persist) => { + const currentStorage = current ?? emptyAccountsStorage; + if (JSON.stringify(currentStorage) !== restoredAccountsSnapshot) { + return; + } + await persist(liveAccountsBeforeRestore); + rolledBack = true; + }); + if (!rolledBack) { + throw new Error("Account storage changed concurrently before rollback could be applied."); + } } catch (rollbackError) { const rollbackMessage = rollbackError instanceof Error ? rollbackError.message : String(rollbackError); @@ -4138,8 +4179,8 @@ while (attempted.size < Math.max(1, accountCount)) { const restorePruneBackup = async (): Promise => { const currentBackup = pruneBackup; if (!currentBackup) return; - await currentBackup.restore(); pruneBackup = null; + await currentBackup.restore(); }; const safeRestorePruneBackup = async (context: string): Promise => { try { diff --git a/test/index.test.ts b/test/index.test.ts index 71bcc112..a08f84de 100644 --- a/test/index.test.ts +++ b/test/index.test.ts @@ -3093,6 +3093,144 @@ describe("OpenAIOAuthPlugin persistAccountPool", () => { } }); + it("does not overwrite concurrent account changes when prune-backup rollback cannot safely apply", async () => { + const cliModule = await import("../lib/cli.js"); + const storageModule = await import("../lib/storage.js"); + const syncModule = await import("../lib/codex-multi-auth-sync.js"); + const configModule = await import("../lib/config.js"); + const confirmModule = await import("../lib/ui/confirm.js"); + + const tempDir = await fs.mkdtemp(join(tmpdir(), "oc-sync-prune-concurrent-")); + try { + mockStorage.accounts = [ + { + accountId: "org-keep", + organizationId: "org-keep", + accountIdSource: "org", + email: "keep@example.com", + refreshToken: "refresh-keep", + }, + { + accountId: "org-prune", + organizationId: "org-prune", + accountIdSource: "org", + email: "prune@example.com", + refreshToken: "refresh-prune", + }, + ]; + mockStorage.activeIndex = 0; + mockStorage.activeIndexByFamily = {}; + + vi.mocked(cliModule.promptLoginMode) + .mockResolvedValueOnce({ mode: "experimental-sync-now" }) + .mockResolvedValueOnce({ mode: "cancel" }); + vi.mocked(cliModule.promptCodexMultiAuthSyncPrune).mockResolvedValueOnce([1]); + vi.mocked(configModule.getSyncFromCodexMultiAuthEnabled).mockReturnValue(true); + vi.mocked(confirmModule.confirm).mockResolvedValueOnce(true); + + vi.mocked(storageModule.createTimestampedBackupPath).mockImplementation((prefix?: string) => + join(tempDir, `${prefix ?? "codex-backup"}.json`), + ); + vi.mocked(storageModule.exportAccounts).mockImplementation(async (filePath: string) => { + await fs.writeFile( + filePath, + JSON.stringify({ + version: mockStorage.version, + activeIndex: mockStorage.activeIndex, + activeIndexByFamily: { ...mockStorage.activeIndexByFamily }, + accounts: mockStorage.accounts.map((account) => ({ ...account })), + }), + "utf8", + ); + }); + vi.mocked(storageModule.loadAccounts).mockImplementation(async () => ({ + ...mockStorage, + accounts: mockStorage.accounts.map((account) => ({ ...account })), + activeIndexByFamily: { ...mockStorage.activeIndexByFamily }, + })); + vi.mocked(storageModule.withAccountStorageTransaction).mockImplementation( + async (callback) => { + const loadedStorage = { + ...mockStorage, + accounts: mockStorage.accounts.map((account) => ({ ...account })), + activeIndexByFamily: { ...mockStorage.activeIndexByFamily }, + }; + const persist = async (nextStorage: typeof mockStorage) => { + mockStorage.version = nextStorage.version; + mockStorage.accounts = nextStorage.accounts.map((account) => ({ ...account })); + mockStorage.activeIndex = nextStorage.activeIndex; + mockStorage.activeIndexByFamily = { ...nextStorage.activeIndexByFamily }; + }; + await callback(loadedStorage, persist); + }, + ); + vi.mocked(storageModule.saveFlaggedAccounts) + .mockResolvedValueOnce(undefined) + .mockImplementationOnce(async () => { + mockStorage.accounts = [ + { + accountId: "org-concurrent", + organizationId: "org-concurrent", + accountIdSource: "org", + email: "concurrent@example.com", + refreshToken: "refresh-concurrent", + }, + ]; + mockStorage.activeIndex = 0; + mockStorage.activeIndexByFamily = {}; + throw new Error("flagged write failed"); + }); + + const capacityError = new syncModule.CodexMultiAuthSyncCapacityError({ + rootDir: tempDir, + accountsPath: join(tempDir, "openai-codex-accounts.json"), + scope: "global", + currentCount: 2, + sourceCount: 2, + sourceDedupedTotal: 3, + dedupedTotal: 3, + maxAccounts: 2, + needToRemove: 1, + importableNewAccounts: 1, + skippedOverlaps: 1, + suggestedRemovals: [ + { + index: 1, + email: "prune@example.com", + accountLabel: "Workspace prune", + isCurrentAccount: false, + score: 180, + reason: "disabled", + }, + ], + }); + + vi.mocked(syncModule.previewSyncFromCodexMultiAuth) + .mockRejectedValueOnce(capacityError) + .mockResolvedValueOnce({ + rootDir: tempDir, + accountsPath: join(tempDir, "openai-codex-accounts.json"), + scope: "global", + imported: 0, + skipped: 1, + total: 1, + }); + + const mockClient = createMockClient(); + const { OpenAIOAuthPlugin } = await import("../index.js"); + const plugin = (await OpenAIOAuthPlugin({ client: mockClient } as never)) as unknown as PluginType; + const autoMethod = plugin.auth.methods[0] as unknown as { + authorize: (inputs?: Record) => Promise<{ instructions: string }>; + }; + + const authResult = await autoMethod.authorize(); + expect(authResult.instructions).toBe("Authentication cancelled"); + expect(mockStorage.accounts.map((account) => account.accountId)).toEqual(["org-concurrent"]); + } finally { + await fs.rm(tempDir, { recursive: true, force: true }); + } + }); + it("aborts sync prune when a selected account disappears before confirmation", async () => { const cliModule = await import("../lib/cli.js"); const storageModule = await import("../lib/storage.js"); From 8ff0a6d6fac05d12221429e60a7c06c8d986ff40 Mon Sep 17 00:00:00 2001 From: ndycode Date: Mon, 9 Mar 2026 16:21:40 -0700 Subject: [PATCH 68/81] fix: log overlap cleanup lock fallbacks --- lib/codex-multi-auth-sync.ts | 6 +++--- test/codex-multi-auth-sync.test.ts | 4 ++++ 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/lib/codex-multi-auth-sync.ts b/lib/codex-multi-auth-sync.ts index a101baaa..4f74fc0f 100644 --- a/lib/codex-multi-auth-sync.ts +++ b/lib/codex-multi-auth-sync.ts @@ -1123,12 +1123,12 @@ async function loadRawCodexMultiAuthOverlapCleanupStorage( throw new Error("Invalid raw storage snapshot for synced overlap cleanup."); } catch (error) { const code = (error as NodeJS.ErrnoException).code; - if (code === "ENOENT" || code === "EBUSY") { + if (code === "ENOENT") { return fallback; } - if (code === "EACCES" || code === "EPERM") { + if (code === "EBUSY" || code === "EACCES" || code === "EPERM") { logWarn( - `Permission denied reading raw storage snapshot for synced overlap cleanup (${code}); using transaction snapshot fallback.`, + `Failed reading raw storage snapshot for synced overlap cleanup (${code}); using transaction snapshot fallback.`, ); return fallback; } diff --git a/test/codex-multi-auth-sync.test.ts b/test/codex-multi-auth-sync.test.ts index a603e84d..b460a84f 100644 --- a/test/codex-multi-auth-sync.test.ts +++ b/test/codex-multi-auth-sync.test.ts @@ -1711,6 +1711,7 @@ describe("codex-multi-auth sync", () => { 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; @@ -1761,6 +1762,9 @@ describe("codex-multi-auth sync", () => { } 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 () => { From c9618d5eeb70e240293198c89b712ecac4f0895f Mon Sep 17 00:00:00 2001 From: ndycode Date: Mon, 9 Mar 2026 16:22:03 -0700 Subject: [PATCH 69/81] fix: cap sync prune retries and cover token gates --- index.ts | 35 ++++---- test/index.test.ts | 220 +++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 238 insertions(+), 17 deletions(-) diff --git a/index.ts b/index.ts index 7c551e58..811d5ca8 100644 --- a/index.ts +++ b/index.ts @@ -3436,7 +3436,6 @@ while (attempted.size < Math.max(1, accountCount)) { continue; } - try { // If we already have a valid cached access token, don't force-refresh. // This avoids flagging accounts where the refresh token has been burned // but the access token is still valid (same behavior as Codex CLI). @@ -3635,11 +3634,6 @@ while (attempted.size < Math.max(1, accountCount)) { const message = error instanceof Error ? error.message : String(error); emit(i, `error: ${message.slice(0, 160)}`, "danger"); } - } catch (error) { - errors += 1; - const message = error instanceof Error ? error.message : String(error); - emit(i, `error: ${message.slice(0, 120)}`, "danger"); - } } if (removeFromActive.size > 0) { @@ -4191,7 +4185,10 @@ while (attempted.size < Math.max(1, accountCount)) { console.log(`\nFailed to restore pruned accounts during ${context}: ${message}\n`); } }; - while (true) { + const syncPruneMaxAttempts = 5; + let syncPruneAttempts = 0; + while (syncPruneAttempts < syncPruneMaxAttempts) { + syncPruneAttempts += 1; try { const loadedSource = await loadCodexMultiAuthSourceStorage(process.cwd()); const preview = await previewSyncFromCodexMultiAuth(process.cwd(), loadedSource); @@ -4250,8 +4247,8 @@ while (attempted.size < Math.max(1, accountCount)) { console.log(""); return; } catch (error) { - if (error instanceof CodexMultiAuthSyncCapacityError) { - const { details } = error; + if (error instanceof CodexMultiAuthSyncCapacityError) { + const { details } = error; console.log(""); console.log("Sync blocked by account limit."); console.log(`Source: ${details.accountsPath}`); @@ -4320,18 +4317,22 @@ while (attempted.size < Math.max(1, accountCount)) { console.log("Sync cancelled.\n"); return; } - if (!pruneBackup) { - pruneBackup = await createSyncPruneBackup(); + if (!pruneBackup) { + pruneBackup = await createSyncPruneBackup(); + } + await removeAccountsForSync(removalPlan.targets); + continue; } - await removeAccountsForSync(removalPlan.targets); - continue; - } - const message = error instanceof Error ? error.message : String(error); - await safeRestorePruneBackup("sync failure"); - console.log(`\nSync failed: ${message}\n`); + const message = error instanceof Error ? error.message : String(error); + await safeRestorePruneBackup("sync failure"); + console.log(`\nSync failed: ${message}\n`); return; } } + console.log( + "\nSync hit max retry limit - raise CODEX_AUTH_SYNC_MAX_ACCOUNTS or remove accounts manually.\n", + ); + return; }; const runCodexMultiAuthOverlapCleanup = async (): Promise => { diff --git a/test/index.test.ts b/test/index.test.ts index a08f84de..31cd3e73 100644 --- a/test/index.test.ts +++ b/test/index.test.ts @@ -2696,6 +2696,114 @@ describe("OpenAIOAuthPlugin persistAccountPool", () => { }); }); + it("hydrates shared-email cached tokens only for the flagged account whose accountId matches the token", async () => { + const cliModule = await import("../lib/cli.js"); + const storageModule = await import("../lib/storage.js"); + const accountsModule = await import("../lib/accounts.js"); + const refreshQueueModule = await import("../lib/refresh-queue.js"); + + const flaggedAccounts = [ + { + refreshToken: "flagged-refresh-a", + organizationId: "org-shared-a", + accountId: "shared-a", + accountIdSource: "manual", + accountLabel: "Shared Workspace A", + email: "shared@example.com", + flaggedAt: Date.now() - 1000, + addedAt: Date.now() - 1000, + lastUsed: Date.now() - 1000, + }, + { + refreshToken: "flagged-refresh-b", + organizationId: "org-shared-b", + accountId: "shared-b", + accountIdSource: "manual", + accountLabel: "Shared Workspace B", + email: "shared@example.com", + flaggedAt: Date.now() - 500, + addedAt: Date.now() - 500, + lastUsed: Date.now() - 500, + }, + ]; + + vi.mocked(cliModule.promptLoginMode) + .mockResolvedValueOnce({ mode: "verify-flagged" }) + .mockResolvedValueOnce({ mode: "cancel" }); + + vi.mocked(storageModule.loadFlaggedAccounts) + .mockResolvedValueOnce({ + version: 1, + accounts: flaggedAccounts, + }) + .mockResolvedValueOnce({ + version: 1, + accounts: flaggedAccounts, + }) + .mockResolvedValueOnce({ + version: 1, + accounts: [], + }); + + vi.mocked(accountsModule.lookupCodexCliTokensByEmail).mockImplementation(async (email) => { + if (email === "shared@example.com") { + return { + accessToken: "cached-access-b", + refreshToken: "cached-refresh-b", + expiresAt: Date.now() + 60_000, + }; + } + return null; + }); + vi.mocked(accountsModule.extractAccountId).mockImplementation((token) => { + if (token === "cached-access-b") return "shared-b"; + if (token === "live-access-a") return "shared-a"; + return "account-1"; + }); + vi.mocked(accountsModule.extractAccountEmail).mockImplementation((accessToken) => { + if (accessToken === "cached-access-b" || accessToken === "live-access-a") { + return "shared@example.com"; + } + return "user@example.com"; + }); + vi.mocked(accountsModule.getAccountIdCandidates).mockReturnValue([ + { + accountId: "shared-b", + source: "token", + label: "Shared Workspace B [id:shared-b]", + }, + ]); + vi.mocked(accountsModule.selectBestAccountCandidate).mockImplementation( + (candidates) => candidates[0] ?? null, + ); + vi.mocked(refreshQueueModule.queuedRefresh).mockResolvedValueOnce({ + type: "success", + access: "live-access-a", + refresh: "live-refresh-a", + expires: Date.now() + 60_000, + }); + + const mockClient = createMockClient(); + const { OpenAIOAuthPlugin } = await import("../index.js"); + const plugin = (await OpenAIOAuthPlugin({ + client: mockClient, + } as never)) as unknown as PluginType; + const autoMethod = plugin.auth.methods[0] as unknown as { + authorize: (inputs?: Record) => Promise<{ instructions: string }>; + }; + + const authResult = await autoMethod.authorize(); + expect(authResult.instructions).toBe("Authentication cancelled"); + expect(vi.mocked(refreshQueueModule.queuedRefresh)).toHaveBeenCalledTimes(1); + expect(mockStorage.accounts).toHaveLength(2); + expect( + mockStorage.accounts.find((account) => account.organizationId === "org-shared-a")?.refreshToken, + ).toBe("live-refresh-a"); + expect( + mockStorage.accounts.find((account) => account.organizationId === "org-shared-b")?.refreshToken, + ).toBe("cached-refresh-b"); + }); + it("reloads storage after synced overlap cleanup before persisting auto-repair refreshes", async () => { const cliModule = await import("../lib/cli.js"); const storageModule = await import("../lib/storage.js"); @@ -3231,6 +3339,118 @@ describe("OpenAIOAuthPlugin persistAccountPool", () => { } }); + it("stops sync-prune retries after repeated capacity failures", async () => { + const cliModule = await import("../lib/cli.js"); + const storageModule = await import("../lib/storage.js"); + const syncModule = await import("../lib/codex-multi-auth-sync.js"); + const configModule = await import("../lib/config.js"); + const confirmModule = await import("../lib/ui/confirm.js"); + + const tempDir = await fs.mkdtemp(join(tmpdir(), "oc-sync-prune-max-")); + const consoleSpy = vi.spyOn(console, "log").mockImplementation(() => {}); + try { + mockStorage.accounts = [ + { + accountId: "org-keep", + organizationId: "org-keep", + accountIdSource: "org", + email: "keep@example.com", + refreshToken: "refresh-keep", + }, + { + accountId: "org-prune", + organizationId: "org-prune", + accountIdSource: "org", + email: "prune@example.com", + refreshToken: "refresh-prune", + }, + ]; + mockStorage.activeIndex = 0; + mockStorage.activeIndexByFamily = {}; + + vi.mocked(cliModule.promptLoginMode) + .mockResolvedValueOnce({ mode: "experimental-sync-now" }) + .mockResolvedValueOnce({ mode: "cancel" }); + for (let attempt = 0; attempt < 5; attempt += 1) { + vi.mocked(cliModule.promptCodexMultiAuthSyncPrune).mockResolvedValueOnce([1]); + vi.mocked(confirmModule.confirm).mockResolvedValueOnce(true); + } + vi.mocked(configModule.getSyncFromCodexMultiAuthEnabled).mockReturnValue(true); + + vi.mocked(storageModule.createTimestampedBackupPath).mockImplementation((prefix?: string) => + join(tempDir, `${prefix ?? "codex-backup"}.json`), + ); + vi.mocked(storageModule.exportAccounts).mockImplementation(async (filePath: string) => { + await fs.writeFile( + filePath, + JSON.stringify({ + version: mockStorage.version, + activeIndex: mockStorage.activeIndex, + activeIndexByFamily: { ...mockStorage.activeIndexByFamily }, + accounts: mockStorage.accounts.map((account) => ({ ...account })), + }), + "utf8", + ); + }); + for (let attempt = 0; attempt < 5; attempt += 1) { + vi.mocked(storageModule.withAccountStorageTransaction).mockImplementationOnce( + async (callback) => { + const loadedStorage = { + ...mockStorage, + accounts: mockStorage.accounts.map((account) => ({ ...account })), + activeIndexByFamily: { ...mockStorage.activeIndexByFamily }, + }; + const persist = async (_nextStorage: typeof mockStorage) => {}; + await callback(loadedStorage, persist); + }, + ); + } + + const capacityError = new syncModule.CodexMultiAuthSyncCapacityError({ + rootDir: tempDir, + accountsPath: join(tempDir, "openai-codex-accounts.json"), + scope: "global", + currentCount: 2, + sourceCount: 2, + sourceDedupedTotal: 3, + dedupedTotal: 3, + maxAccounts: 2, + needToRemove: 1, + importableNewAccounts: 1, + skippedOverlaps: 1, + suggestedRemovals: [ + { + index: 1, + email: "prune@example.com", + accountLabel: "Workspace prune", + isCurrentAccount: false, + score: 180, + reason: "disabled", + }, + ], + }); + + for (let attempt = 0; attempt < 5; attempt += 1) { + vi.mocked(syncModule.previewSyncFromCodexMultiAuth).mockRejectedValueOnce(capacityError); + } + + const mockClient = createMockClient(); + const { OpenAIOAuthPlugin } = await import("../index.js"); + const plugin = (await OpenAIOAuthPlugin({ client: mockClient } as never)) as unknown as PluginType; + const autoMethod = plugin.auth.methods[0] as unknown as { + authorize: (inputs?: Record) => Promise<{ instructions: string }>; + }; + + const authResult = await autoMethod.authorize(); + expect(authResult.instructions).toBe("Authentication cancelled"); + expect(vi.mocked(cliModule.promptCodexMultiAuthSyncPrune)).toHaveBeenCalledTimes(5); + expect(consoleSpy.mock.calls.some(([value]) => String(value).includes("Sync hit max retry limit"))).toBe(true); + } finally { + consoleSpy.mockRestore(); + await fs.rm(tempDir, { recursive: true, force: true }); + } + }); + it("aborts sync prune when a selected account disappears before confirmation", async () => { const cliModule = await import("../lib/cli.js"); const storageModule = await import("../lib/storage.js"); From f4497ec927b2a2ae6725f9d4497136bcf390d6ca Mon Sep 17 00:00:00 2001 From: ndycode Date: Mon, 9 Mar 2026 16:47:53 -0700 Subject: [PATCH 70/81] fix: retry stale sync temp cleanup on windows locks --- lib/codex-multi-auth-sync.ts | 18 ++++++++-- test/codex-multi-auth-sync.test.ts | 58 ++++++++++++++++++++++++++++++ 2 files changed, 74 insertions(+), 2 deletions(-) diff --git a/lib/codex-multi-auth-sync.ts b/lib/codex-multi-auth-sync.ts index 4f74fc0f..9d8ec676 100644 --- a/lib/codex-multi-auth-sync.ts +++ b/lib/codex-multi-auth-sync.ts @@ -119,6 +119,7 @@ interface PreparedCodexMultiAuthPreviewStorage { } 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)); @@ -225,11 +226,24 @@ async function cleanupStaleNormalizedImportTempDirs( } await fs.rm(candidateDir, { recursive: true, force: true }); } catch (error) { - const code = (error as NodeJS.ErrnoException).code; + let code = (error as NodeJS.ErrnoException).code; if (code === "ENOENT") { continue; } - const message = error instanceof Error ? error.message : String(error); + 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}`); } } diff --git a/test/codex-multi-auth-sync.test.ts b/test/codex-multi-auth-sync.test.ts index b460a84f..9a001844 100644 --- a/test/codex-multi-auth-sync.test.ts +++ b/test/codex-multi-auth-sync.test.ts @@ -963,6 +963,64 @@ describe("codex-multi-auth sync", () => { } }); + 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; From a555251af38b8a06c896c3e4e89fcceda4c1b6bf Mon Sep 17 00:00:00 2001 From: ndycode Date: Mon, 9 Mar 2026 16:48:03 -0700 Subject: [PATCH 71/81] test: cover prune restore retries and tty flows --- index.ts | 28 +++-- test/index.test.ts | 262 +++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 282 insertions(+), 8 deletions(-) diff --git a/index.ts b/index.ts index 811d5ca8..5901fccc 100644 --- a/index.ts +++ b/index.ts @@ -3883,13 +3883,25 @@ while (attempted.size < Math.max(1, accountCount)) { return; } - const createSyncPruneBackup = async (): Promise<{ - backupPath: string; - restore: () => Promise; - }> => { - const currentAccountsStorage = - (await loadAccounts()) ?? - ({ + const createSyncPruneBackup = async (): Promise<{ + backupPath: string; + restore: () => Promise; + }> => { + const readPruneBackupFile = async (backupPath: string): Promise => { + try { + return await fsPromises.readFile(backupPath, "utf-8"); + } catch (error) { + const code = (error as NodeJS.ErrnoException).code; + if (code !== "EBUSY" && code !== "EACCES" && code !== "EPERM") { + throw error; + } + await new Promise((resolve) => setTimeout(resolve, 100)); + return await fsPromises.readFile(backupPath, "utf-8"); + } + }; + const currentAccountsStorage = + (await loadAccounts()) ?? + ({ version: 3, accounts: [], activeIndex: 0, @@ -3908,7 +3920,7 @@ while (attempted.size < Math.max(1, accountCount)) { return { backupPath, restore: async () => { - const backupRaw = await fsPromises.readFile(backupPath, "utf-8"); + const backupRaw = await readPruneBackupFile(backupPath); const parsed = JSON.parse(backupRaw) as { accounts?: unknown; flagged?: unknown; diff --git a/test/index.test.ts b/test/index.test.ts index 31cd3e73..a8374fbc 100644 --- a/test/index.test.ts +++ b/test/index.test.ts @@ -3,6 +3,17 @@ import { promises as fs } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; +const readlineMocks = vi.hoisted(() => { + const question = vi.fn(async () => undefined); + const close = vi.fn(); + const createInterface = vi.fn(() => ({ question, close })); + return { question, close, createInterface }; +}); + +vi.mock("node:readline/promises", () => ({ + createInterface: readlineMocks.createInterface, +})); + vi.mock("@opencode-ai/plugin/tool", () => { const makeSchema = () => ({ optional: () => makeSchema(), @@ -503,12 +514,59 @@ const createMockClient = () => ({ session: { prompt: vi.fn() }, }); +const withInteractiveTerminal = async (run: (context: { + writeSpy: ReturnType; + setRawMode: ReturnType; + readSpy: ReturnType; +}) => Promise) => { + const stdinIsTTY = Object.getOwnPropertyDescriptor(process.stdin, "isTTY"); + const stdinIsRaw = Object.getOwnPropertyDescriptor(process.stdin, "isRaw"); + const stdoutIsTTY = Object.getOwnPropertyDescriptor(process.stdout, "isTTY"); + const stdoutRows = Object.getOwnPropertyDescriptor(process.stdout, "rows"); + const originalSetRawMode = (process.stdin as NodeJS.ReadStream & { setRawMode?: (value: boolean) => void }).setRawMode; + const setRawMode = vi.fn(); + const writeSpy = vi.spyOn(process.stdout, "write").mockReturnValue(true); + const readSpy = vi.spyOn(process.stdin, "read").mockReturnValue(null); + Object.defineProperty(process.stdin, "isTTY", { value: true, configurable: true }); + Object.defineProperty(process.stdin, "isRaw", { value: false, configurable: true, writable: true }); + Object.defineProperty(process.stdout, "isTTY", { value: true, configurable: true }); + Object.defineProperty(process.stdout, "rows", { value: 24, configurable: true }); + (process.stdin as NodeJS.ReadStream & { setRawMode?: (value: boolean) => void }).setRawMode = setRawMode; + + try { + await run({ writeSpy, setRawMode, readSpy }); + } finally { + writeSpy.mockRestore(); + readSpy.mockRestore(); + if (stdinIsTTY) { + Object.defineProperty(process.stdin, "isTTY", stdinIsTTY); + } else { + delete (process.stdin as NodeJS.ReadStream & { isTTY?: boolean }).isTTY; + } + if (stdinIsRaw) { + Object.defineProperty(process.stdin, "isRaw", stdinIsRaw); + } else { + delete (process.stdin as NodeJS.ReadStream & { isRaw?: boolean }).isRaw; + } + if (stdoutIsTTY) { + Object.defineProperty(process.stdout, "isTTY", stdoutIsTTY); + } else { + delete (process.stdout as NodeJS.WriteStream & { isTTY?: boolean }).isTTY; + } + if (stdoutRows) { + Object.defineProperty(process.stdout, "rows", stdoutRows); + } + (process.stdin as NodeJS.ReadStream & { setRawMode?: (value: boolean) => void }).setRawMode = originalSetRawMode; + } +}; + describe("OpenAIOAuthPlugin", () => { let plugin: PluginType; let mockClient: ReturnType; beforeEach(async () => { vi.clearAllMocks(); + readlineMocks.question.mockResolvedValue(undefined); mockClient = createMockClient(); mockStorage.accounts = []; @@ -3068,6 +3126,98 @@ describe("OpenAIOAuthPlugin persistAccountPool", () => { expect(mockStorage.activeIndexByFamily.codex).toBe(0); }); + it("renders and auto-closes the forecast operation screen in interactive terminals", async () => { + const cliModule = await import("../lib/cli.js"); + const storageModule = await import("../lib/storage.js"); + const { ANSI } = await import("../lib/ui/ansi.js"); + + mockStorage.accounts = [ + { + accountId: "org-primary", + organizationId: "org-primary", + accountIdSource: "org", + email: "primary@example.com", + refreshToken: "refresh-primary", + }, + ]; + mockStorage.activeIndex = 0; + mockStorage.activeIndexByFamily = {}; + + vi.mocked(cliModule.promptLoginMode) + .mockResolvedValueOnce({ mode: "forecast" }) + .mockResolvedValueOnce({ mode: "cancel" }); + vi.mocked(storageModule.saveAccounts).mockImplementation(async (nextStorage) => { + mockStorage.version = nextStorage.version; + mockStorage.activeIndex = nextStorage.activeIndex; + mockStorage.activeIndexByFamily = { ...nextStorage.activeIndexByFamily }; + mockStorage.accounts = nextStorage.accounts.map((account) => ({ ...account })); + }); + + await withInteractiveTerminal(async ({ writeSpy, setRawMode }) => { + vi.useFakeTimers(); + try { + const mockClient = createMockClient(); + const { OpenAIOAuthPlugin } = await import("../index.js"); + const plugin = (await OpenAIOAuthPlugin({ client: mockClient } as never)) as unknown as PluginType; + const autoMethod = plugin.auth.methods[0] as unknown as { + authorize: (inputs?: Record) => Promise<{ instructions: string }>; + }; + + const resultPromise = autoMethod.authorize(); + await vi.runAllTimersAsync(); + const authResult = await resultPromise; + expect(authResult.instructions).toBe("Authentication cancelled"); + expect(writeSpy).toHaveBeenCalledWith(expect.stringContaining(ANSI.altScreenOn)); + expect(writeSpy).toHaveBeenCalledWith(expect.stringContaining(ANSI.altScreenOff)); + expect(setRawMode).toHaveBeenCalledWith(true); + expect(setRawMode).toHaveBeenLastCalledWith(false); + expect(readlineMocks.createInterface).not.toHaveBeenCalled(); + } finally { + vi.useRealTimers(); + } + }); + }); + + it("closes the interactive operation screen after a failed forecast action", async () => { + const cliModule = await import("../lib/cli.js"); + const storageModule = await import("../lib/storage.js"); + const { ANSI } = await import("../lib/ui/ansi.js"); + + mockStorage.accounts = [ + { + accountId: "org-primary", + organizationId: "org-primary", + accountIdSource: "org", + email: "primary@example.com", + refreshToken: "refresh-primary", + }, + ]; + mockStorage.activeIndex = 0; + mockStorage.activeIndexByFamily = {}; + + vi.mocked(cliModule.promptLoginMode) + .mockResolvedValueOnce({ mode: "forecast" }) + .mockResolvedValueOnce({ mode: "cancel" }); + vi.mocked(storageModule.saveAccounts).mockRejectedValueOnce(new Error("save failed")); + + await withInteractiveTerminal(async ({ writeSpy }) => { + const mockClient = createMockClient(); + const { OpenAIOAuthPlugin } = await import("../index.js"); + const plugin = (await OpenAIOAuthPlugin({ client: mockClient } as never)) as unknown as PluginType; + const autoMethod = plugin.auth.methods[0] as unknown as { + authorize: (inputs?: Record) => Promise<{ instructions: string }>; + }; + + const authResult = await autoMethod.authorize(); + expect(authResult.instructions).toBe("Authentication cancelled"); + expect(readlineMocks.createInterface).toHaveBeenCalled(); + expect(readlineMocks.question).toHaveBeenCalled(); + expect(readlineMocks.close).toHaveBeenCalled(); + expect(writeSpy).toHaveBeenCalledWith(expect.stringContaining(ANSI.altScreenOn)); + expect(writeSpy).toHaveBeenCalledWith(expect.stringContaining(ANSI.altScreenOff)); + }); + }); + it("restores pruned accounts when sync does not commit after a prune retry", async () => { const cliModule = await import("../lib/cli.js"); const storageModule = await import("../lib/storage.js"); @@ -3201,6 +3351,118 @@ describe("OpenAIOAuthPlugin persistAccountPool", () => { } }); + it("retries prune-backup reads after a transient Windows lock", async () => { + const cliModule = await import("../lib/cli.js"); + const storageModule = await import("../lib/storage.js"); + const syncModule = await import("../lib/codex-multi-auth-sync.js"); + const configModule = await import("../lib/config.js"); + const confirmModule = await import("../lib/ui/confirm.js"); + + const tempDir = await fs.mkdtemp(join(tmpdir(), "oc-sync-prune-read-retry-")); + const originalReadFile = fs.readFile.bind(fs); + let backupReadBlocked = false; + const readFileSpy = vi.spyOn(fs, "readFile").mockImplementation(async (path, options) => { + if (!backupReadBlocked && String(path).includes("codex-sync-prune-backup")) { + backupReadBlocked = true; + throw Object.assign(new Error("busy"), { code: "EBUSY" }); + } + return originalReadFile(path, options as never); + }); + + try { + mockStorage.accounts = [ + { + accountId: "org-keep", + organizationId: "org-keep", + accountIdSource: "org", + email: "keep@example.com", + refreshToken: "refresh-keep", + }, + { + accountId: "org-prune", + organizationId: "org-prune", + accountIdSource: "org", + email: "prune@example.com", + refreshToken: "refresh-prune", + }, + ]; + mockStorage.activeIndex = 0; + mockStorage.activeIndexByFamily = {}; + + vi.mocked(cliModule.promptLoginMode) + .mockResolvedValueOnce({ mode: "experimental-sync-now" }) + .mockResolvedValueOnce({ mode: "cancel" }); + vi.mocked(cliModule.promptCodexMultiAuthSyncPrune).mockResolvedValueOnce([1]); + vi.mocked(configModule.getSyncFromCodexMultiAuthEnabled).mockReturnValue(true); + vi.mocked(confirmModule.confirm).mockResolvedValueOnce(true); + + vi.mocked(storageModule.createTimestampedBackupPath).mockImplementation((prefix?: string) => + join(tempDir, `${prefix ?? "codex-backup"}.json`), + ); + vi.mocked(storageModule.exportAccounts).mockImplementation(async (filePath: string) => { + await fs.writeFile( + filePath, + JSON.stringify({ + version: mockStorage.version, + activeIndex: mockStorage.activeIndex, + activeIndexByFamily: { ...mockStorage.activeIndexByFamily }, + accounts: mockStorage.accounts.map((account) => ({ ...account })), + }), + "utf8", + ); + }); + vi.mocked(syncModule.previewSyncFromCodexMultiAuth) + .mockRejectedValueOnce( + new syncModule.CodexMultiAuthSyncCapacityError({ + rootDir: tempDir, + accountsPath: join(tempDir, "openai-codex-accounts.json"), + scope: "global", + currentCount: 2, + sourceCount: 2, + sourceDedupedTotal: 3, + dedupedTotal: 3, + maxAccounts: 2, + needToRemove: 1, + importableNewAccounts: 1, + skippedOverlaps: 1, + suggestedRemovals: [ + { + index: 1, + email: "prune@example.com", + accountLabel: "Workspace prune", + isCurrentAccount: false, + score: 180, + reason: "disabled", + }, + ], + }), + ) + .mockResolvedValueOnce({ + rootDir: tempDir, + accountsPath: join(tempDir, "openai-codex-accounts.json"), + scope: "global", + imported: 0, + skipped: 1, + total: 1, + }); + + const mockClient = createMockClient(); + const { OpenAIOAuthPlugin } = await import("../index.js"); + const plugin = (await OpenAIOAuthPlugin({ client: mockClient } as never)) as unknown as PluginType; + const autoMethod = plugin.auth.methods[0] as unknown as { + authorize: (inputs?: Record) => Promise<{ instructions: string }>; + }; + + const authResult = await autoMethod.authorize(); + expect(authResult.instructions).toBe("Authentication cancelled"); + expect(backupReadBlocked).toBe(true); + expect(mockStorage.accounts.map((account) => account.accountId)).toEqual(["org-keep", "org-prune"]); + } finally { + readFileSpy.mockRestore(); + await fs.rm(tempDir, { recursive: true, force: true }); + } + }); + it("does not overwrite concurrent account changes when prune-backup rollback cannot safely apply", async () => { const cliModule = await import("../lib/cli.js"); const storageModule = await import("../lib/storage.js"); From 23aa317dc6235697ad63e1b3e58487d9fec6dd09 Mon Sep 17 00:00:00 2001 From: ndycode Date: Mon, 9 Mar 2026 17:09:41 -0700 Subject: [PATCH 72/81] test: restore terminal row state in interactive helpers --- test/index.test.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/test/index.test.ts b/test/index.test.ts index a8374fbc..caf13c30 100644 --- a/test/index.test.ts +++ b/test/index.test.ts @@ -555,6 +555,8 @@ const withInteractiveTerminal = async (run: (context: { } if (stdoutRows) { Object.defineProperty(process.stdout, "rows", stdoutRows); + } else { + delete (process.stdout as NodeJS.WriteStream & { rows?: number }).rows; } (process.stdin as NodeJS.ReadStream & { setRawMode?: (value: boolean) => void }).setRawMode = originalSetRawMode; } From 09bfa10cf55eb578aded20f04fb7e06ed8c236fb Mon Sep 17 00:00:00 2001 From: ndycode Date: Mon, 9 Mar 2026 21:56:43 +0800 Subject: [PATCH 73/81] fix: restore health-check account guard Co-authored-by: Codex --- index.ts | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/index.ts b/index.ts index 5901fccc..53cd4958 100644 --- a/index.ts +++ b/index.ts @@ -3436,6 +3436,7 @@ while (attempted.size < Math.max(1, accountCount)) { continue; } + try { // If we already have a valid cached access token, don't force-refresh. // This avoids flagging accounts where the refresh token has been burned // but the access token is still valid (same behavior as Codex CLI). @@ -3634,6 +3635,11 @@ while (attempted.size < Math.max(1, accountCount)) { const message = error instanceof Error ? error.message : String(error); emit(i, `error: ${message.slice(0, 160)}`, "danger"); } + } catch (error) { + errors += 1; + const message = error instanceof Error ? error.message : String(error); + emit(i, `error: ${message.slice(0, 120)}`, "danger"); + } } if (removeFromActive.size > 0) { From bdf51ab18d21b3b779bf5f506a17c2905a3bea0b Mon Sep 17 00:00:00 2001 From: ndycode Date: Mon, 9 Mar 2026 22:11:34 +0800 Subject: [PATCH 74/81] fix: harden greptile retry blockers Co-authored-by: Codex --- index.ts | 28 +++++-- lib/codex-multi-auth-sync.ts | 2 +- lib/config.ts | 8 +- test/codex-multi-auth-sync.test.ts | 46 ++++++++++- test/index.test.ts | 128 ++++++++++++++++++++++++++++- 5 files changed, 193 insertions(+), 19 deletions(-) diff --git a/index.ts b/index.ts index 53cd4958..00dddf01 100644 --- a/index.ts +++ b/index.ts @@ -3889,21 +3889,33 @@ while (attempted.size < Math.max(1, accountCount)) { return; } + const PRUNE_BACKUP_READ_RETRY_DELAYS_MS = [100, 250, 500] as const; + const createSyncPruneBackup = async (): Promise<{ backupPath: string; restore: () => Promise; }> => { const readPruneBackupFile = async (backupPath: string): Promise => { - try { - return await fsPromises.readFile(backupPath, "utf-8"); - } catch (error) { - const code = (error as NodeJS.ErrnoException).code; - if (code !== "EBUSY" && code !== "EACCES" && code !== "EPERM") { - throw error; + const retryableCodes = new Set(["EBUSY", "EACCES", "EPERM"]); + for ( + let attempt = 0; + attempt <= PRUNE_BACKUP_READ_RETRY_DELAYS_MS.length; + attempt += 1 + ) { + try { + return await fsPromises.readFile(backupPath, "utf-8"); + } catch (error) { + const code = (error as NodeJS.ErrnoException).code; + if (!code || !retryableCodes.has(code) || attempt >= PRUNE_BACKUP_READ_RETRY_DELAYS_MS.length) { + throw error; + } + const delayMs = PRUNE_BACKUP_READ_RETRY_DELAYS_MS[attempt]; + if (delayMs !== undefined) { + await new Promise((resolve) => setTimeout(resolve, delayMs)); + } } - await new Promise((resolve) => setTimeout(resolve, 100)); - return await fsPromises.readFile(backupPath, "utf-8"); } + throw new Error("readPruneBackupFile: unexpected retry exit"); }; const currentAccountsStorage = (await loadAccounts()) ?? diff --git a/lib/codex-multi-auth-sync.ts b/lib/codex-multi-auth-sync.ts index 9d8ec676..c99e3355 100644 --- a/lib/codex-multi-auth-sync.ts +++ b/lib/codex-multi-auth-sync.ts @@ -130,7 +130,7 @@ async function removeNormalizedImportTempDir( tempPath: string, options: NormalizedImportFileOptions, ): Promise { - const retryableCodes = new Set(["EBUSY", "EAGAIN", "ENOTEMPTY"]); + 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 { diff --git a/lib/config.ts b/lib/config.ts index 1686aec9..12b617a2 100644 --- a/lib/config.ts +++ b/lib/config.ts @@ -131,10 +131,6 @@ function readRawPluginConfig(recoverInvalid = false): RawPluginConfig { } async function readRawPluginConfigAsync(recoverInvalid = false): Promise { - if (!existsSync(CONFIG_PATH)) { - return {}; - } - try { const fileContent = await fs.readFile(CONFIG_PATH, "utf-8"); const normalizedFileContent = stripUtf8Bom(fileContent); @@ -144,6 +140,10 @@ async function readRawPluginConfigAsync(recoverInvalid = false): Promise { } }); - it("fails fast on non-retryable Windows-style temp cleanup errors", async () => { + 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"); @@ -743,7 +785,7 @@ describe("codex-multi-auth sync", () => { const rmSpy = vi .spyOn(fs.promises, "rm") - .mockRejectedValue(Object.assign(new Error("permission denied"), { code: "EACCES" })); + .mockRejectedValue(Object.assign(new Error("invalid temp dir"), { code: "EINVAL" })); const loggerModule = await import("../lib/logger.js"); try { diff --git a/test/index.test.ts b/test/index.test.ts index caf13c30..7067a0b1 100644 --- a/test/index.test.ts +++ b/test/index.test.ts @@ -3362,10 +3362,10 @@ describe("OpenAIOAuthPlugin persistAccountPool", () => { const tempDir = await fs.mkdtemp(join(tmpdir(), "oc-sync-prune-read-retry-")); const originalReadFile = fs.readFile.bind(fs); - let backupReadBlocked = false; + let backupReadAttempts = 0; const readFileSpy = vi.spyOn(fs, "readFile").mockImplementation(async (path, options) => { - if (!backupReadBlocked && String(path).includes("codex-sync-prune-backup")) { - backupReadBlocked = true; + if (String(path).includes("codex-sync-prune-backup") && backupReadAttempts < 2) { + backupReadAttempts += 1; throw Object.assign(new Error("busy"), { code: "EBUSY" }); } return originalReadFile(path, options as never); @@ -3457,7 +3457,8 @@ describe("OpenAIOAuthPlugin persistAccountPool", () => { const authResult = await autoMethod.authorize(); expect(authResult.instructions).toBe("Authentication cancelled"); - expect(backupReadBlocked).toBe(true); + expect(backupReadAttempts).toBe(2); + expect(readFileSpy.mock.calls.filter(([path]) => String(path).includes("codex-sync-prune-backup"))).toHaveLength(3); expect(mockStorage.accounts.map((account) => account.accountId)).toEqual(["org-keep", "org-prune"]); } finally { readFileSpy.mockRestore(); @@ -3465,6 +3466,125 @@ describe("OpenAIOAuthPlugin persistAccountPool", () => { } }); + it("fails prune-backup restore after exhausting the Windows lock retry budget", async () => { + const cliModule = await import("../lib/cli.js"); + const storageModule = await import("../lib/storage.js"); + const syncModule = await import("../lib/codex-multi-auth-sync.js"); + const configModule = await import("../lib/config.js"); + const confirmModule = await import("../lib/ui/confirm.js"); + + const tempDir = await fs.mkdtemp(join(tmpdir(), "oc-sync-prune-read-fail-")); + const originalReadFile = fs.readFile.bind(fs); + let backupReadAttempts = 0; + const readFileSpy = vi.spyOn(fs, "readFile").mockImplementation(async (path, options) => { + if (String(path).includes("codex-sync-prune-backup")) { + backupReadAttempts += 1; + throw Object.assign(new Error("busy"), { code: "EBUSY" }); + } + return originalReadFile(path, options as never); + }); + const consoleSpy = vi.spyOn(console, "log").mockImplementation(() => undefined); + + try { + mockStorage.accounts = [ + { + accountId: "org-keep", + organizationId: "org-keep", + accountIdSource: "org", + email: "keep@example.com", + refreshToken: "refresh-keep", + }, + { + accountId: "org-prune", + organizationId: "org-prune", + accountIdSource: "org", + email: "prune@example.com", + refreshToken: "refresh-prune", + }, + ]; + mockStorage.activeIndex = 0; + mockStorage.activeIndexByFamily = {}; + + vi.mocked(cliModule.promptLoginMode) + .mockResolvedValueOnce({ mode: "experimental-sync-now" }) + .mockResolvedValueOnce({ mode: "cancel" }); + vi.mocked(cliModule.promptCodexMultiAuthSyncPrune).mockResolvedValueOnce([1]); + vi.mocked(configModule.getSyncFromCodexMultiAuthEnabled).mockReturnValue(true); + vi.mocked(confirmModule.confirm).mockResolvedValueOnce(true); + + vi.mocked(storageModule.createTimestampedBackupPath).mockImplementation((prefix?: string) => + join(tempDir, `${prefix ?? "codex-backup"}.json`), + ); + vi.mocked(storageModule.exportAccounts).mockImplementation(async (filePath: string) => { + await fs.writeFile( + filePath, + JSON.stringify({ + version: mockStorage.version, + activeIndex: mockStorage.activeIndex, + activeIndexByFamily: { ...mockStorage.activeIndexByFamily }, + accounts: mockStorage.accounts.map((account) => ({ ...account })), + }), + "utf8", + ); + }); + vi.mocked(syncModule.previewSyncFromCodexMultiAuth) + .mockRejectedValueOnce( + new syncModule.CodexMultiAuthSyncCapacityError({ + rootDir: tempDir, + accountsPath: join(tempDir, "openai-codex-accounts.json"), + scope: "global", + currentCount: 2, + sourceCount: 2, + sourceDedupedTotal: 3, + dedupedTotal: 3, + maxAccounts: 2, + needToRemove: 1, + importableNewAccounts: 1, + skippedOverlaps: 1, + suggestedRemovals: [ + { + index: 1, + email: "prune@example.com", + accountLabel: "Workspace prune", + isCurrentAccount: false, + score: 180, + reason: "disabled", + }, + ], + }), + ) + .mockResolvedValueOnce({ + rootDir: tempDir, + accountsPath: join(tempDir, "openai-codex-accounts.json"), + scope: "global", + imported: 0, + skipped: 1, + total: 1, + }); + + const mockClient = createMockClient(); + const { OpenAIOAuthPlugin } = await import("../index.js"); + const plugin = (await OpenAIOAuthPlugin({ client: mockClient } as never)) as unknown as PluginType; + const autoMethod = plugin.auth.methods[0] as unknown as { + authorize: (inputs?: Record) => Promise<{ instructions: string }>; + }; + + const authResult = await autoMethod.authorize(); + expect(authResult.instructions).toBe("Authentication cancelled"); + expect(backupReadAttempts).toBe(4); + expect(mockStorage.accounts.map((account) => account.accountId)).toEqual(["org-keep"]); + expect( + consoleSpy.mock.calls.some(([value]) => + String(value).includes("Failed to restore previously pruned accounts after zero-import preview"), + ), + ).toBe(true); + } finally { + consoleSpy.mockRestore(); + readFileSpy.mockRestore(); + await fs.rm(tempDir, { recursive: true, force: true }); + } + }); + it("does not overwrite concurrent account changes when prune-backup rollback cannot safely apply", async () => { const cliModule = await import("../lib/cli.js"); const storageModule = await import("../lib/storage.js"); From 5efeedefb59ce605b3c6c9acfda095c28ac0cb9f Mon Sep 17 00:00:00 2001 From: ndycode Date: Mon, 9 Mar 2026 22:15:48 +0800 Subject: [PATCH 75/81] fix: tighten codex sync safety guards Co-authored-by: Codex --- index.ts | 35 +++++++++++++++++++++++++++++++---- test/index.test.ts | 2 +- 2 files changed, 32 insertions(+), 5 deletions(-) diff --git a/index.ts b/index.ts index 00dddf01..d674497f 100644 --- a/index.ts +++ b/index.ts @@ -1239,7 +1239,7 @@ export const OpenAIOAuthPlugin: Plugin = async ({ client }: PluginInput) => { // biome-ignore lint/suspicious/noControlCharactersInRegex: matching ANSI escape codes const ANSI_STYLE_REGEX = new RegExp("\\x1b\\[[0-9;]*m", "g"); - const SCREEN_CONTROL_CHAR_REGEX = new RegExp("[\\u0000-\\u0008\\u000b\\u000c\\u000e-\\u001f\\u007f]", "g"); + const SCREEN_CONTROL_CHAR_REGEX = new RegExp("[\\u0000-\\u001f\\u007f]", "g"); const sanitizeScreenText = (value: string): string => value.replace(ANSI_STYLE_REGEX, "").replace(SCREEN_CONTROL_CHAR_REGEX, "").trim(); type OperationTone = "normal" | "muted" | "success" | "warning" | "danger" | "accent"; @@ -1617,11 +1617,15 @@ export const OpenAIOAuthPlugin: Plugin = async ({ client }: PluginInput) => { account: { email?: string; accountId?: string }, tokenAccountId: string | undefined, ): boolean => { + const normalizedAccountId = account.accountId?.trim(); + if (normalizedAccountId) { + return tokenAccountId === normalizedAccountId; + } const normalizedEmail = sanitizeEmail(account.email); if (normalizedEmail && (emailCounts.get(normalizedEmail) ?? 0) <= 1) { return true; } - return Boolean(tokenAccountId && account.accountId && tokenAccountId === account.accountId); + return false; }; type SyncRemovalTarget = { @@ -3682,6 +3686,15 @@ while (attempted.size < Math.max(1, accountCount)) { console.log(line.line); } console.log(""); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + if (screen) { + screen.push(`Health check failed: ${message}`, "danger"); + await screen.finish(undefined, { failed: true }); + screenFinished = true; + } else { + console.log(`\nHealth check failed: ${message}\n`); + } } finally { if (screen && !screenFinished) { screen.abort(); @@ -4198,13 +4211,27 @@ while (attempted.size < Math.max(1, accountCount)) { | { backupPath: string; restore: () => Promise; + restoreFailureMessage?: string; } | null = null; const restorePruneBackup = async (): Promise => { const currentBackup = pruneBackup; if (!currentBackup) return; - pruneBackup = null; - await currentBackup.restore(); + if (currentBackup.restoreFailureMessage) { + throw new Error( + `${currentBackup.restoreFailureMessage}. Backup remains at ${currentBackup.backupPath}.`, + ); + } + try { + await currentBackup.restore(); + pruneBackup = null; + } catch (restoreError) { + const message = + restoreError instanceof Error ? restoreError.message : String(restoreError); + currentBackup.restoreFailureMessage = message; + pruneBackup = currentBackup; + throw new Error(`${message}. Backup remains at ${currentBackup.backupPath}.`); + } }; const safeRestorePruneBackup = async (context: string): Promise => { try { diff --git a/test/index.test.ts b/test/index.test.ts index 7067a0b1..e07dc236 100644 --- a/test/index.test.ts +++ b/test/index.test.ts @@ -2745,7 +2745,7 @@ describe("OpenAIOAuthPlugin persistAccountPool", () => { const authResult = await autoMethod.authorize(); expect(authResult.instructions).toBe("Authentication cancelled"); - expect(vi.mocked(refreshQueueModule.queuedRefresh)).toHaveBeenCalledTimes(1); + expect(vi.mocked(refreshQueueModule.queuedRefresh)).toHaveBeenCalledTimes(2); expect(mockStorage.accounts).toHaveLength(2); expect(new Set(mockStorage.accounts.map((account) => account.organizationId))).toEqual( new Set(["org-cache", "org-refresh"]), From 5f484b0dc3599a1f1eca30e1ef7b9119fdc85ca0 Mon Sep 17 00:00:00 2001 From: ndycode Date: Mon, 9 Mar 2026 22:27:16 +0800 Subject: [PATCH 76/81] fix: close remaining greptile gaps Co-authored-by: Codex --- index.ts | 2 +- lib/config.ts | 3 ++- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/index.ts b/index.ts index d674497f..b4797aa2 100644 --- a/index.ts +++ b/index.ts @@ -1238,7 +1238,7 @@ export const OpenAIOAuthPlugin: Plugin = async ({ client }: PluginInput) => { }; // biome-ignore lint/suspicious/noControlCharactersInRegex: matching ANSI escape codes - const ANSI_STYLE_REGEX = new RegExp("\\x1b\\[[0-9;]*m", "g"); + const ANSI_STYLE_REGEX = new RegExp("\\x1b\\[[0-?]*[ -/]*[@-~]", "g"); const SCREEN_CONTROL_CHAR_REGEX = new RegExp("[\\u0000-\\u001f\\u007f]", "g"); const sanitizeScreenText = (value: string): string => value.replace(ANSI_STYLE_REGEX, "").replace(SCREEN_CONTROL_CHAR_REGEX, "").trim(); diff --git a/lib/config.ts b/lib/config.ts index 12b617a2..b571e404 100644 --- a/lib/config.ts +++ b/lib/config.ts @@ -195,6 +195,7 @@ export async function savePluginConfigMutation( } catch { // best effort backup cleanup } + backupMoved = false; return; } catch (retryError) { if (backupMoved) { @@ -364,7 +365,7 @@ async function withPluginConfigLock(fn: () => T | Promise): Promise { // best effort stale-lock recovery } } - await sleepAsync(25); + await sleepAsync(25 + Math.floor(Math.random() * 25)); } } From 415330fe28c035552f33cb6be00834eb04411a19 Mon Sep 17 00:00:00 2001 From: ndycode Date: Mon, 9 Mar 2026 22:47:06 +0800 Subject: [PATCH 77/81] fix: address latest greptile blockers Co-authored-by: Codex --- index.ts | 44 +++++++++++++++++++++------------- lib/sync-prune-backup.ts | 6 ++++- test/index.test.ts | 18 ++------------ test/sync-prune-backup.test.ts | 6 +++-- 4 files changed, 39 insertions(+), 35 deletions(-) diff --git a/index.ts b/index.ts index b4797aa2..e074b916 100644 --- a/index.ts +++ b/index.ts @@ -1370,7 +1370,6 @@ export const OpenAIOAuthPlugin: Plugin = async ({ client }: PluginInput) => { writeInlineStatus("Paused. Press any key to return."); await new Promise((resolve) => { - const wasRaw = process.stdin.isRaw ?? false; const onData = () => { cleanup(); resolve(); @@ -3942,6 +3941,8 @@ while (attempted.size < Math.max(1, accountCount)) { 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); // On Windows, mode bits are ignored and the backup relies on the parent directory ACLs. await fsPromises.writeFile(backupPath, `${JSON.stringify(backupPayload, null, 2)}\n`, { encoding: "utf-8", @@ -3952,15 +3953,12 @@ while (attempted.size < Math.max(1, accountCount)) { backupPath, restore: async () => { const backupRaw = await readPruneBackupFile(backupPath); - const parsed = JSON.parse(backupRaw) as { - accounts?: unknown; - flagged?: unknown; - }; - const normalizedAccounts = normalizeAccountStorage(parsed.accounts); + JSON.parse(backupRaw); + const normalizedAccounts = normalizeAccountStorage(restoreAccountsSnapshot); if (!normalizedAccounts) { throw new Error("Prune backup account snapshot failed validation."); } - const flaggedSnapshot = parsed.flagged; + const flaggedSnapshot = restoreFlaggedSnapshot; if ( !flaggedSnapshot || typeof flaggedSnapshot !== "object" || @@ -4526,15 +4524,29 @@ while (attempted.size < Math.max(1, accountCount)) { return; } - storage.activeIndex = best.index; - storage.activeIndexByFamily = storage.activeIndexByFamily ?? {}; - for (const family of MODEL_FAMILIES) { - storage.activeIndexByFamily[family] = best.index; - } - await saveAccounts(storage); + let selectedAccount: AccountStorageV3["accounts"][number] | undefined; + await withAccountStorageTransaction(async (loadedStorage, persist) => { + const workingStorage = + loadedStorage ?? + ({ + version: 3, + accounts: [], + activeIndex: 0, + activeIndexByFamily: {}, + } satisfies AccountStorageV3); + if (!workingStorage.accounts[best.index]) { + throw new Error(`Best account ${best.index + 1} changed before selection.`); + } + workingStorage.activeIndex = best.index; + workingStorage.activeIndexByFamily = workingStorage.activeIndexByFamily ?? {}; + for (const family of MODEL_FAMILIES) { + workingStorage.activeIndexByFamily[family] = best.index; + } + await persist(workingStorage); + selectedAccount = workingStorage.accounts[best.index]; + }); invalidateAccountManagerCache(); - const account = storage.accounts[best.index]; - const selectedLabel = formatCommandAccountLabel(account, best.index); + const selectedLabel = formatCommandAccountLabel(selectedAccount, best.index); if (screen) { screen.push(`Compared ${explainability.length} account(s); ${eligible.length} eligible.`, "muted"); @@ -4553,7 +4565,7 @@ while (attempted.size < Math.max(1, accountCount)) { return; } - console.log(`\nSelected best account: ${account?.email ?? `Account ${best.index + 1}`}\n`); + console.log(`\nSelected best account: ${selectedAccount?.email ?? `Account ${best.index + 1}`}\n`); } catch (error) { const message = error instanceof Error ? error.message : String(error); if (screen) { diff --git a/lib/sync-prune-backup.ts b/lib/sync-prune-backup.ts index b1557923..28e76d15 100644 --- a/lib/sync-prune-backup.ts +++ b/lib/sync-prune-backup.ts @@ -6,8 +6,12 @@ type FlaggedSnapshot = { }; function cloneWithoutAccessToken(account: TAccount): TAccount { - const clone = structuredClone(account) as TAccount & { accessToken?: unknown }; + const clone = structuredClone(account) as TAccount & { + accessToken?: unknown; + refreshToken?: unknown; + }; delete clone.accessToken; + delete clone.refreshToken; return clone as TAccount; } diff --git a/test/index.test.ts b/test/index.test.ts index e07dc236..d591aab0 100644 --- a/test/index.test.ts +++ b/test/index.test.ts @@ -3107,13 +3107,6 @@ describe("OpenAIOAuthPlugin persistAccountPool", () => { .mockResolvedValueOnce({ mode: "forecast" }) .mockResolvedValueOnce({ mode: "cancel" }); - vi.mocked(storageModule.saveAccounts).mockImplementation(async (nextStorage) => { - mockStorage.version = nextStorage.version; - mockStorage.activeIndex = nextStorage.activeIndex; - mockStorage.activeIndexByFamily = { ...nextStorage.activeIndexByFamily }; - mockStorage.accounts = nextStorage.accounts.map((account) => ({ ...account })); - }); - const mockClient = createMockClient(); const { OpenAIOAuthPlugin } = await import("../index.js"); const plugin = (await OpenAIOAuthPlugin({ client: mockClient } as never)) as unknown as PluginType; @@ -3123,7 +3116,7 @@ describe("OpenAIOAuthPlugin persistAccountPool", () => { const authResult = await autoMethod.authorize(); expect(authResult.instructions).toBe("Authentication cancelled"); - expect(vi.mocked(storageModule.saveAccounts)).toHaveBeenCalled(); + expect(vi.mocked(storageModule.withAccountStorageTransaction)).toHaveBeenCalled(); expect(mockStorage.activeIndex).toBe(0); expect(mockStorage.activeIndexByFamily.codex).toBe(0); }); @@ -3148,13 +3141,6 @@ describe("OpenAIOAuthPlugin persistAccountPool", () => { vi.mocked(cliModule.promptLoginMode) .mockResolvedValueOnce({ mode: "forecast" }) .mockResolvedValueOnce({ mode: "cancel" }); - vi.mocked(storageModule.saveAccounts).mockImplementation(async (nextStorage) => { - mockStorage.version = nextStorage.version; - mockStorage.activeIndex = nextStorage.activeIndex; - mockStorage.activeIndexByFamily = { ...nextStorage.activeIndexByFamily }; - mockStorage.accounts = nextStorage.accounts.map((account) => ({ ...account })); - }); - await withInteractiveTerminal(async ({ writeSpy, setRawMode }) => { vi.useFakeTimers(); try { @@ -3200,7 +3186,7 @@ describe("OpenAIOAuthPlugin persistAccountPool", () => { vi.mocked(cliModule.promptLoginMode) .mockResolvedValueOnce({ mode: "forecast" }) .mockResolvedValueOnce({ mode: "cancel" }); - vi.mocked(storageModule.saveAccounts).mockRejectedValueOnce(new Error("save failed")); + vi.mocked(storageModule.withAccountStorageTransaction).mockRejectedValueOnce(new Error("save failed")); await withInteractiveTerminal(async ({ writeSpy }) => { const mockClient = createMockClient(); diff --git a/test/sync-prune-backup.test.ts b/test/sync-prune-backup.test.ts index 4c38d1a2..017b1835 100644 --- a/test/sync-prune-backup.test.ts +++ b/test/sync-prune-backup.test.ts @@ -3,7 +3,7 @@ import { createSyncPruneBackupPayload } from "../lib/sync-prune-backup.js"; import type { AccountStorageV3 } from "../lib/storage.js"; describe("sync prune backup payload", () => { - it("omits access tokens from the prune backup payload", () => { + it("omits live tokens from the prune backup payload", () => { const storage: AccountStorageV3 = { version: 3, activeIndex: 0, @@ -31,7 +31,9 @@ describe("sync prune backup payload", () => { }); 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", () => { @@ -77,10 +79,10 @@ 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.flagged.accounts[0]).toMatchObject({ - refreshToken: "refresh-token", metadata: { source: "flagged", }, }); + expect(payload.flagged.accounts[0]).not.toHaveProperty("refreshToken"); }); }); From 575eee76f07670a5bcdda6c5d5300cb9d39cad7f Mon Sep 17 00:00:00 2001 From: ndycode Date: Mon, 9 Mar 2026 23:01:53 +0800 Subject: [PATCH 78/81] fix: transaction-wrap account switching Co-authored-by: Codex --- index.ts | 21 ++++++++++++++------- 1 file changed, 14 insertions(+), 7 deletions(-) diff --git a/index.ts b/index.ts index e074b916..19c26c2c 100644 --- a/index.ts +++ b/index.ts @@ -4856,16 +4856,23 @@ while (attempted.size < Math.max(1, accountCount)) { break; } if (typeof menuResult.switchAccountIndex === "number") { - const target = workingStorage.accounts[menuResult.switchAccountIndex]; - if (target) { - workingStorage.activeIndex = menuResult.switchAccountIndex; - workingStorage.activeIndexByFamily = workingStorage.activeIndexByFamily ?? {}; + let targetLabel: string | null = null; + await withAccountStorageTransaction(async (loadedStorage, persist) => { + const txStorage = loadedStorage; + if (!txStorage) return; + const target = txStorage.accounts[menuResult.switchAccountIndex]; + if (!target) return; + txStorage.activeIndex = menuResult.switchAccountIndex; + txStorage.activeIndexByFamily = txStorage.activeIndexByFamily ?? {}; for (const family of MODEL_FAMILIES) { - workingStorage.activeIndexByFamily[family] = menuResult.switchAccountIndex; + txStorage.activeIndexByFamily[family] = menuResult.switchAccountIndex; } - await saveAccounts(workingStorage); + await persist(txStorage); + targetLabel = target.email ?? `Account ${menuResult.switchAccountIndex + 1}`; + }); + if (targetLabel) { invalidateAccountManagerCache(); - console.log(`\nSet current account: ${target.email ?? `Account ${menuResult.switchAccountIndex + 1}`}.\n`); + console.log(`\nSet current account: ${targetLabel}.\n`); } continue; } From e298c1645b2f83dd5518191fc19a94a52ee435ad Mon Sep 17 00:00:00 2001 From: ndycode Date: Tue, 10 Mar 2026 11:07:19 +0800 Subject: [PATCH 79/81] fix: close remaining 3-5 blockers Co-authored-by: Codex --- index.ts | 21 +++++----- lib/cli.ts | 4 +- lib/sync-prune-backup.ts | 6 +-- lib/ui/ansi.ts | 4 ++ lib/ui/auth-menu.ts | 5 +-- test/index.test.ts | 89 +++++++++++++++++++++++++++++----------- 6 files changed, 86 insertions(+), 43 deletions(-) diff --git a/index.ts b/index.ts index 19c26c2c..7d8bcd6e 100644 --- a/index.ts +++ b/index.ts @@ -162,7 +162,7 @@ import { buildTableHeader, buildTableRow, type TableOptions } from "./lib/table- import { setUiRuntimeOptions, type UiRuntimeOptions } from "./lib/ui/runtime.js"; import { paintUiText, formatUiBadge, formatUiHeader, formatUiItem, formatUiKeyValue, formatUiSection } from "./lib/ui/format.js"; import { confirm } from "./lib/ui/confirm.js"; -import { ANSI } from "./lib/ui/ansi.js"; +import { ANSI, ANSI_CSI_REGEX, CONTROL_CHAR_REGEX } from "./lib/ui/ansi.js"; import { buildBeginnerChecklist, buildBeginnerDoctorFindings, @@ -1237,11 +1237,8 @@ export const OpenAIOAuthPlugin: Plugin = async ({ client }: PluginInput) => { return applyUiRuntimeFromConfig(loadPluginConfig()); }; - // biome-ignore lint/suspicious/noControlCharactersInRegex: matching ANSI escape codes - const ANSI_STYLE_REGEX = new RegExp("\\x1b\\[[0-?]*[ -/]*[@-~]", "g"); - const SCREEN_CONTROL_CHAR_REGEX = new RegExp("[\\u0000-\\u001f\\u007f]", "g"); const sanitizeScreenText = (value: string): string => - value.replace(ANSI_STYLE_REGEX, "").replace(SCREEN_CONTROL_CHAR_REGEX, "").trim(); + value.replace(ANSI_CSI_REGEX, "").replace(CONTROL_CHAR_REGEX, "").trim(); type OperationTone = "normal" | "muted" | "success" | "warning" | "danger" | "accent"; const styleOperationText = ( @@ -4024,7 +4021,6 @@ while (attempted.size < Math.max(1, accountCount)) { const removeAccountsForSync = async ( targets: SyncRemovalTarget[], ): Promise => { - const currentFlaggedStorage = await loadFlaggedAccounts(); const targetKeySet = new Set( targets .filter((target) => typeof target.refreshToken === "string" && target.refreshToken.length > 0) @@ -4139,6 +4135,7 @@ while (attempted.size < Math.max(1, accountCount)) { }), ), ); + const currentFlaggedStorage = await loadFlaggedAccounts(); await saveFlaggedAccounts({ version: 1, accounts: currentFlaggedStorage.accounts.filter( @@ -4363,6 +4360,9 @@ while (attempted.size < Math.max(1, accountCount)) { for (const line of removalPlan.previewLines) { console.log(` ${line}`); } + console.log( + "Accounts removed in this step cannot be recovered if the process is interrupted - ensure sync completes before closing.", + ); console.log(""); const confirmed = await confirm( `Remove ${indexesToRemove.length} selected account(s) and retry sync?`, @@ -4856,19 +4856,20 @@ while (attempted.size < Math.max(1, accountCount)) { break; } if (typeof menuResult.switchAccountIndex === "number") { + const targetIndex = menuResult.switchAccountIndex; let targetLabel: string | null = null; await withAccountStorageTransaction(async (loadedStorage, persist) => { const txStorage = loadedStorage; if (!txStorage) return; - const target = txStorage.accounts[menuResult.switchAccountIndex]; + const target = txStorage.accounts[targetIndex]; if (!target) return; - txStorage.activeIndex = menuResult.switchAccountIndex; + txStorage.activeIndex = targetIndex; txStorage.activeIndexByFamily = txStorage.activeIndexByFamily ?? {}; for (const family of MODEL_FAMILIES) { - txStorage.activeIndexByFamily[family] = menuResult.switchAccountIndex; + txStorage.activeIndexByFamily[family] = targetIndex; } await persist(txStorage); - targetLabel = target.email ?? `Account ${menuResult.switchAccountIndex + 1}`; + targetLabel = target.email ?? `Account ${targetIndex + 1}`; }); if (targetLabel) { invalidateAccountManagerCache(); diff --git a/lib/cli.ts b/lib/cli.ts index 8667764c..cda64221 100644 --- a/lib/cli.ts +++ b/lib/cli.ts @@ -1,6 +1,7 @@ import { createInterface } from "node:readline/promises"; import { stdin as input, stdout as output } from "node:process"; import type { AccountIdSource } from "./types.js"; +import { ANSI_CSI_REGEX, CONTROL_CHAR_REGEX } from "./ui/ansi.js"; import { showAuthMenu, showAccountDetails, @@ -11,9 +12,6 @@ import { } from "./ui/auth-menu.js"; import { UI_COPY } from "./ui/copy.js"; -const ANSI_CSI_REGEX = new RegExp("\\x1b\\[[0-?]*[ -/]*[@-~]", "g"); -const CONTROL_CHAR_REGEX = new RegExp("[\\u0000-\\u001f\\u007f]", "g"); - export function isNonInteractiveMode(): boolean { if (process.env.FORCE_INTERACTIVE_MODE === "1") return false; if (!input.isTTY || !output.isTTY) return true; diff --git a/lib/sync-prune-backup.ts b/lib/sync-prune-backup.ts index 28e76d15..abff7c91 100644 --- a/lib/sync-prune-backup.ts +++ b/lib/sync-prune-backup.ts @@ -5,7 +5,7 @@ type FlaggedSnapshot = { accounts: TAccount[]; }; -function cloneWithoutAccessToken(account: TAccount): TAccount { +function cloneWithoutTokens(account: TAccount): TAccount { const clone = structuredClone(account) as TAccount & { accessToken?: unknown; refreshToken?: unknown; @@ -27,12 +27,12 @@ export function createSyncPruneBackupPayload( version: 1, accounts: { ...currentAccountsStorage, - accounts: currentAccountsStorage.accounts.map((account) => cloneWithoutAccessToken(account)), + accounts: currentAccountsStorage.accounts.map((account) => cloneWithoutTokens(account)), activeIndexByFamily: { ...(currentAccountsStorage.activeIndexByFamily ?? {}) }, }, flagged: { ...currentFlaggedStorage, - accounts: currentFlaggedStorage.accounts.map((flagged) => cloneWithoutAccessToken(flagged)), + accounts: currentFlaggedStorage.accounts.map((flagged) => cloneWithoutTokens(flagged)), }, }; } diff --git a/lib/ui/ansi.ts b/lib/ui/ansi.ts index 3fe73abf..9ad0b98a 100644 --- a/lib/ui/ansi.ts +++ b/lib/ui/ansi.ts @@ -31,6 +31,10 @@ export const ANSI = { reset: "\x1b[0m", } as const; +// biome-ignore lint/suspicious/noControlCharactersInRegex: matching ANSI escape codes +export const ANSI_CSI_REGEX = new RegExp("\\x1b\\[[0-?]*[ -/]*[@-~]", "g"); +export const CONTROL_CHAR_REGEX = new RegExp("[\\u0000-\\u001f\\u007f]", "g"); + export type KeyAction = | "up" | "down" diff --git a/lib/ui/auth-menu.ts b/lib/ui/auth-menu.ts index 5b25462f..40b222ae 100644 --- a/lib/ui/auth-menu.ts +++ b/lib/ui/auth-menu.ts @@ -1,6 +1,6 @@ import { createInterface } from "node:readline/promises"; import { stdin as input, stdout as output } from "node:process"; -import { ANSI, isTTY } from "./ansi.js"; +import { ANSI, ANSI_CSI_REGEX, CONTROL_CHAR_REGEX, isTTY } from "./ansi.js"; import { confirm } from "./confirm.js"; import { getUiRuntimeOptions } from "./runtime.js"; import { select, type MenuItem } from "./select.js"; @@ -63,9 +63,6 @@ export type SettingsAction = type SettingsHubAction = "sync" | "maintenance" | "back" | "cancel"; -// biome-ignore lint/suspicious/noControlCharactersInRegex: matching ANSI escape codes -const ANSI_CSI_REGEX = new RegExp("\\x1b\\[[0-?]*[ -/]*[@-~]", "g"); -const CONTROL_CHAR_REGEX = new RegExp("[\\u0000-\\u001f\\u007f]", "g"); export interface SyncPruneCandidate { index: number; email?: string; diff --git a/test/index.test.ts b/test/index.test.ts index d591aab0..216a0fe9 100644 --- a/test/index.test.ts +++ b/test/index.test.ts @@ -3214,6 +3214,7 @@ describe("OpenAIOAuthPlugin persistAccountPool", () => { const confirmModule = await import("../lib/ui/confirm.js"); const tempDir = await fs.mkdtemp(join(tmpdir(), "oc-sync-prune-")); + const consoleSpy = vi.spyOn(console, "log").mockImplementation(() => undefined); try { mockStorage.accounts = [ { @@ -3329,12 +3330,18 @@ describe("OpenAIOAuthPlugin persistAccountPool", () => { const authResult = await autoMethod.authorize(); expect(authResult.instructions).toBe("Authentication cancelled"); + expect( + consoleSpy.mock.calls.some(([value]) => + String(value).includes("cannot be recovered if the process is interrupted"), + ), + ).toBe(true); expect(mockStorage.accounts).toHaveLength(2); expect(mockStorage.accounts.map((account) => account.accountId)).toEqual([ "org-keep", "org-prune", ]); } finally { + consoleSpy.mockRestore(); await fs.rm(tempDir, { recursive: true, force: true }); } }); @@ -4086,29 +4093,59 @@ describe("OpenAIOAuthPlugin persistAccountPool", () => { vi.mocked(storageModule.loadFlaggedAccounts).mockReset(); vi.mocked(storageModule.saveFlaggedAccounts).mockReset(); vi.mocked(storageModule.saveFlaggedAccounts).mockResolvedValue(undefined); - vi.mocked(storageModule.loadFlaggedAccounts).mockResolvedValue({ - version: 1, - accounts: [ - { - refreshToken: "refresh-shared", - organizationId: "org-other", - accountId: "org-other", - flaggedAt: 1, - }, - { - refreshToken: "refresh-shared", - organizationId: "org-prune", - accountId: "org-prune", - flaggedAt: 2, - }, - { - refreshToken: "refresh-keep", - organizationId: "org-keep", - accountId: "org-keep", - flaggedAt: 3, - }, - ], - }); + vi.mocked(storageModule.loadFlaggedAccounts) + .mockResolvedValueOnce({ + version: 1, + accounts: [ + { + refreshToken: "refresh-shared", + organizationId: "org-other", + accountId: "org-other", + flaggedAt: 1, + }, + { + refreshToken: "refresh-shared", + organizationId: "org-prune", + accountId: "org-prune", + flaggedAt: 2, + }, + { + refreshToken: "refresh-keep", + organizationId: "org-keep", + accountId: "org-keep", + flaggedAt: 3, + }, + ], + }) + .mockResolvedValue({ + version: 1, + accounts: [ + { + refreshToken: "refresh-shared", + organizationId: "org-other", + accountId: "org-other", + flaggedAt: 1, + }, + { + refreshToken: "refresh-shared", + organizationId: "org-prune", + accountId: "org-prune", + flaggedAt: 2, + }, + { + refreshToken: "refresh-keep", + organizationId: "org-keep", + accountId: "org-keep", + flaggedAt: 3, + }, + { + refreshToken: "refresh-concurrent-flagged", + organizationId: "org-concurrent-flagged", + accountId: "org-concurrent-flagged", + flaggedAt: 4, + }, + ], + }); vi.mocked(storageModule.createTimestampedBackupPath).mockImplementation((prefix?: string) => join(tempDir, `${prefix ?? "codex-backup"}.json`), @@ -4203,6 +4240,12 @@ describe("OpenAIOAuthPlugin persistAccountPool", () => { accountId: "org-keep", flaggedAt: 3, }, + { + refreshToken: "refresh-concurrent-flagged", + organizationId: "org-concurrent-flagged", + accountId: "org-concurrent-flagged", + flaggedAt: 4, + }, ], }); } finally { From 9bcb775f2912a40aec002ee9fee991c67899fde9 Mon Sep 17 00:00:00 2001 From: ndycode Date: Tue, 10 Mar 2026 11:10:02 +0800 Subject: [PATCH 80/81] fix: close latest greptile issues Co-authored-by: Codex --- index.ts | 3 ++- test/index.test.ts | 3 +++ 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/index.ts b/index.ts index 7d8bcd6e..94013359 100644 --- a/index.ts +++ b/index.ts @@ -4365,7 +4365,8 @@ while (attempted.size < Math.max(1, accountCount)) { ); console.log(""); const confirmed = await confirm( - `Remove ${indexesToRemove.length} selected account(s) and retry sync?`, + `Remove ${indexesToRemove.length} selected account(s) and retry sync? ` + + `Accounts cannot be recovered if the process is interrupted before sync completes.`, ); if (!confirmed) { await safeRestorePruneBackup("sync cancellation"); diff --git a/test/index.test.ts b/test/index.test.ts index 216a0fe9..1dcffd28 100644 --- a/test/index.test.ts +++ b/test/index.test.ts @@ -3335,6 +3335,9 @@ describe("OpenAIOAuthPlugin persistAccountPool", () => { String(value).includes("cannot be recovered if the process is interrupted"), ), ).toBe(true); + expect(vi.mocked(confirmModule.confirm)).toHaveBeenCalledWith( + expect.stringContaining("cannot be recovered if the process is interrupted"), + ); expect(mockStorage.accounts).toHaveLength(2); expect(mockStorage.accounts.map((account) => account.accountId)).toEqual([ "org-keep", From 642d37dd7f77c60877e777385a651e47e775dfde Mon Sep 17 00:00:00 2001 From: ndycode Date: Tue, 10 Mar 2026 11:40:29 +0800 Subject: [PATCH 81/81] fix: harden sync prune storage and temp cleanup Co-authored-by: Codex --- index.ts | 44 +++++---- lib/codex-multi-auth-sync.ts | 31 +++++++ lib/storage.ts | 72 +++++++++++---- test/codex-multi-auth-sync.test.ts | 65 +++++++++++++ test/index.test.ts | 142 ++++++++++++++++++----------- test/storage.test.ts | 66 ++++++++++++++ 6 files changed, 326 insertions(+), 94 deletions(-) diff --git a/index.ts b/index.ts index 94013359..e5bbd35d 100644 --- a/index.ts +++ b/index.ts @@ -124,11 +124,13 @@ import { previewImportAccounts, createTimestampedBackupPath, loadFlaggedAccounts, + loadAccountAndFlaggedStorageSnapshot, saveFlaggedAccounts, clearFlaggedAccounts, StorageError, formatStorageErrorHint, normalizeAccountStorage, + withFlaggedAccountsTransaction, type AccountStorageV3, type FlaggedAccountMetadataV1, } from "./lib/storage.js"; @@ -3926,15 +3928,16 @@ while (attempted.size < Math.max(1, accountCount)) { } throw new Error("readPruneBackupFile: unexpected retry exit"); }; + const { accounts: loadedAccountsStorage, flagged: currentFlaggedStorage } = + await loadAccountAndFlaggedStorageSnapshot(); const currentAccountsStorage = - (await loadAccounts()) ?? + loadedAccountsStorage ?? ({ - version: 3, - accounts: [], - activeIndex: 0, - activeIndexByFamily: {}, - } satisfies AccountStorageV3); - const currentFlaggedStorage = await loadFlaggedAccounts(); + 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); @@ -4135,19 +4138,20 @@ while (attempted.size < Math.max(1, accountCount)) { }), ), ); - const currentFlaggedStorage = await loadFlaggedAccounts(); - await saveFlaggedAccounts({ - version: 1, - accounts: currentFlaggedStorage.accounts.filter( - (flagged) => - !removedFlaggedKeys.has( - getSyncRemovalTargetKey({ - refreshToken: flagged.refreshToken, - organizationId: flagged.organizationId, - accountId: flagged.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 removedLabels = removedTargets diff --git a/lib/codex-multi-auth-sync.ts b/lib/codex-multi-auth-sync.ts index c99e3355..cf48f8e1 100644 --- a/lib/codex-multi-auth-sync.ts +++ b/lib/codex-multi-auth-sync.ts @@ -170,6 +170,35 @@ function stableSerialize(value: unknown): string { return JSON.stringify(value); } +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, @@ -185,9 +214,11 @@ async function withNormalizedImportFile( 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) { diff --git a/lib/storage.ts b/lib/storage.ts index 2b8413ad..8abd3fb0 100644 --- a/lib/storage.ts +++ b/lib/storage.ts @@ -1129,6 +1129,16 @@ export async function withAccountStorageTransaction( }); } +export async function loadAccountAndFlaggedStorageSnapshot(): Promise<{ + accounts: AccountStorageV3 | null; + flagged: FlaggedAccountStorageV1; +}> { + return withStorageLock(async () => ({ + accounts: await loadAccountsInternal(saveAccountsUnlocked), + flagged: await loadFlaggedAccountsUnlocked(saveFlaggedAccountsUnlocked), + })); +} + /** * Persists account storage to disk using atomic write (temp file + rename). * Creates the .opencode directory if it doesn't exist. @@ -1271,7 +1281,9 @@ function normalizeFlaggedStorage(data: unknown): FlaggedAccountStorageV1 { }; } -export async function loadFlaggedAccounts(): Promise { +async function loadFlaggedAccountsUnlocked( + persistMigration: ((storage: FlaggedAccountStorageV1) => Promise) | null, +): Promise { const path = getFlaggedAccountsPath(); const empty: FlaggedAccountStorageV1 = { version: 1, accounts: [] }; @@ -1296,8 +1308,8 @@ export async function loadFlaggedAccounts(): Promise { const legacyContent = await fs.readFile(legacyPath, "utf-8"); const legacyData = JSON.parse(legacyContent) as unknown; const migrated = normalizeFlaggedStorage(legacyData); - if (migrated.accounts.length > 0) { - await saveFlaggedAccounts(migrated); + if (migrated.accounts.length > 0 && persistMigration) { + await persistMigration(migrated); } try { await fs.unlink(legacyPath); @@ -1320,26 +1332,46 @@ export async function loadFlaggedAccounts(): Promise { } } -export async function saveFlaggedAccounts(storage: FlaggedAccountStorageV1): Promise { - return withStorageLock(async () => { - const path = getFlaggedAccountsPath(); - const uniqueSuffix = `${Date.now()}.${Math.random().toString(36).slice(2, 8)}`; - const tempPath = `${path}.${uniqueSuffix}.tmp`; +async function saveFlaggedAccountsUnlocked(storage: FlaggedAccountStorageV1): Promise { + const path = getFlaggedAccountsPath(); + const uniqueSuffix = `${Date.now()}.${Math.random().toString(36).slice(2, 8)}`; + const tempPath = `${path}.${uniqueSuffix}.tmp`; + try { + await fs.mkdir(dirname(path), { recursive: true }); + const content = JSON.stringify(normalizeFlaggedStorage(storage), null, 2); + await fs.writeFile(tempPath, content, { encoding: "utf-8", mode: 0o600 }); + await renameWithWindowsRetry(tempPath, path); + } catch (error) { try { - await fs.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; + 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 loadFlaggedAccounts(): Promise { + return withStorageLock(async () => loadFlaggedAccountsUnlocked(saveFlaggedAccountsUnlocked)); +} + +export async function withFlaggedAccountsTransaction( + handler: ( + current: FlaggedAccountStorageV1, + persist: (storage: FlaggedAccountStorageV1) => Promise, + ) => Promise, +): Promise { + return withStorageLock(async () => { + const current = await loadFlaggedAccountsUnlocked(saveFlaggedAccountsUnlocked); + return handler(current, saveFlaggedAccountsUnlocked); + }); +} + +export async function saveFlaggedAccounts(storage: FlaggedAccountStorageV1): Promise { + return withStorageLock(async () => { + await saveFlaggedAccountsUnlocked(storage); }); } diff --git a/test/codex-multi-auth-sync.test.ts b/test/codex-multi-auth-sync.test.ts index f8350535..ccaabf4b 100644 --- a/test/codex-multi-auth-sync.test.ts +++ b/test/codex-multi-auth-sync.test.ts @@ -844,6 +844,71 @@ describe("codex-multi-auth sync", () => { } }); + 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("finds the project-scoped codex-multi-auth source across same-repo worktrees", async () => { const rootDir = join(process.cwd(), ".tmp-codex-multi-auth"); process.env.CODEX_MULTI_AUTH_DIR = rootDir; diff --git a/test/index.test.ts b/test/index.test.ts index 1dcffd28..437abeab 100644 --- a/test/index.test.ts +++ b/test/index.test.ts @@ -300,6 +300,16 @@ const mockStorage = { activeIndexByFamily: {} as Record, }; +const mockFlaggedStorage = { + version: 1 as const, + accounts: [] as Array<{ + refreshToken: string; + organizationId?: string; + accountId?: string; + flaggedAt: number; + }>, +}; + vi.mock("../lib/storage.js", () => ({ getStoragePath: () => "/mock/path/accounts.json", loadAccounts: vi.fn(async () => mockStorage), @@ -325,6 +335,36 @@ vi.mock("../lib/storage.js", () => ({ await callback(loadedStorage, persist); }, ), + loadAccountAndFlaggedStorageSnapshot: vi.fn(async () => ({ + accounts: { + ...mockStorage, + accounts: mockStorage.accounts.map((account) => ({ ...account })), + activeIndexByFamily: { ...mockStorage.activeIndexByFamily }, + }, + flagged: { + version: 1, + accounts: mockFlaggedStorage.accounts.map((account) => ({ ...account })), + }, + })), + withFlaggedAccountsTransaction: vi.fn( + async ( + callback: ( + current: typeof mockFlaggedStorage, + persist: (nextStorage: typeof mockFlaggedStorage) => Promise, + ) => Promise, + ) => { + const loadedStorage = { + version: 1 as const, + accounts: mockFlaggedStorage.accounts.map((account) => ({ ...account })), + }; + const persist = async (nextStorage: typeof mockFlaggedStorage) => { + mockFlaggedStorage.accounts = nextStorage.accounts.map((account) => ({ ...account })); + const storageModule = await import("../lib/storage.js"); + await vi.mocked(storageModule.saveFlaggedAccounts)(nextStorage); + }; + await callback(loadedStorage, persist); + }, + ), cleanupDuplicateEmailAccounts: vi.fn(async () => ({ before: 0, after: 0, @@ -348,8 +388,13 @@ vi.mock("../lib/storage.js", () => ({ })), previewImportAccounts: vi.fn(async () => ({ imported: 2, skipped: 1, total: 5 })), createTimestampedBackupPath: vi.fn((prefix?: string) => `/tmp/${prefix ?? "codex-backup"}-20260101-000000.json`), - loadFlaggedAccounts: vi.fn(async () => ({ version: 1, accounts: [] })), - saveFlaggedAccounts: vi.fn(async () => {}), + loadFlaggedAccounts: vi.fn(async () => ({ + version: 1, + accounts: mockFlaggedStorage.accounts.map((account) => ({ ...account })), + })), + saveFlaggedAccounts: vi.fn(async (storage: typeof mockFlaggedStorage) => { + mockFlaggedStorage.accounts = storage.accounts.map((account) => ({ ...account })); + }), clearFlaggedAccounts: vi.fn(async () => {}), normalizeAccountStorage: vi.fn((value: unknown) => value), StorageError: class StorageError extends Error { @@ -574,6 +619,7 @@ describe("OpenAIOAuthPlugin", () => { mockStorage.accounts = []; mockStorage.activeIndex = 0; mockStorage.activeIndexByFamily = {}; + mockFlaggedStorage.accounts = []; const { OpenAIOAuthPlugin } = await import("../index.js"); plugin = await OpenAIOAuthPlugin({ client: mockClient } as never) as unknown as PluginType; @@ -4093,62 +4139,50 @@ describe("OpenAIOAuthPlugin persistAccountPool", () => { vi.mocked(configModule.getSyncFromCodexMultiAuthEnabled).mockReturnValue(true); vi.mocked(confirmModule.confirm).mockReset(); vi.mocked(confirmModule.confirm).mockResolvedValue(true); - vi.mocked(storageModule.loadFlaggedAccounts).mockReset(); + vi.mocked(storageModule.withFlaggedAccountsTransaction).mockReset(); vi.mocked(storageModule.saveFlaggedAccounts).mockReset(); vi.mocked(storageModule.saveFlaggedAccounts).mockResolvedValue(undefined); - vi.mocked(storageModule.loadFlaggedAccounts) - .mockResolvedValueOnce({ - version: 1, - accounts: [ - { - refreshToken: "refresh-shared", - organizationId: "org-other", - accountId: "org-other", - flaggedAt: 1, - }, - { - refreshToken: "refresh-shared", - organizationId: "org-prune", - accountId: "org-prune", - flaggedAt: 2, - }, - { - refreshToken: "refresh-keep", - organizationId: "org-keep", - accountId: "org-keep", - flaggedAt: 3, - }, - ], - }) - .mockResolvedValue({ - version: 1, - accounts: [ - { - refreshToken: "refresh-shared", - organizationId: "org-other", - accountId: "org-other", - flaggedAt: 1, - }, - { - refreshToken: "refresh-shared", - organizationId: "org-prune", - accountId: "org-prune", - flaggedAt: 2, - }, - { - refreshToken: "refresh-keep", - organizationId: "org-keep", - accountId: "org-keep", - flaggedAt: 3, - }, + mockFlaggedStorage.accounts = [ + { + refreshToken: "refresh-shared", + organizationId: "org-other", + accountId: "org-other", + flaggedAt: 1, + }, + { + refreshToken: "refresh-shared", + organizationId: "org-prune", + accountId: "org-prune", + flaggedAt: 2, + }, + { + refreshToken: "refresh-keep", + organizationId: "org-keep", + accountId: "org-keep", + flaggedAt: 3, + }, + { + refreshToken: "refresh-concurrent-flagged", + organizationId: "org-concurrent-flagged", + accountId: "org-concurrent-flagged", + flaggedAt: 4, + }, + ]; + vi.mocked(storageModule.withFlaggedAccountsTransaction).mockImplementation( + async (callback) => { + const persist = async (nextStorage) => { + mockFlaggedStorage.accounts = nextStorage.accounts.map((account) => ({ ...account })); + await vi.mocked(storageModule.saveFlaggedAccounts)(nextStorage); + }; + await callback( { - refreshToken: "refresh-concurrent-flagged", - organizationId: "org-concurrent-flagged", - accountId: "org-concurrent-flagged", - flaggedAt: 4, + version: 1, + accounts: mockFlaggedStorage.accounts.map((account) => ({ ...account })), }, - ], - }); + persist, + ); + }, + ); vi.mocked(storageModule.createTimestampedBackupPath).mockImplementation((prefix?: string) => join(tempDir, `${prefix ?? "codex-backup"}.json`), diff --git a/test/storage.test.ts b/test/storage.test.ts index 8a645bdd..2121dcc3 100644 --- a/test/storage.test.ts +++ b/test/storage.test.ts @@ -21,6 +21,8 @@ import { previewImportAccounts, createTimestampedBackupPath, withAccountStorageTransaction, + withFlaggedAccountsTransaction, + loadAccountAndFlaggedStorageSnapshot, previewDuplicateEmailCleanup, cleanupDuplicateEmailAccounts, } from "../lib/storage.js"; @@ -1767,6 +1769,70 @@ describe("storage", () => { expect(loaded.accounts).toHaveLength(1); expect(loaded.accounts[0]?.refreshToken).toBe("flagged-ebusy"); }); + + it("updates flagged storage atomically inside withFlaggedAccountsTransaction", async () => { + await saveFlaggedAccounts({ + version: 1, + accounts: [ + { + refreshToken: "flagged-keep", + accountId: "flagged-keep", + flaggedAt: 1, + addedAt: 1, + lastUsed: 1, + }, + { + refreshToken: "flagged-drop", + accountId: "flagged-drop", + flaggedAt: 2, + addedAt: 2, + lastUsed: 2, + }, + ], + }); + + await withFlaggedAccountsTransaction(async (current, persist) => { + await persist({ + version: 1, + accounts: current.accounts.filter((account) => account.refreshToken !== "flagged-drop"), + }); + }); + + const loaded = await loadFlaggedAccounts(); + expect(loaded.accounts.map((account) => account.refreshToken)).toEqual(["flagged-keep"]); + }); + + it("reads accounts and flagged storage from one snapshot helper", async () => { + await saveAccounts({ + version: 3, + activeIndex: 0, + activeIndexByFamily: {}, + accounts: [ + { + refreshToken: "account-refresh", + accountId: "account-id", + addedAt: 1, + lastUsed: 1, + }, + ], + }); + await saveFlaggedAccounts({ + version: 1, + accounts: [ + { + refreshToken: "flagged-refresh", + accountId: "flagged-id", + flaggedAt: 1, + addedAt: 1, + lastUsed: 1, + }, + ], + }); + + const snapshot = await loadAccountAndFlaggedStorageSnapshot(); + expect(snapshot.accounts?.accounts.map((account) => account.refreshToken)).toEqual(["account-refresh"]); + expect(snapshot.flagged.accounts.map((account) => account.refreshToken)).toEqual(["flagged-refresh"]); + }); }); describe("setStoragePath", () => {