From 64ed622841f5fd01745e5c5214d7c1c28b674834 Mon Sep 17 00:00:00 2001 From: "coderabbitai[bot]" <136622811+coderabbitai[bot]@users.noreply.github.com> Date: Fri, 27 Feb 2026 08:07:39 +0000 Subject: [PATCH] =?UTF-8?q?=F0=9F=93=9D=20Add=20docstrings=20to=20`feat/cl?= =?UTF-8?q?ean-runtime-tui-docs-overhaul`?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Docstrings generation was requested by @ndycode. * https://github.com/ndycode/codex-multi-auth/pull/2#issuecomment-3971411253 The following files were modified: * `lib/auth/browser.ts` * `lib/auth/server.ts` * `lib/auto-update-checker.ts` * `lib/capability-policy.ts` * `lib/cli.ts` * `lib/codex-cli/state.ts` * `lib/codex-cli/sync.ts` * `lib/codex-cli/writer.ts` * `lib/config.ts` * `lib/dashboard-settings.ts` * `lib/entitlement-cache.ts` * `lib/forecast.ts` * `lib/live-account-sync.ts` * `lib/preemptive-quota-scheduler.ts` * `lib/prompts/opencode-codex.ts` * `lib/quota-cache.ts` * `lib/quota-probe.ts` * `lib/recovery/constants.ts` * `lib/refresh-lease.ts` * `lib/request/failure-policy.ts` * `lib/request/fetch-helpers.ts` * `lib/request/stream-failover.ts` * `lib/rotation.ts` * `lib/runtime-paths.ts` * `lib/session-affinity.ts` * `lib/storage.ts` * `lib/storage/paths.ts` * `lib/ui/ansi.ts` * `lib/ui/auth-menu.ts` * `lib/ui/confirm.ts` * `lib/ui/copy.ts` * `lib/ui/format.ts` * `lib/ui/runtime.ts` * `lib/ui/select.ts` * `lib/ui/theme.ts` * `lib/unified-settings.ts` * `scripts/codex.js` --- lib/auth/browser.ts | 67 ++- lib/auth/server.ts | 18 +- lib/auto-update-checker.ts | 153 +++++- lib/capability-policy.ts | 153 ++++++ lib/cli.ts | 163 +++++- lib/codex-cli/state.ts | 238 ++++++++- lib/codex-cli/sync.ts | 95 +++- lib/codex-cli/writer.ts | 491 +++++++++++++++--- lib/config.ts | 595 ++++++++++++++++++++-- lib/dashboard-settings.ts | 449 +++++++++++++++++ lib/entitlement-cache.ts | 146 ++++++ lib/forecast.ts | 387 ++++++++++++++ lib/live-account-sync.ts | 207 ++++++++ lib/preemptive-quota-scheduler.ts | 267 ++++++++++ lib/prompts/opencode-codex.ts | 22 +- lib/quota-cache.ts | 219 ++++++++ lib/quota-probe.ts | 396 +++++++++++++++ lib/recovery/constants.ts | 21 +- lib/refresh-lease.ts | 437 ++++++++++++++++ lib/request/failure-policy.ts | 172 +++++++ lib/request/fetch-helpers.ts | 36 +- lib/request/stream-failover.ts | 210 ++++++++ lib/rotation.ts | 28 +- lib/runtime-paths.ts | 178 +++++++ lib/session-affinity.ts | 149 ++++++ lib/storage.ts | 356 ++++++++++++- lib/storage/paths.ts | 63 ++- lib/ui/ansi.ts | 33 +- lib/ui/auth-menu.ts | 805 +++++++++++++++++++++++++++--- lib/ui/confirm.ts | 10 +- lib/ui/copy.ts | 127 +++++ lib/ui/format.ts | 133 ++++- lib/ui/runtime.ts | 32 +- lib/ui/select.ts | 438 ++++++++++------ lib/ui/theme.ts | 147 +++++- lib/unified-settings.ts | 227 +++++++++ scripts/codex.js | 177 +++++++ 37 files changed, 7368 insertions(+), 477 deletions(-) create mode 100644 lib/capability-policy.ts create mode 100644 lib/dashboard-settings.ts create mode 100644 lib/entitlement-cache.ts create mode 100644 lib/forecast.ts create mode 100644 lib/live-account-sync.ts create mode 100644 lib/preemptive-quota-scheduler.ts create mode 100644 lib/quota-cache.ts create mode 100644 lib/quota-probe.ts create mode 100644 lib/refresh-lease.ts create mode 100644 lib/request/failure-policy.ts create mode 100644 lib/request/stream-failover.ts create mode 100644 lib/runtime-paths.ts create mode 100644 lib/session-affinity.ts create mode 100644 lib/ui/copy.ts create mode 100644 lib/unified-settings.ts create mode 100644 scripts/codex.js diff --git a/lib/auth/browser.ts b/lib/auth/browser.ts index 04c94170..5069a41f 100644 --- a/lib/auth/browser.ts +++ b/lib/auth/browser.ts @@ -55,10 +55,12 @@ function commandExists(command: string): boolean { } /** - * Opens a URL in the default browser - * Silently fails if browser cannot be opened (user can copy URL manually) - * @param url - URL to open - * @returns True if a browser launch was attempted + * Attempts to open the given URL in the system default browser. + * + * Concurrency: this function launches a detached child process and does not wait for completion; calling it concurrently is safe. On Windows the URL is sanitized for PowerShell quoting rules but not redacted. The function does not modify or redact any sensitive tokens that may be present in the URL. + * + * @param url - The URL to open; may contain query tokens or sensitive data which will not be redacted by this function. + * @returns `true` if a browser launch was attempted, `false` otherwise. */ export function openBrowserUrl(url: string): boolean { try { @@ -95,3 +97,60 @@ export function openBrowserUrl(url: string): boolean { } } +/** + * Attempts to copy the provided string to the system clipboard using platform-appropriate utilities. + * + * This is a best-effort, fire-and-forget operation: child processes are launched and errors are ignored, so concurrent calls are safe but success is only indicated by whether a clipboard command was started. On Windows the text is escaped for PowerShell before launching Set-Clipboard. This function does not redact secrets; callers must remove or mask tokens/credentials before calling. + * + * @param text - The string to copy; empty or falsy values are ignored + * @returns `true` if a platform clipboard command was launched, `false` otherwise + */ +export function copyTextToClipboard(text: string): boolean { + try { + if (!text) return false; + + if (process.platform === "win32") { + const psText = text + .replace(/`/g, "``") + .replace(/\$/g, "`$") + .replace(/"/g, '""'); + const child = spawn( + "powershell.exe", + ["-NoLogo", "-NoProfile", "-Command", `Set-Clipboard -Value "${psText}"`], + { stdio: "ignore" }, + ); + child.on("error", () => {}); + return true; + } + + if (process.platform === "darwin") { + if (!commandExists("pbcopy")) return false; + const child = spawn("pbcopy", [], { + stdio: ["pipe", "ignore", "ignore"], + shell: false, + }); + child.on("error", () => {}); + child.stdin?.end(text); + return true; + } + + const linuxClipboardCommands: Array<{ command: string; args: string[] }> = [ + { command: "wl-copy", args: [] }, + { command: "xclip", args: ["-selection", "clipboard"] }, + { command: "xsel", args: ["--clipboard", "--input"] }, + ]; + for (const { command, args } of linuxClipboardCommands) { + if (!commandExists(command)) continue; + const child = spawn(command, args, { + stdio: ["pipe", "ignore", "ignore"], + shell: false, + }); + child.on("error", () => {}); + child.stdin?.end(text); + return true; + } + return false; + } catch { + return false; + } +} diff --git a/lib/auth/server.ts b/lib/auth/server.ts index 1f83a105..40c0453e 100644 --- a/lib/auth/server.ts +++ b/lib/auth/server.ts @@ -10,9 +10,16 @@ const __dirname = path.dirname(fileURLToPath(import.meta.url)); const successHtml = fs.readFileSync(path.join(__dirname, "..", "oauth-success.html"), "utf-8"); /** - * Start a small local HTTP server that waits for /auth/callback and returns the code - * @param options - OAuth state for validation - * @returns Promise that resolves to server info + * Start a local HTTP listener that accepts the OAuth redirect on /auth/callback and provides helpers to obtain the authorization code. + * + * The server binds to 127.0.0.1:1455 and validates the `state` query parameter before capturing the `code`. The resolved object exposes `port`, `ready`, `close()`, and `waitForCode()`; `waitForCode()` polls for a captured code for up to 5 minutes and returns `{ code }` or `null`. Concurrency: the implementation expects a single consumer of `waitForCode()` (it returns the first captured code). No filesystem side effects are performed (Windows path semantics are not relevant). Authorization codes are handled in-memory and are not logged by this module; callers should avoid logging or persisting returned codes to prevent accidental token leakage. + * + * @param state - The expected OAuth `state` value used to validate incoming callback requests + * @returns An object with runtime info and helpers: + * - `port`: 1455 + * - `ready`: `true` if the server successfully bound to the port, `false` if binding failed and manual paste is required + * - `close()`: stops the server and aborts any pending polling + * - `waitForCode()`: polls for the authorization code and resolves to `{ code }` when received or `null` on abort/timeout */ export function startLocalOAuthServer({ state }: { state: string }): Promise { let pollAborted = false; @@ -39,7 +46,10 @@ export function startLocalOAuthServer({ state }: { state: string }): Promise parseInt(p, 10) || 0); - const latestParts = latest.split(".").map((p) => parseInt(p, 10) || 0); - - for (let i = 0; i < Math.max(currentParts.length, latestParts.length); i++) { - const c = currentParts[i] ?? 0; - const l = latestParts[i] ?? 0; - if (l > c) return 1; - if (l < c) return -1; +/** + * Parse a semantic-version string into numeric core components and prerelease segments. + * + * Accepts versions with an optional leading "v", build metadata (after "+"), and prerelease (after "-"). + * The core is returned as [major, minor, patch] with non-numeric or missing parts coerced to 0. + * The prerelease is returned as an array of dot-separated segments (strings); an empty array indicates a release. + * + * This function is pure and safe for concurrent use. It does not access the filesystem, does not perform + * network calls, and does not perform any token redaction or exposure. + * + * @param version - The semver string to parse (for example "v1.2.3", "1.2.3-alpha.1+build.5") + * @returns An object with `core` set to numeric [major, minor, patch] and `prerelease` set to an array of prerelease segments + */ +function parseSemver(version: string): ParsedSemver { + const normalized = version.trim().replace(/^v/i, ""); + const [withoutBuild] = normalized.split("+"); + const [corePart = "0.0.0", prereleasePart] = (withoutBuild ?? "0.0.0").split("-", 2); + const [majorRaw = "0", minorRaw = "0", patchRaw = "0"] = corePart.split("."); + + const toSafeInt = (value: string): number => { + if (!/^\d+$/.test(value)) return 0; + const parsed = Number.parseInt(value, 10); + return Number.isFinite(parsed) ? parsed : 0; + }; + + return { + core: [toSafeInt(majorRaw), toSafeInt(minorRaw), toSafeInt(patchRaw)], + prerelease: + prereleasePart && prereleasePart.trim().length > 0 + ? prereleasePart.split(".").filter((segment) => segment.length > 0) + : [], + }; +} + +/** + * Compare two prerelease identifier arrays to determine which represents a newer prerelease. + * + * @param current - Prerelease segments from the current version (e.g., `["alpha", "1"]`) + * @param latest - Prerelease segments from the latest version to compare against + * @returns `1` if `latest` is newer than `current`, `-1` if `current` is newer, `0` if they are equivalent + */ +function comparePrerelease(current: string[], latest: string[]): number { + const maxLen = Math.max(current.length, latest.length); + + for (let i = 0; i < maxLen; i++) { + const currentPart = current[i]; + const latestPart = latest[i]; + + if (currentPart === undefined && latestPart === undefined) return 0; + if (currentPart === undefined) return 1; + if (latestPart === undefined) return -1; + + if (currentPart === latestPart) continue; + + const currentIsNumeric = /^\d+$/.test(currentPart); + const latestIsNumeric = /^\d+$/.test(latestPart); + + if (currentIsNumeric && latestIsNumeric) { + const currentNum = Number.parseInt(currentPart, 10); + const latestNum = Number.parseInt(latestPart, 10); + if (latestNum > currentNum) return 1; + if (latestNum < currentNum) return -1; + continue; + } + + if (currentIsNumeric && !latestIsNumeric) return 1; + if (!currentIsNumeric && latestIsNumeric) return -1; + + const lexical = latestPart.localeCompare(currentPart, "en", { sensitivity: "case" }); + if (lexical > 0) return 1; + if (lexical < 0) return -1; } + return 0; } +/** + * Compare two semantic version strings and determine which is newer. + * + * @param current - The currently installed version (semver string; may start with "v" and may include build metadata) + * @param latest - The candidate version to compare against (semver string; may start with "v" and may include build metadata) + * @returns `1` if `latest` is newer than `current`, `-1` if `current` is newer than `latest`, `0` if they are equivalent + * + * Notes: pure and safe for concurrent use; does not perform I/O. Version parsing tolerates non-numeric segments by coercion. Safe on Windows filesystems and does not read or emit sensitive tokens. + */ +function compareVersions(current: string, latest: string): number { + const parsedCurrent = parseSemver(current); + const parsedLatest = parseSemver(latest); + + for (let i = 0; i < parsedCurrent.core.length; i++) { + const currentPart = parsedCurrent.core[i] ?? 0; + const latestPart = parsedLatest.core[i] ?? 0; + if (latestPart > currentPart) return 1; + if (latestPart < currentPart) return -1; + } + + const currentHasPrerelease = parsedCurrent.prerelease.length > 0; + const latestHasPrerelease = parsedLatest.prerelease.length > 0; + + if (!currentHasPrerelease && latestHasPrerelease) { + return -1; + } + if (currentHasPrerelease && !latestHasPrerelease) { + return 1; + } + + return comparePrerelease(parsedCurrent.prerelease, parsedLatest.prerelease); +} + +/** + * Fetches the latest published version string for the package from the npm registry. + * + * Callers may invoke this concurrently; each call issues an independent HTTP request. + * This function performs no filesystem I/O (so Windows filesystem semantics are not applicable) + * and does not process or emit authentication tokens. + * + * @returns The latest version string from the registry, or `null` if the registry could not be reached, returned a non-OK response, or the version field was absent. + */ async function fetchLatestVersion(): Promise { try { const controller = new AbortController(); diff --git a/lib/capability-policy.ts b/lib/capability-policy.ts new file mode 100644 index 00000000..da5c09b6 --- /dev/null +++ b/lib/capability-policy.ts @@ -0,0 +1,153 @@ +export interface CapabilityPolicySnapshot { + successes: number; + failures: number; + unsupported: number; + lastSuccessAt?: number; + lastFailureAt?: number; +} + +interface CapabilityEntry extends CapabilityPolicySnapshot { + updatedAt: number; +} + +const MAX_ENTRIES = 2048; +const PASSIVE_RECOVERY_PER_MIN = 0.5; + +/** + * Normalize a model identifier by trimming whitespace, converting to lowercase, taking the final `/`-delimited segment, and removing common qualitative suffixes. + * + * @param model - The raw model string to normalize; may be undefined. + * @returns The normalized model string, or `null` if `model` is falsy or empty after trimming. + * + * Notes: + * - This function is pure and has no side effects; it is safe for concurrent use. + * - It only splits on the forward slash (`/`); Windows-style backslashes (`\`) are not treated as separators. + * - Qualitative suffixes removed (case-insensitive): `-none`, `-minimal`, `-low`, `-medium`, `-high`, `-xhigh`. + */ +function normalizeModel(model: string | undefined): string | null { + if (!model) return null; + const trimmed = model.trim().toLowerCase(); + if (!trimmed) return null; + const stripped = trimmed.includes("/") ? (trimmed.split("/").pop() ?? trimmed) : trimmed; + return stripped.replace(/-(none|minimal|low|medium|high|xhigh)$/i, ""); +} + +/** + * Builds a composite key from an account key and a normalized model identifier. + * + * This function returns an opaque, colon-separated key suitable for in-memory maps. + * It performs no I/O, is safe for concurrent use within a single process, does not + * sanitize for filesystem use (e.g., Windows filename rules), and does not redact + * sensitive tokens — callers must sanitize or redact before persisting or logging. + * + * @param accountKey - The account identifier; if falsy the function returns `null` + * @param model - The model string to normalize; may be `undefined` + * @returns The composite key in the form `accountKey:normalizedModel`, or `null` if either input is missing or the model cannot be normalized + */ +function makeKey(accountKey: string, model: string | undefined): string | null { + const normalized = normalizeModel(model); + if (!accountKey || !normalized) return null; + return `${accountKey}:${normalized}`; +} + +export class CapabilityPolicyStore { + private readonly entries = new Map(); + + recordSuccess(accountKey: string, model: string, now = Date.now()): void { + const key = makeKey(accountKey, model); + if (!key) return; + const existing = this.entries.get(key); + this.entries.set(key, { + successes: (existing?.successes ?? 0) + 1, + failures: Math.max(0, (existing?.failures ?? 0) - 1), + unsupported: Math.max(0, (existing?.unsupported ?? 0) - 1), + lastSuccessAt: now, + lastFailureAt: existing?.lastFailureAt, + updatedAt: now, + }); + this.evictIfNeeded(); + } + + recordFailure(accountKey: string, model: string, now = Date.now()): void { + const key = makeKey(accountKey, model); + if (!key) return; + const existing = this.entries.get(key); + this.entries.set(key, { + successes: existing?.successes ?? 0, + failures: (existing?.failures ?? 0) + 1, + unsupported: existing?.unsupported ?? 0, + lastSuccessAt: existing?.lastSuccessAt, + lastFailureAt: now, + updatedAt: now, + }); + this.evictIfNeeded(); + } + + recordUnsupported(accountKey: string, model: string, now = Date.now()): void { + const key = makeKey(accountKey, model); + if (!key) return; + const existing = this.entries.get(key); + this.entries.set(key, { + successes: existing?.successes ?? 0, + failures: (existing?.failures ?? 0) + 1, + unsupported: (existing?.unsupported ?? 0) + 1, + lastSuccessAt: existing?.lastSuccessAt, + lastFailureAt: now, + updatedAt: now, + }); + this.evictIfNeeded(); + } + + getBoost(accountKey: string, model: string, now = Date.now()): number { + const key = makeKey(accountKey, model); + if (!key) return 0; + const entry = this.entries.get(key); + if (!entry) return 0; + + const minutesSinceUpdate = Math.max(0, (now - entry.updatedAt) / 60_000); + const recoveredFailures = Math.max(0, entry.failures - minutesSinceUpdate * PASSIVE_RECOVERY_PER_MIN); + const recoveredUnsupported = Math.max(0, entry.unsupported - minutesSinceUpdate * PASSIVE_RECOVERY_PER_MIN); + + const successScore = Math.min(12, entry.successes * 2); + const failurePenalty = Math.min(18, recoveredFailures * 3); + const unsupportedPenalty = Math.min(24, recoveredUnsupported * 6); + const net = successScore - failurePenalty - unsupportedPenalty; + return Math.max(-30, Math.min(20, net)); + } + + getSnapshot(accountKey: string, model: string): CapabilityPolicySnapshot | null { + const key = makeKey(accountKey, model); + if (!key) return null; + const entry = this.entries.get(key); + if (!entry) return null; + return { + successes: entry.successes, + failures: entry.failures, + unsupported: entry.unsupported, + lastSuccessAt: entry.lastSuccessAt, + lastFailureAt: entry.lastFailureAt, + }; + } + + clearAccount(accountKey: string): number { + if (!accountKey) return 0; + let removed = 0; + for (const key of this.entries.keys()) { + if (key.startsWith(`${accountKey}:`)) { + this.entries.delete(key); + removed += 1; + } + } + return removed; + } + + private evictIfNeeded(): void { + if (this.entries.size <= MAX_ENTRIES) return; + const oldest = this.entries.entries().next().value; + if (!oldest) return; + const [key] = oldest; + if (typeof key === "string") { + this.entries.delete(key); + } + } +} diff --git a/lib/cli.ts b/lib/cli.ts index 7d6e445d..d5cb6217 100644 --- a/lib/cli.ts +++ b/lib/cli.ts @@ -7,6 +7,7 @@ import { 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. @@ -23,6 +24,16 @@ export function isNonInteractiveMode(): boolean { return false; } +/** + * Prompt the user whether to add another account. + * + * Prompts with a contextual question based on `currentCount` and returns the user's yes/no choice. In non-interactive mode this resolves to `false`. + * + * Concurrency: safe to call concurrently — each invocation creates its own readline interface. Terminal behavior: works with Windows terminals and POSIX TTYs. Privacy: raw input is not persisted or logged; only the normalized yes/no result is used. + * + * @param currentCount - The current number of saved accounts; used to format the prompt. + * @returns `true` if the user answered "y" or "yes", `false` otherwise. + */ export async function promptAddAnotherAccount(currentCount: number): Promise { if (isNonInteractiveMode()) { return false; @@ -30,8 +41,8 @@ export async function promptAddAnotherAccount(currentCount: number): Promise string | undefined); } export interface LoginMenuResult { @@ -69,9 +100,26 @@ export interface LoginMenuResult { deleteAccountIndex?: number; refreshAccountIndex?: number; toggleAccountIndex?: number; + switchAccountIndex?: number; deleteAll?: boolean; } +/** + * Builds a user-facing label for an account prefixed with a 1-based index. + * + * Prefers `account.accountLabel` combined with `account.email` when available, falls back to `account.email`, + * then to the last six characters of `account.accountId`, and finally to the literal `"Account"` if no identifying + * fields exist. + * + * Concurrency: pure and side-effect free — safe to call from concurrent contexts. Windows filesystem behavior: + * label generation is platform-agnostic and does not depend on filesystem semantics. Token redaction: + * this function does not attempt to redact or mask sensitive tokens; only uses provided fields and will include + * email or accountId suffix when present. + * + * @param account - Account data from which to derive the display label + * @param index - Zero-based account index used to produce the leading 1-based numeric prefix + * @returns The formatted label, e.g. "1. Personal (you@example.com)", "2. 123abc", or "3. Account" + */ function formatAccountLabel(account: ExistingAccountInfo, index: number): string { const num = index + 1; const label = account.accountLabel?.trim(); @@ -88,16 +136,52 @@ function formatAccountLabel(account: ExistingAccountInfo, index: number): string return `${num}. Account`; } +/** + * Resolve the effective source index for an account. + * + * This returns the account's explicit `sourceIndex` when present and numeric, otherwise falls back to `index`. + * The function is synchronous and safe to call concurrently. It performs no filesystem I/O (no Windows-specific behavior), + * and it does not read or expose authentication tokens (only numeric indices are returned). + * + * @param account - Account object to resolve the source index from + * @returns The numeric source index to use for this account + */ +function resolveAccountSourceIndex(account: ExistingAccountInfo): number { + return typeof account.sourceIndex === "number" ? account.sourceIndex : account.index; +} + +/** + * Prompt the user to type DELETE to confirm removal of all saved accounts. + * + * Reads a single line from stdin, trims surrounding whitespace (handles Windows CR line endings), + * and returns whether the exact, case-sensitive string "DELETE" was provided. Assumes exclusive + * access to the process stdin/stdout (not safe for concurrent prompts). The entered text is + * compared transiently and is not persisted; avoid logging the raw input to prevent leaking tokens. + * + * @returns `true` if the user entered `DELETE` exactly (case-sensitive), `false` otherwise. + */ 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(); } } +/** + * Prompt the user to choose a login mode from a simple textual fallback menu. + * + * Displays saved accounts (if any), repeatedly asks for a selection using the fallback prompts, + * and returns the mapped LoginMenuResult for the first recognized command. + * + * @param existingAccounts - Saved account entries displayed to the user to aid selection; used only for presentation and to derive labels/indices. + * @returns The selected LoginMenuResult describing the chosen mode (and `deleteAll: true` for the "fresh/clear" choice when confirmed). + * + * Concurrency: intended to run in a single CLI flow; do not invoke concurrently from multiple tasks/processes. + * Windows behavior: accepts input with CRLF and LF line endings interchangeably. + * Token handling: the function does not persist raw typed input beyond producing the decision result; do not type long-lived secrets into this prompt. async function promptLoginModeFallback(existingAccounts: ExistingAccountInfo[]): Promise { const rl = createInterface({ input, output }); try { @@ -110,21 +194,54 @@ 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 === "f" || normalized === "fresh") return { mode: "fresh", deleteAll: true }; + if (normalized === "b" || normalized === "p" || normalized === "forecast") { + return { mode: "forecast" }; + } + if (normalized === "x" || normalized === "fix") return { mode: "fix" }; + if (normalized === "s" || normalized === "settings" || normalized === "configure") { + return { mode: "settings" }; + } + if (normalized === "f" || normalized === "fresh" || normalized === "clear") { + 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 === "d" || normalized === "deep") { + return { mode: "deep-check" }; + } + if ( + normalized === "g" || + normalized === "flagged" || + normalized === "verify-flagged" || + normalized === "verify" + ) { + 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(); } } +/** + * Presents an interactive login/menu UI and returns the selected login or management action. + * + * Displays an authentication menu when running in a TTY; falls back to a non-TTY prompt flow or returns `{ mode: "add" }` in non-interactive mode. Continues to prompt until the user selects an actionable mode (e.g., add, forecast, check, manage) or cancels. + * + * Concurrency: callers should not invoke this function concurrently from the same process/TTY because it reads from stdin/stdout. It is safe to call again after the returned promise resolves. + * + * Windows filesystem note: menu flow may read/write transient UI state to temporary files on Windows when rendering complex prompts; callers should not rely on persistent files being created. + * + * Token redaction: interactive prompts and UI functions invoked by this routine will not print raw authentication tokens; displayed account information is limited to account labels/IDs and status fields. + * + * @param existingAccounts - List of known accounts to show in the menu (used for selection and account-specific management actions) + * @param options - Optional menu settings; `flaggedCount` influences UI badges and `statusMessage` (string or function) is shown in the menu + * @returns The chosen LoginMenuResult describing the mode to execute and any associated account index or management flags + */ export async function promptLoginMode( existingAccounts: ExistingAccountInfo[], options: LoginMenuOptions = {}, @@ -140,14 +257,21 @@ 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": + return { mode: "settings" }; case "fresh": if (!(await promptDeleteAllTypedConfirm())) { - console.log("\nDelete-all cancelled.\n"); + console.log("\nDelete all cancelled.\n"); continue; } return { mode: "fresh", deleteAll: true }; @@ -160,19 +284,32 @@ export async function promptLoginMode( case "select-account": { const accountAction = await showAccountDetails(action.account); if (accountAction === "delete") { - return { mode: "manage", deleteAccountIndex: action.account.index }; + return { mode: "manage", deleteAccountIndex: resolveAccountSourceIndex(action.account) }; + } + if (accountAction === "set-current") { + return { mode: "manage", switchAccountIndex: resolveAccountSourceIndex(action.account) }; } if (accountAction === "refresh") { - return { mode: "manage", refreshAccountIndex: action.account.index }; + return { mode: "manage", refreshAccountIndex: resolveAccountSourceIndex(action.account) }; } if (accountAction === "toggle") { - return { mode: "manage", toggleAccountIndex: action.account.index }; + return { mode: "manage", toggleAccountIndex: resolveAccountSourceIndex(action.account) }; } continue; } + case "set-current-account": + return { mode: "manage", switchAccountIndex: resolveAccountSourceIndex(action.account) }; + case "refresh-account": + return { mode: "manage", refreshAccountIndex: resolveAccountSourceIndex(action.account) }; + case "toggle-account": + return { mode: "manage", toggleAccountIndex: resolveAccountSourceIndex(action.account) }; + case "delete-account": + return { mode: "manage", deleteAccountIndex: resolveAccountSourceIndex(action.account) }; + 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-cli/state.ts b/lib/codex-cli/state.ts index f605fc4b..ed3487c0 100644 --- a/lib/codex-cli/state.ts +++ b/lib/codex-cli/state.ts @@ -2,6 +2,7 @@ import { existsSync, promises as fs } from "node:fs"; import { homedir } from "node:os"; import { join } from "node:path"; import { decodeJWT } from "../auth/auth.js"; +import { extractAccountEmail, extractAccountId } from "../auth/token-utils.js"; import { createLogger } from "../logger.js"; import { incrementCodexCliMetric, @@ -28,6 +29,8 @@ export interface CodexCliState { accounts: CodexCliAccountSnapshot[]; activeAccountId?: string; activeEmail?: string; + syncVersion?: number; + sourceUpdatedAtMs?: number; } let cache: CodexCliState | null = null; @@ -153,13 +156,61 @@ export function isCodexCliSyncEnabled(): boolean { return true; } +/** + * Resolves the filesystem path to the Codex CLI accounts file. + * + * If the environment variable CODEX_CLI_ACCOUNTS_PATH is set and non-empty, its trimmed value is returned; + * otherwise the default path "/.codex/accounts.json" is returned. + * + * Notes: + * - Concurrency: callers should assume the file may be updated concurrently by external processes and handle + * read/write races accordingly (e.g., by retrying or opening the file atomically). + * - Windows: the returned path will use the platform-specific separator (backslashes on Windows); callers that + * normalize or display the path should account for that. + * - Sensitive data: the accounts file often contains access/refresh tokens; callers must treat the returned path + * as referencing sensitive data and redact token values when logging or exposing file contents. + * + * @returns The resolved path to the Codex CLI accounts.json file. + */ export function getCodexCliAccountsPath(): string { const override = (process.env.CODEX_CLI_ACCOUNTS_PATH ?? "").trim(); if (override.length > 0) return override; return join(homedir(), ".codex", "accounts.json"); } -function parseCodexCliState(path: string, parsed: unknown): CodexCliState | null { +/** + * Resolves the filesystem path for the Codex CLI auth state file. + * + * If the CODEX_CLI_AUTH_PATH environment variable is set and non-empty, its + * trimmed value is returned; otherwise the default is /.codex/auth.json. + * This function is safe to call concurrently and returns a platform-native path + * (Windows separators may be present on Windows). + * + * Note: the resolved path may reference files that contain tokens; callers should + * avoid logging the full path or must redact sensitive values before emitting logs. + * + * @returns The resolved path to the Codex CLI auth.json file + */ +export function getCodexCliAuthPath(): string { + const override = (process.env.CODEX_CLI_AUTH_PATH ?? "").trim(); + if (override.length > 0) return override; + return join(homedir(), ".codex", "auth.json"); +} + +/** + * Parse an accounts-style payload into a CodexCliState snapshot. + * + * @param path - Filesystem path of the source payload (used for diagnostics). Path may be Windows-style; this function does not normalize or access the filesystem and is safe to call with concurrent file changes. + * @param parsed - Parsed JSON payload expected to contain an `accounts` array and optional active account fields; other shapes return `null`. + * @param sourceUpdatedAtMs - Optional source file modification timestamp to record on the returned state. + * @returns A CodexCliState constructed from the payload (including `accounts`, inferred `activeAccountId`/`activeEmail`, `syncVersion`, and optional `sourceUpdatedAtMs`), or `null` if `parsed` is not the expected structure. + * + * Note: returned account snapshots may include raw token values; callers must redact sensitive tokens before logging or exposing the state. +function parseCodexCliState( + path: string, + parsed: unknown, + sourceUpdatedAtMs?: number, +): CodexCliState | null { if (!isRecord(parsed) || !Array.isArray(parsed.accounts)) { return null; } @@ -188,9 +239,102 @@ function parseCodexCliState(path: string, parsed: unknown): CodexCliState | null accounts, activeAccountId, activeEmail, + syncVersion: readNumber(parsed.codexMultiAuthSyncVersion), + sourceUpdatedAtMs, + }; +} + +/** + * Parse an auth.json-style payload into a CodexCliState containing a single account snapshot. + * + * Parses the provided object for an auth.tokens block, extracts access/refresh/id tokens, + * derives accountId and email from tokens or fields, computes token expiry from JWT `exp` + * when available, and returns a CodexCliState with that single active account or `null` + * if the payload does not contain usable token information. + * + * Concurrency: callers may be invoked concurrently; this function is pure and has no shared-state side effects. + * Windows filesystem note: `path` is used only for provenance reporting and is not accessed by this function; + * callers should normalize Windows paths before presenting them to users. + * Token handling: extracted tokens are used only to derive metadata (accountId, email, expiry). + * Tokens returned inside the resulting state are not redacted here — callers responsible for logging must redact secrets. + * + * @param path - Filesystem path used as the source provenance for the parsed payload (e.g., auth.json path) + * @param parsed - Parsed JSON payload from the source file; expected to contain a `tokens` object + * @param sourceUpdatedAtMs - Optional filesystem modification timestamp (ms) of the source file to attach to the returned state + * @returns A CodexCliState containing one active account snapshot derived from the tokens, or `null` if the payload lacks usable tokens + */ +function parseCodexCliAuthState( + path: string, + parsed: unknown, + sourceUpdatedAtMs?: number, +): CodexCliState | null { + if (!isRecord(parsed)) return null; + const tokens = isRecord(parsed.tokens) ? parsed.tokens : null; + if (!tokens) return null; + + const accessToken = extractTokenFromRecord(tokens, ["access_token", "accessToken"]); + const refreshToken = extractTokenFromRecord(tokens, ["refresh_token", "refreshToken"]); + if (!accessToken && !refreshToken) return null; + + const idToken = extractTokenFromRecord(tokens, ["id_token", "idToken"]); + const accountId = + readTrimmedString(tokens.account_id) ?? + readTrimmedString(tokens.accountId) ?? + (accessToken ? extractAccountId(accessToken) : undefined); + const email = + (accessToken ? extractAccountEmail(accessToken, idToken) : undefined) ?? + normalizeEmail(parsed.email); + + let expiresAt: number | undefined = undefined; + if (accessToken) { + const decoded = decodeJWT(accessToken); + const exp = decoded?.exp; + if (typeof exp === "number" && Number.isFinite(exp)) { + expiresAt = exp * 1000; + } + } + + const snapshot: CodexCliAccountSnapshot = { + accountId, + email, + accessToken: accessToken ?? "", + refreshToken, + expiresAt, + isActive: true, + }; + + return { + path, + accounts: [snapshot], + activeAccountId: accountId, + activeEmail: email, + syncVersion: readNumber(parsed.codexMultiAuthSyncVersion), + sourceUpdatedAtMs, }; } +/** + * Load and cache Codex CLI authentication state by reading accounts.json (preferred) or auth.json from disk. + * + * Reads the CODEX CLI state from the configured accounts or auth path, parses it into a CodexCliState, + * and stores a TTL-limited in-memory cache to avoid frequent filesystem reads. If both files exist the + * accounts path is preferred; if parsing fails the other path will be attempted. Use `options.forceRefresh` + * to bypass the cache and re-read from disk. + * + * Concurrency: multiple concurrent callers may race to refresh the cache; this function provides a best-effort, + * in-memory TTL cache and is not synchronized across processes. + * + * Windows filesystem: path overrides via CODEX_CLI_ACCOUNTS_PATH / CODEX_CLI_AUTH_PATH are honored; default + * locations resolve relative to the user's home directory and support Windows path semantics. + * + * Token redaction: logged debug/warning messages do not include raw token values and sensitive token fields + * are not exposed in telemetry or logs. + * + * @param options - Optional behavior flags + * @param options.forceRefresh - If true, bypass the in-memory cache and re-read the source files + * @returns The parsed CodexCliState when a valid state is found, or `null` when sync is disabled, files are missing, + * the payloads are malformed, or an error occurs + */ export async function loadCodexCliState( options?: { forceRefresh?: boolean }, ): Promise { @@ -203,49 +347,93 @@ export async function loadCodexCliState( return cache; } - const path = getCodexCliAccountsPath(); + const accountsPath = getCodexCliAccountsPath(); + const authPath = getCodexCliAuthPath(); incrementCodexCliMetric("readAttempts"); cacheLoadedAt = now; - if (!existsSync(path)) { + const hasAccountsPath = existsSync(accountsPath); + const hasAuthPath = existsSync(authPath); + if (!hasAccountsPath && !hasAuthPath) { incrementCodexCliMetric("readMisses"); cache = null; return null; } try { - const raw = await fs.readFile(path, "utf-8"); - const parsed = JSON.parse(raw) as unknown; - const state = parseCodexCliState(path, parsed); - if (!state) { - incrementCodexCliMetric("readFailures"); + if (hasAccountsPath) { + const raw = await fs.readFile(accountsPath, "utf-8"); + const parsed = JSON.parse(raw) as unknown; + let sourceUpdatedAtMs: number | undefined; + try { + sourceUpdatedAtMs = (await fs.stat(accountsPath)).mtimeMs; + } catch { + sourceUpdatedAtMs = undefined; + } + const state = parseCodexCliState(accountsPath, parsed, sourceUpdatedAtMs); + if (state) { + incrementCodexCliMetric("readSuccesses"); + log.debug("Loaded Codex CLI state", { + operation: "read-state", + outcome: "success", + path: accountsPath, + accountCount: state.accounts.length, + activeAccountRef: makeAccountFingerprint({ + accountId: state.activeAccountId, + email: state.activeEmail, + }), + }); + cache = state; + return state; + } log.warn("Codex CLI accounts payload is malformed", { operation: "read-state", outcome: "malformed", - path, + path: accountsPath, }); - cache = null; - return null; } - incrementCodexCliMetric("readSuccesses"); - log.debug("Loaded Codex CLI state", { - operation: "read-state", - outcome: "success", - path, - accountCount: state.accounts.length, - activeAccountRef: makeAccountFingerprint({ - accountId: state.activeAccountId, - email: state.activeEmail, - }), - }); - cache = state; - return state; + + if (hasAuthPath) { + const raw = await fs.readFile(authPath, "utf-8"); + const parsed = JSON.parse(raw) as unknown; + let sourceUpdatedAtMs: number | undefined; + try { + sourceUpdatedAtMs = (await fs.stat(authPath)).mtimeMs; + } catch { + sourceUpdatedAtMs = undefined; + } + const state = parseCodexCliAuthState(authPath, parsed, sourceUpdatedAtMs); + if (state) { + incrementCodexCliMetric("readSuccesses"); + log.debug("Loaded Codex CLI auth state", { + operation: "read-state", + outcome: "success", + path: authPath, + accountCount: state.accounts.length, + activeAccountRef: makeAccountFingerprint({ + accountId: state.activeAccountId, + email: state.activeEmail, + }), + }); + cache = state; + return state; + } + log.warn("Codex CLI auth payload is malformed", { + operation: "read-state", + outcome: "malformed", + path: authPath, + }); + } + + incrementCodexCliMetric("readFailures"); + cache = null; + return null; } catch (error) { incrementCodexCliMetric("readFailures"); log.warn("Failed to read Codex CLI state", { operation: "read-state", outcome: "error", - path, + path: hasAccountsPath ? accountsPath : authPath, error: String(error), }); cache = null; diff --git a/lib/codex-cli/sync.ts b/lib/codex-cli/sync.ts index edd22f52..ec6c3282 100644 --- a/lib/codex-cli/sync.ts +++ b/lib/codex-cli/sync.ts @@ -1,4 +1,8 @@ -import type { AccountMetadataV3, AccountStorageV3 } from "../storage.js"; +import { + getLastAccountsSaveTimestamp, + type AccountMetadataV3, + type AccountStorageV3, +} from "../storage.js"; import { MODEL_FAMILIES, type ModelFamily } from "../prompts/codex.js"; import { createLogger } from "../logger.js"; import { loadCodexCliState, type CodexCliAccountSnapshot } from "./state.js"; @@ -6,6 +10,7 @@ import { incrementCodexCliMetric, makeAccountFingerprint, } from "./observability.js"; +import { getLastCodexCliSelectionWriteTimestamp } from "./writer.js"; const log = createLogger("codex-cli-sync"); @@ -180,6 +185,12 @@ function normalizeStoredFamilyIndexes(storage: AccountStorageV3): void { } } +/** + * Extract the accountId and email from the first snapshot marked active. + * + * @param snapshots - Array of Codex CLI account snapshots to search + * @returns An object with `accountId` and `email` from the first snapshot where `isActive` is true; properties are `undefined` if no active snapshot is found + */ function readActiveFromSnapshots( snapshots: CodexCliAccountSnapshot[], ): { accountId?: string; email?: string } { @@ -190,6 +201,51 @@ function readActiveFromSnapshots( }; } +/** + * Decide whether Codex CLI's active-account selection should overwrite local selection. + * + * Prefers the source (Codex CLI) selection when its timestamp/version appears at least as recent + * as the local selection write time (with a 1s tolerance); otherwise preserves the local selection. + * + * Concurrency assumptions: compares monotonic millisecond timestamps and assumes host clocks are + * reasonably synchronized; ties favor Codex CLI. On Windows filesystems where timestamp resolution + * may be coarse, the 1s tolerance reduces false negatives. Token-redaction: this decision only + * uses numeric timestamps/versions and never inspects or logs tokens or other sensitive strings. + * + * @param state - Loaded Codex CLI state (may be null/undefined when not present) + * @returns `true` if the Codex CLI selection should be applied (overwrite local), `false` otherwise. + */ +function shouldApplyCodexCliSelection(state: Awaited>): boolean { + if (!state) return false; + const codexVersion = + typeof state.syncVersion === "number" && Number.isFinite(state.syncVersion) + ? state.syncVersion + : typeof state.sourceUpdatedAtMs === "number" && Number.isFinite(state.sourceUpdatedAtMs) + ? state.sourceUpdatedAtMs + : 0; + const localVersion = Math.max( + getLastAccountsSaveTimestamp(), + getLastCodexCliSelectionWriteTimestamp(), + ); + if (codexVersion <= 0 || localVersion <= 0) return true; + // Keep local selection when plugin wrote more recently than Codex state. + return codexVersion >= localVersion - 1_000; +} + +/** + * Reconciles local account storage with Codex CLI state and returns the resulting storage and whether it changed. + * + * Loads Codex CLI state, upserts snapshots into a clone (or new) storage, and conditionally applies the Codex CLI active-account selection based on state vs local timestamps. + * + * Concurrency: callers should serialize invocations to avoid lost updates; the function does not perform inter-process file locking. + * + * Windows filesystem note: this function operates on in-memory storage objects only; any caller that persists the returned storage must handle Windows path and locking semantics. + * + * Token redaction: account snapshots containing tokens are consulted for matching and merging, but this function does not log raw tokens; callers must ensure persisted storage redacts or encrypts sensitive tokens. + * + * @param current - The current local AccountStorageV3, or `null` to start from an empty storage. + * @returns An object with `storage` set to the reconciled AccountStorageV3 (or `null` if input was `null` and no accounts were produced) and `changed` set to `true` if the returned storage differs from `current`. + */ export async function syncAccountStorageFromCodexCli( current: AccountStorageV3 | null, ): Promise<{ storage: AccountStorageV3 | null; changed: boolean }> { @@ -223,21 +279,30 @@ export async function syncAccountStorageFromCodexCli( } const activeFromSnapshots = readActiveFromSnapshots(state.accounts); - const desiredIndex = resolveActiveIndex( - next.accounts, - state.activeAccountId ?? activeFromSnapshots.accountId, - state.activeEmail ?? activeFromSnapshots.email, - ); + const applyActiveFromCodex = shouldApplyCodexCliSelection(state); + if (applyActiveFromCodex) { + const desiredIndex = resolveActiveIndex( + next.accounts, + state.activeAccountId ?? activeFromSnapshots.accountId, + state.activeEmail ?? activeFromSnapshots.email, + ); - const previousActive = next.activeIndex; - const previousFamilies = JSON.stringify(next.activeIndexByFamily ?? {}); - writeFamilyIndexes(next, desiredIndex); - normalizeStoredFamilyIndexes(next); - if (previousActive !== next.activeIndex) { - changed = true; - } - if (previousFamilies !== JSON.stringify(next.activeIndexByFamily ?? {})) { - changed = true; + const previousActive = next.activeIndex; + const previousFamilies = JSON.stringify(next.activeIndexByFamily ?? {}); + writeFamilyIndexes(next, desiredIndex); + normalizeStoredFamilyIndexes(next); + if (previousActive !== next.activeIndex) { + changed = true; + } + if (previousFamilies !== JSON.stringify(next.activeIndexByFamily ?? {})) { + changed = true; + } + } else { + normalizeStoredFamilyIndexes(next); + log.debug("Skipped Codex CLI active selection overwrite due to newer local state", { + operation: "reconcile-storage", + outcome: "local-newer", + }); } incrementCodexCliMetric(changed ? "reconcileChanges" : "reconcileNoops"); diff --git a/lib/codex-cli/writer.ts b/lib/codex-cli/writer.ts index 18a16820..0acf59f4 100644 --- a/lib/codex-cli/writer.ts +++ b/lib/codex-cli/writer.ts @@ -1,29 +1,104 @@ import { existsSync, promises as fs } from "node:fs"; import { dirname } from "node:path"; import { createLogger } from "../logger.js"; -import { clearCodexCliStateCache, getCodexCliAccountsPath, isCodexCliSyncEnabled } from "./state.js"; +import { + clearCodexCliStateCache, + getCodexCliAccountsPath, + getCodexCliAuthPath, + isCodexCliSyncEnabled, +} from "./state.js"; import { incrementCodexCliMetric, makeAccountFingerprint, } from "./observability.js"; const log = createLogger("codex-cli-writer"); +let lastCodexCliSelectionWriteAt = 0; interface ActiveSelection { accountId?: string; email?: string; + accessToken?: string; + refreshToken?: string; + expiresAt?: number; + idToken?: string; } +/** + * Determines whether a value is a plain object (non-null and not an array). + * + * @returns `true` if `value` is an object and not `null` or an array, `false` otherwise. + */ function isRecord(value: unknown): value is Record { return !!value && typeof value === "object" && !Array.isArray(value); } +/** + * Produce a trimmed non-empty string from an input value. + * + * @param value - The value to normalize; if it's a string, leading and trailing whitespace are removed. + * @returns `string` if `value` is a string containing non-whitespace characters after trimming, `undefined` otherwise. + */ +function readTrimmedString(value: unknown): string | undefined { + if (typeof value !== "string") return undefined; + const trimmed = value.trim(); + return trimmed.length > 0 ? trimmed : undefined; +} + +/** + * Parses a value and returns it as a finite number when possible. + * + * @param value - The input to parse; may be a number or a numeric string. + * @returns The finite numeric value represented by `value`, or `undefined` if it cannot be parsed as a finite number. + */ +function readNumber(value: unknown): number | undefined { + if (typeof value === "number" && Number.isFinite(value)) return value; + if (typeof value === "string") { + const parsed = Number(value); + if (Number.isFinite(parsed)) return parsed; + } + return undefined; +} + +/** + * Extracts the first non-empty trimmed string value from `record` using the provided `keys`. + * + * @param record - Plain object to read values from. + * @param keys - Ordered list of property names to check on `record`. + * @returns `string` with the first found non-empty trimmed value, `undefined` if none found. + * + * Concurrency: pure and side-effect-free; safe for concurrent use. Windows filesystem: no filesystem interaction. + * Security: returned token is raw credential material and MUST be redacted before logging or emitting to telemetry. + */ +function extractTokenFromRecord( + record: Record, + keys: string[], +): string | undefined { + for (const key of keys) { + const token = readTrimmedString(record[key]); + if (token) return token; + } + return undefined; +} + +/** + * Normalize an email-like string by trimming whitespace and converting to lowercase. + * + * @param value - The input value to normalize; only string inputs are processed. + * @returns The trimmed, lowercased string if `value` is a non-empty string after trimming, `undefined` otherwise. + */ function normalizeEmail(value: unknown): string | undefined { if (typeof value !== "string") return undefined; const trimmed = value.trim().toLowerCase(); return trimmed.length > 0 ? trimmed : undefined; } +/** + * Extracts the first non-empty account identifier from a record by checking common field names. + * + * @param record - Object to search for an account identifier + * @returns The trimmed identifier from the first matching key, or `undefined` if none found + */ function readAccountId(record: Record): string | undefined { const keys = ["accountId", "account_id", "workspace_id", "organization_id", "id"]; for (const key of keys) { @@ -35,6 +110,65 @@ function readAccountId(record: Record): string | undefined { return undefined; } +/** + * Build an ActiveSelection by extracting account id, email, tokens, and expiration from a possibly nested account record. + * + * This function reads common field variants (camelCase and snake_case) and favors top-level values over nested auth.tokens. + * It performs normalization for email and numeric parsing for expiration but does not mutate the input. + * + * Concurrency: pure and synchronous — safe to call concurrently from multiple threads/tasks. + * Filesystem: performs no I/O and has no Windows-specific filesystem behavior. + * Token handling: extracted tokens are returned unmodified; callers must redact or treat them as sensitive before logging or persisting. + * + * @param record - A plain object representing an account entry; may contain nested `auth.tokens`. + * @returns An ActiveSelection containing any of the following if found: `accountId`, `email`, `accessToken`, `refreshToken`, `idToken`, and `expiresAt` (milliseconds timestamp). + */ +function extractSelectionFromAccountRecord(record: Record): ActiveSelection { + const auth = isRecord(record.auth) ? record.auth : undefined; + const tokens = auth && isRecord(auth.tokens) ? auth.tokens : undefined; + + const accessToken = + extractTokenFromRecord(record, ["accessToken", "access_token"]) ?? + (tokens ? extractTokenFromRecord(tokens, ["access_token", "accessToken"]) : undefined); + const refreshToken = + extractTokenFromRecord(record, ["refreshToken", "refresh_token"]) ?? + (tokens ? extractTokenFromRecord(tokens, ["refresh_token", "refreshToken"]) : undefined); + const accountId = + readAccountId(record) ?? + (tokens ? readTrimmedString(tokens.account_id) ?? readTrimmedString(tokens.accountId) : undefined); + const idToken = + extractTokenFromRecord(record, ["idToken", "id_token"]) ?? + (tokens ? extractTokenFromRecord(tokens, ["id_token", "idToken"]) : undefined); + const email = + normalizeEmail(record.email) ?? + normalizeEmail(record.user_email) ?? + normalizeEmail(record.username); + const expiresAt = + readNumber(record.expiresAt) ?? + readNumber(record.expires_at) ?? + (tokens ? readNumber(tokens.expires_at) : undefined); + + return { + accountId, + email, + accessToken, + refreshToken, + expiresAt, + idToken, + }; +} + +/** + * Find the index of an account in `accounts` that matches the given `selection` by account ID or normalized email. + * + * @param accounts - Array of account entries; entries may be arbitrary values (non-records are skipped). + * @param selection - ActiveSelection containing optional `accountId` and/or `email` used for matching. + * @returns The index of the matching account in `accounts`, or `-1` if no match is found. + * + * Notes: + * - Concurrency: callers should serialize concurrent updates that rely on this index to avoid races. + * - Filesystem/Windows: callers that persist results should handle Windows atomic-rename semantics separately. + * - Token redaction: this function only matches identifiers and emails; it does not read or redact tokens. function resolveMatchIndex( accounts: unknown[], selection: ActiveSelection, @@ -61,101 +195,293 @@ function resolveMatchIndex( return -1; } +/** + * Convert a millisecond epoch value to an ISO 8601 timestamp string. + * + * @param ms - Milliseconds since Unix epoch; if not a finite number greater than 0, the current time is used + * @returns An ISO 8601 formatted timestamp string corresponding to `ms` when valid, otherwise the current time + */ +function toIsoTime(ms: number | undefined): string { + if (typeof ms === "number" && Number.isFinite(ms) && ms > 0) { + return new Date(ms).toISOString(); + } + return new Date().toISOString(); +} + +/** + * Persist the provided ActiveSelection into the Codex CLI auth state file, merging with existing state. + * + * Merges incoming token/email/account fields with any existing auth state, enforces presence of both + * access and refresh tokens, updates token fields (access_token, refresh_token, id_token, account_id), + * sets `auth_mode` (defaulting to "chatgpt"), `OPENAI_API_KEY` to null, `last_refresh` to the selection's + * expiry, and stamps `codexMultiAuthSyncVersion`. Writes atomically via a temporary file and sets file + * permissions to 0o600 on supported platforms. + * + * Concurrency and platform notes: + * - The function performs an atomic rename of a temp file to the target path; callers must still coordinate + * concurrent writers at a higher level to avoid lost updates. + * - On Windows, file permission semantics differ; the mode hint may not be fully enforced by the OS. + * + * Token handling: + * - Provided token strings are trimmed; existing token values are preserved when not overridden. + * - `id_token` falls back to the access token if not explicitly provided. + * - Sensitive tokens are written to disk; callers should treat the target file as secret and the function + * will set restrictive file mode where supported. + * + * @param path - Filesystem path to the auth state JSON file to update. + * @param selection - ActiveSelection containing optional tokens, accountId, email, and expiresAt to persist. + * @returns `true` if the auth state file was successfully written and tokens persisted, `false` otherwise. + */ +async function writeCodexAuthState( + path: string, + selection: ActiveSelection, +): Promise { + const raw = existsSync(path) ? await fs.readFile(path, "utf-8") : "{}"; + const parsed = JSON.parse(raw) as unknown; + if (!isRecord(parsed)) { + log.warn("Failed to persist Codex auth selection", { + operation: "write-active-selection", + outcome: "malformed-auth-state", + path, + }); + return false; + } + + const existingTokens = isRecord(parsed.tokens) ? parsed.tokens : {}; + const next = { ...parsed } as Record; + const nextTokens = { ...existingTokens } as Record; + + const syncVersion = Date.now(); + const selectedAccessToken = readTrimmedString(selection.accessToken); + const selectedRefreshToken = readTrimmedString(selection.refreshToken); + const accessToken = + selectedAccessToken ?? + (typeof existingTokens.access_token === "string" ? existingTokens.access_token : undefined); + const refreshToken = + selectedRefreshToken ?? + (typeof existingTokens.refresh_token === "string" ? existingTokens.refresh_token : undefined); + + if (!accessToken || !refreshToken) { + log.warn("Failed to persist Codex auth selection", { + operation: "write-active-selection", + outcome: "missing-token-payload", + path, + accountRef: makeAccountFingerprint({ + accountId: selection.accountId, + email: selection.email, + }), + }); + return false; + } + + next.auth_mode = typeof parsed.auth_mode === "string" ? parsed.auth_mode : "chatgpt"; + next.OPENAI_API_KEY = null; + const selectedEmail = normalizeEmail(selection.email); + if (selectedEmail) { + next.email = selectedEmail; + } + nextTokens.access_token = accessToken; + nextTokens.refresh_token = refreshToken; + const resolvedIdToken = + readTrimmedString(selection.idToken) ?? + accessToken; + nextTokens.id_token = resolvedIdToken; + if (selection.accountId?.trim()) { + nextTokens.account_id = selection.accountId.trim(); + } + next.tokens = nextTokens; + next.last_refresh = toIsoTime(selection.expiresAt); + next.codexMultiAuthSyncVersion = syncVersion; + + const tempPath = `${path}.${Date.now()}.${Math.random().toString(36).slice(2, 8)}.tmp`; + await fs.mkdir(dirname(path), { recursive: true }); + await fs.writeFile(tempPath, JSON.stringify(next, null, 2), { + encoding: "utf-8", + mode: 0o600, + }); + await fs.rename(tempPath, path); + lastCodexCliSelectionWriteAt = syncVersion; + return true; +} + +/** + * Persist the provided active selection to Codex CLI storage (accounts and/or auth) and update in-memory state. + * + * Attempts to write the given selection to the Codex CLI accounts file and/or the auth state file depending on which paths exist. + * When writing accounts, the matching account entry will be marked active and top-level active identifiers will be updated. + * When writing auth, tokens and related metadata are merged with existing auth state and validated before persisting. + * + * Concurrency and filesystem notes: + * - Writes are performed via atomic tempfile->rename semantics; callers should expect eventual consistency if concurrent writers run. + * - On Windows the atomic rename behavior depends on platform semantics; temporary file and rename steps are used to minimize partial-write visibility. + * + * Token handling and redaction: + * - Access, refresh, and id tokens are merged and validated; missing required tokens cause the auth write to fail. + * - Logs and metrics avoid emitting raw tokens; any token-related fingerprinting uses redacted identifiers. + * + * @param selection - Partial or complete ActiveSelection describing the desired active account and tokens. Fields provided override values read from storage; missing fields are filled from matched account records when available. + * @returns `true` if at least one storage path was successfully updated, `false` otherwise. + */ export async function setCodexCliActiveSelection( selection: ActiveSelection, ): Promise { if (!isCodexCliSyncEnabled()) return false; - const path = getCodexCliAccountsPath(); - if (!existsSync(path)) return false; incrementCodexCliMetric("writeAttempts"); + const accountsPath = getCodexCliAccountsPath(); + const authPath = getCodexCliAuthPath(); + const hasAccountsPath = existsSync(accountsPath); + const hasAuthPath = existsSync(authPath); + + if (!hasAccountsPath && !hasAuthPath) { + incrementCodexCliMetric("writeFailures"); + return false; + } try { - const raw = await fs.readFile(path, "utf-8"); - const parsed = JSON.parse(raw) as unknown; - if (!isRecord(parsed) || !Array.isArray(parsed.accounts)) { - incrementCodexCliMetric("writeFailures"); - log.warn("Failed to persist Codex CLI active selection", { - operation: "write-active-selection", - outcome: "malformed", - path, - }); - return false; - } + let resolvedSelection: ActiveSelection = { ...selection }; + let wroteAccounts = false; + let wroteAuth = false; - const matchIndex = resolveMatchIndex(parsed.accounts, selection); - if (matchIndex < 0) { - incrementCodexCliMetric("writeFailures"); - log.warn("Failed to persist Codex CLI active selection", { - operation: "write-active-selection", - outcome: "no-match", - path, - accountRef: makeAccountFingerprint({ - accountId: selection.accountId, - email: selection.email, - }), - }); - return false; - } + if (hasAccountsPath) { + const raw = await fs.readFile(accountsPath, "utf-8"); + const parsed = JSON.parse(raw) as unknown; + if (!isRecord(parsed) || !Array.isArray(parsed.accounts)) { + log.warn("Failed to persist Codex CLI active selection", { + operation: "write-active-selection", + outcome: "malformed", + path: accountsPath, + }); + } else { + const matchIndex = resolveMatchIndex(parsed.accounts, selection); + if (matchIndex < 0) { + log.warn("Failed to persist Codex CLI active selection", { + operation: "write-active-selection", + outcome: "no-match", + path: accountsPath, + accountRef: makeAccountFingerprint({ + accountId: selection.accountId, + email: selection.email, + }), + }); + if (!hasAuthPath) { + incrementCodexCliMetric("writeFailures"); + return false; + } + } else { + const chosen = parsed.accounts[matchIndex]; + if (!isRecord(chosen)) { + log.warn("Failed to persist Codex CLI active selection", { + operation: "write-active-selection", + outcome: "invalid-account-record", + path: accountsPath, + }); + if (!hasAuthPath) { + incrementCodexCliMetric("writeFailures"); + return false; + } + } else { + const chosenSelection = extractSelectionFromAccountRecord(chosen); + resolvedSelection = { + ...resolvedSelection, + accountId: resolvedSelection.accountId ?? chosenSelection.accountId, + email: resolvedSelection.email ?? chosenSelection.email, + accessToken: resolvedSelection.accessToken ?? chosenSelection.accessToken, + refreshToken: resolvedSelection.refreshToken ?? chosenSelection.refreshToken, + expiresAt: resolvedSelection.expiresAt ?? chosenSelection.expiresAt, + idToken: resolvedSelection.idToken ?? chosenSelection.idToken, + }; - const chosen = parsed.accounts[matchIndex]; - if (!isRecord(chosen)) { - incrementCodexCliMetric("writeFailures"); - log.warn("Failed to persist Codex CLI active selection", { - operation: "write-active-selection", - outcome: "invalid-account-record", - path, - }); - return false; - } + const next = { ...parsed }; + const syncVersion = Date.now(); + const chosenAccountId = readAccountId(chosen) ?? selection.accountId?.trim(); + const chosenEmail = normalizeEmail(chosen.email) ?? normalizeEmail(selection.email); + + if (chosenAccountId) { + next.activeAccountId = chosenAccountId; + next.active_account_id = chosenAccountId; + } + if (chosenEmail) { + next.activeEmail = chosenEmail; + next.active_email = chosenEmail; + } - const next = { ...parsed }; - const chosenAccountId = readAccountId(chosen) ?? selection.accountId?.trim(); - const chosenEmail = normalizeEmail(chosen.email) ?? normalizeEmail(selection.email); + next.accounts = parsed.accounts.map((entry, index) => { + if (!isRecord(entry)) return entry; + const updated = { ...entry }; + updated.active = index === matchIndex; + updated.isActive = index === matchIndex; + updated.is_active = index === matchIndex; + return updated; + }); + next.codexMultiAuthSyncVersion = syncVersion; - if (chosenAccountId) { - next.activeAccountId = chosenAccountId; - next.active_account_id = chosenAccountId; + const tempPath = `${accountsPath}.${Date.now()}.${Math.random().toString(36).slice(2, 8)}.tmp`; + await fs.mkdir(dirname(accountsPath), { recursive: true }); + await fs.writeFile(tempPath, JSON.stringify(next, null, 2), { + encoding: "utf-8", + mode: 0o600, + }); + await fs.rename(tempPath, accountsPath); + lastCodexCliSelectionWriteAt = syncVersion; + wroteAccounts = true; + log.debug("Persisted Codex CLI accounts selection", { + operation: "write-active-selection", + outcome: "success", + path: accountsPath, + accountRef: makeAccountFingerprint({ + accountId: chosenAccountId, + email: chosenEmail, + }), + }); + } + } + } } - if (chosenEmail) { - next.activeEmail = chosenEmail; - next.active_email = chosenEmail; + + if (hasAuthPath) { + wroteAuth = await writeCodexAuthState(authPath, resolvedSelection); + if (!wroteAuth) { + if (!wroteAccounts) { + incrementCodexCliMetric("writeFailures"); + return false; + } + log.warn("Codex auth state update skipped after accounts selection update", { + operation: "write-active-selection", + outcome: "accounts-updated-auth-failed", + path: authPath, + accountRef: makeAccountFingerprint({ + accountId: resolvedSelection.accountId, + email: resolvedSelection.email, + }), + }); + } else { + log.debug("Persisted Codex auth active selection", { + operation: "write-active-selection", + outcome: "success", + path: authPath, + accountRef: makeAccountFingerprint({ + accountId: resolvedSelection.accountId, + email: resolvedSelection.email, + }), + }); + } } - next.accounts = parsed.accounts.map((entry, index) => { - if (!isRecord(entry)) return entry; - const updated = { ...entry }; - updated.active = index === matchIndex; - updated.isActive = index === matchIndex; - updated.is_active = index === matchIndex; - return updated; - }); + if (wroteAccounts || wroteAuth) { + clearCodexCliStateCache(); + incrementCodexCliMetric("writeSuccesses"); + return true; + } - const tempPath = `${path}.${Date.now()}.${Math.random().toString(36).slice(2, 8)}.tmp`; - await fs.mkdir(dirname(path), { recursive: true }); - await fs.writeFile(tempPath, JSON.stringify(next, null, 2), { - encoding: "utf-8", - mode: 0o600, - }); - await fs.rename(tempPath, path); - clearCodexCliStateCache(); - incrementCodexCliMetric("writeSuccesses"); - log.debug("Persisted Codex CLI active selection", { - operation: "write-active-selection", - outcome: "success", - path, - accountRef: makeAccountFingerprint({ - accountId: chosenAccountId, - email: chosenEmail, - }), - }); - return true; + incrementCodexCliMetric("writeFailures"); + return false; } catch (error) { incrementCodexCliMetric("writeFailures"); log.warn("Failed to persist Codex CLI active selection", { operation: "write-active-selection", outcome: "error", - path, + path: hasAccountsPath ? accountsPath : authPath, accountRef: makeAccountFingerprint({ accountId: selection.accountId, email: selection.email, @@ -165,3 +491,18 @@ export async function setCodexCliActiveSelection( return false; } } + +/** + * Returns the timestamp of the last successful Codex CLI active-selection write attempt. + * + * This value is updated when the library successfully writes accounts or auth state to disk. + * Concurrency assumption: multiple processes may race; this value reflects only in-process updates + * performed by this runtime and is not a cross-process lock. On Windows the underlying write uses + * a temp-file-then-rename pattern which may behave differently across processes. The timestamp + * contains only a millisecond epoch and does not include any tokens or sensitive data. + * + * @returns The last successful write time as milliseconds since the Unix epoch, or `0` if no write has completed. + */ +export function getLastCodexCliSelectionWriteTimestamp(): number { + return lastCodexCliSelectionWriteAt; +} diff --git a/lib/config.ts b/lib/config.ts index 3efadbec..f0c86a43 100644 --- a/lib/config.ts +++ b/lib/config.ts @@ -1,13 +1,27 @@ -import { readFileSync, existsSync } from "node:fs"; -import { join } from "node:path"; -import { homedir } from "node:os"; +import { readFileSync, existsSync, promises as fs } from "node:fs"; +import { dirname, join } from "node:path"; import type { PluginConfig } from "./types.js"; import { logWarn } from "./logger.js"; import { PluginConfigSchema, getValidationErrors } from "./schemas.js"; - -const CONFIG_DIR = join(homedir(), ".opencode"); -const CONFIG_PATH = join(CONFIG_DIR, "codex-multi-auth-config.json"); -const LEGACY_CONFIG_PATH = join(CONFIG_DIR, "openai-codex-auth-config.json"); +import { getCodexHomeDir, getCodexMultiAuthDir, getLegacyOpenCodeDir } from "./runtime-paths.js"; +import { + getUnifiedSettingsPath, + loadUnifiedPluginConfigSync, + saveUnifiedPluginConfig, + saveUnifiedPluginConfigSync, +} from "./unified-settings.js"; + +const CONFIG_DIR = getCodexMultiAuthDir(); +const CONFIG_PATH = join(CONFIG_DIR, "config.json"); +const LEGACY_CODEX_CONFIG_PATH = join(getCodexHomeDir(), "codex-multi-auth-config.json"); +const LEGACY_OPENCODE_CONFIG_PATH = join( + getLegacyOpenCodeDir(), + "codex-multi-auth-config.json", +); +const LEGACY_OPENCODE_AUTH_CONFIG_PATH = join( + getLegacyOpenCodeDir(), + "openai-codex-auth-config.json", +); const TUI_COLOR_PROFILES = new Set(["truecolor", "ansi16", "ansi256"]); const TUI_GLYPH_MODES = new Set(["ascii", "unicode", "auto"]); const UNSUPPORTED_CODEX_POLICIES = new Set(["strict", "fallback"]); @@ -27,6 +41,16 @@ export function __resetConfigWarningCacheForTests(): void { emittedConfigWarnings.clear(); } +/** + * Determine the active plugin configuration file path, preferring an explicit environment override, then the unified config path, then legacy locations. + * + * @returns The resolved config file path, or `null` when no config file is present. + * + * @remarks + * - If `CODEX_MULTI_AUTH_CONFIG_PATH` is set and non-empty, that path is returned as-is. + * - When a legacy path is selected a single warning is emitted (the warning will not include sensitive token values). + * - The function only checks existence; it does not read or write files. Concurrent callers may observe the same result but external filesystem changes can affect later calls. + * - On Windows, filesystem case-sensitivity follows the OS/filesystem semantics and may affect path resolution. function resolvePluginConfigPath(): string | null { const envPath = (process.env.CODEX_MULTI_AUTH_CONFIG_PATH ?? "").trim(); if (envPath.length > 0) { @@ -37,12 +61,28 @@ function resolvePluginConfigPath(): string | null { return CONFIG_PATH; } - if (existsSync(LEGACY_CONFIG_PATH)) { + if (existsSync(LEGACY_CODEX_CONFIG_PATH)) { logConfigWarnOnce( - `Using legacy config path ${LEGACY_CONFIG_PATH}. ` + + `Using legacy config path ${LEGACY_CODEX_CONFIG_PATH}. ` + `Please migrate to ${CONFIG_PATH}.`, ); - return LEGACY_CONFIG_PATH; + return LEGACY_CODEX_CONFIG_PATH; + } + + if (existsSync(LEGACY_OPENCODE_CONFIG_PATH)) { + logConfigWarnOnce( + `Using legacy OpenCode config path ${LEGACY_OPENCODE_CONFIG_PATH}. ` + + `Please migrate to ${CONFIG_PATH}.`, + ); + return LEGACY_OPENCODE_CONFIG_PATH; + } + + if (existsSync(LEGACY_OPENCODE_AUTH_CONFIG_PATH)) { + logConfigWarnOnce( + `Using legacy OpenCode config path ${LEGACY_OPENCODE_AUTH_CONFIG_PATH}. ` + + `Please migrate to ${CONFIG_PATH}.`, + ); + return LEGACY_OPENCODE_AUTH_CONFIG_PATH; } return null; @@ -52,7 +92,7 @@ function resolvePluginConfigPath(): string | null { * Default plugin configuration * CODEX_MODE is enabled by default for better Codex CLI parity */ -const DEFAULT_CONFIG: PluginConfig = { +export const DEFAULT_PLUGIN_CONFIG: PluginConfig = { codexMode: true, codexTuiV2: true, codexTuiColorProfile: "truecolor", @@ -80,25 +120,71 @@ const DEFAULT_CONFIG: PluginConfig = { pidOffsetEnabled: false, fetchTimeoutMs: 60_000, streamStallTimeoutMs: 45_000, + liveAccountSync: true, + liveAccountSyncDebounceMs: 250, + liveAccountSyncPollMs: 2_000, + sessionAffinity: true, + sessionAffinityTtlMs: 20 * 60_000, + sessionAffinityMaxEntries: 512, + proactiveRefreshGuardian: true, + proactiveRefreshIntervalMs: 60_000, + proactiveRefreshBufferMs: 5 * 60_000, + networkErrorCooldownMs: 6_000, + serverErrorCooldownMs: 4_000, + storageBackupEnabled: true, + preemptiveQuotaEnabled: true, + preemptiveQuotaRemainingPercent5h: 5, + preemptiveQuotaRemainingPercent7d: 5, + preemptiveQuotaMaxDeferralMs: 2 * 60 * 60_000, }; /** - * Load plugin configuration from ~/.opencode/codex-multi-auth-config.json - * (with compatibility fallback to ~/.opencode/openai-codex-auth-config.json) - * Falls back to defaults if file doesn't exist or is invalid + * Provides a shallow copy of the default plugin configuration. + * + * The returned object is a shallow clone of DEFAULT_PLUGIN_CONFIG; nested objects are shared with the original, so avoid mutating nested fields concurrently. When this configuration is persisted, platform filesystem semantics (including Windows path normalization and permissions) apply. The configuration may contain sensitive tokens or secrets — ensure persistence and logging paths perform appropriate redaction. * - * @returns Plugin configuration + * @returns A shallow copy of the default PluginConfig suitable as a baseline configuration + */ +export function getDefaultPluginConfig(): PluginConfig { + return { ...DEFAULT_PLUGIN_CONFIG }; +} + +/** + * Load and return the effective plugin configuration by merging any user-provided settings with defaults. + * + * Loads configuration from the unified settings store if present; otherwise reads from the active config file + * (env override, legacy locations, or CONFIG_PATH), validates the result, emits up to three validation warnings, + * and attempts to migrate file-based configs into the unified settings store when appropriate. If loading fails + * or no user configuration is available, returns a copy of the default plugin configuration. + * + * Notes: + * - Concurrency: callers should avoid concurrently writing the same config file from multiple processes; this + * function performs only reads (and a best-effort migration write) and does not provide cross-process locking. + * - Windows filesystems: path resolution and migration use OS paths; callers on Windows should ensure environment + * variables and file permissions allow reading/writing the resolved config paths. + * - Token redaction: sensitive token values present in user config are preserved in the returned object but any + * warnings or logged messages will not print token values. + * + * @returns The merged PluginConfig (defaults overridden by any validated user settings) */ export function loadPluginConfig(): PluginConfig { try { - const configPath = resolvePluginConfigPath(); - if (!configPath) { - return DEFAULT_CONFIG; + const unifiedConfig = loadUnifiedPluginConfigSync(); + let userConfig: unknown = unifiedConfig; + let sourceKind: "unified" | "file" = "unified"; + + if (!isRecord(userConfig)) { + const configPath = resolvePluginConfigPath(); + if (!configPath) { + return { ...DEFAULT_PLUGIN_CONFIG }; + } + + const fileContent = readFileSync(configPath, "utf-8"); + const normalizedFileContent = stripUtf8Bom(fileContent); + userConfig = JSON.parse(normalizedFileContent) as unknown; + sourceKind = "file"; } - const fileContent = readFileSync(configPath, "utf-8"); - const normalizedFileContent = stripUtf8Bom(fileContent); - const userConfig = JSON.parse(normalizedFileContent) as unknown; const hasFallbackEnvOverride = process.env.CODEX_AUTH_FALLBACK_UNSUPPORTED_MODEL !== undefined || process.env.CODEX_AUTH_FALLBACK_GPT53_TO_GPT52 !== undefined; @@ -123,8 +209,24 @@ export function loadPluginConfig(): PluginConfig { ); } + if ( + sourceKind === "file" && + isRecord(userConfig) && + (process.env.CODEX_MULTI_AUTH_CONFIG_PATH ?? "").trim().length === 0 + ) { + try { + saveUnifiedPluginConfigSync(userConfig); + } catch (error) { + logConfigWarnOnce( + `Failed to migrate plugin config into ${getUnifiedSettingsPath()}: ${ + error instanceof Error ? error.message : String(error) + }`, + ); + } + } + return { - ...DEFAULT_CONFIG, + ...DEFAULT_PLUGIN_CONFIG, ...(userConfig as Partial), }; } catch (error) { @@ -132,7 +234,7 @@ export function loadPluginConfig(): PluginConfig { logConfigWarnOnce( `Failed to load config from ${configPath}: ${(error as Error).message}`, ); - return DEFAULT_CONFIG; + return { ...DEFAULT_PLUGIN_CONFIG }; } } @@ -140,10 +242,104 @@ function stripUtf8Bom(content: string): string { return content.charCodeAt(0) === 0xfeff ? content.slice(1) : content; } +/** + * Checks whether a value is a non-null object. + * + * This is a pure, synchronous predicate (safe for concurrent use); it does not perform any filesystem + * operations or token redaction and is unaffected by Windows filesystem semantics. + * + * @param value - The value to test + * @returns `true` if `value` is an object and not `null`, `false` otherwise + */ function isRecord(value: unknown): value is Record { return value !== null && typeof value === "object"; } +/** + * Reads and parses a JSON configuration file at the given filesystem path and returns it as an object, or `null` if the file is missing, invalid, or not a plain object. + * + * This function performs a synchronous file read. It does not handle concurrent writers — callers should retry or coordinate if concurrent updates are possible. On Windows, file locks can cause reads to fail; such failures will result in `null` and a single warning being emitted. + * + * The function logs only the file path and the error message on failure; file contents (including any sensitive tokens) are not logged or emitted. + * + * @param configPath - Absolute or relative filesystem path to the JSON config file + * @returns The parsed config as a plain object if present and valid, `null` otherwise + */ +function readConfigRecordFromPath(configPath: string): Record | null { + if (!existsSync(configPath)) return null; + try { + const fileContent = readFileSync(configPath, "utf-8"); + const normalizedFileContent = stripUtf8Bom(fileContent); + const parsed = JSON.parse(normalizedFileContent) as unknown; + return isRecord(parsed) ? parsed : null; + } catch (error) { + logConfigWarnOnce( + `Failed to read config from ${configPath}: ${ + error instanceof Error ? error.message : String(error) + }`, + ); + return null; + } +} + +/** + * Prepare a partial PluginConfig for persistence by removing undefined values and non-finite numbers and shallow-cloning nested objects. + * + * This function is synchronous and side-effect free; callers are responsible for concurrency control when writing the returned object to disk and for any Windows-specific filesystem semantics. It does not redact or mask secrets or tokens — sensitive values must be removed or redacted by the caller before persisting. + * + * @param config - The partial plugin configuration to sanitize; entries with `undefined` values or non-finite numbers are omitted, and nested objects are copied shallowly to avoid retaining references to the original. + * @returns A plain record suitable for JSON serialization containing only the sanitized configuration entries. + */ +function sanitizePluginConfigForSave(config: Partial): Record { + const entries = Object.entries(config as Record); + const sanitized: Record = {}; + for (const [key, value] of entries) { + if (value === undefined) continue; + if (typeof value === "number" && !Number.isFinite(value)) continue; + if (isRecord(value)) { + sanitized[key] = { ...value }; + continue; + } + sanitized[key] = value; + } + return sanitized; +} + +/** + * Persists a partial plugin configuration by merging it with existing configuration and saving to the active storage. + * + * If the CODEX_MULTI_AUTH_CONFIG_PATH environment variable is set, the patch is merged with the JSON at that path (or creates a new file) and written directly to disk. Otherwise the patch is merged with the unified settings store and saved via the unified settings API; when a legacy file exists and no unified config is present, the legacy file is used as the merge base. + * + * Concurrency: callers should assume concurrent invocations can overwrite each other (last write wins); external synchronization is required to avoid lost updates. Files are written with a simple write operation and are not guaranteed atomic across platforms (particularly on Windows). + * + * Token and secret handling: this function does not redact or transform sensitive values — callers must avoid passing secrets or ensure they are redacted before calling. + * + * @param configPatch - Partial plugin configuration to persist. Entries with `undefined` or non-finite numeric values are omitted before saving. + * @returns Promise that resolves when the save operation completes. + */ +export async function savePluginConfig(configPatch: Partial): Promise { + const sanitizedPatch = sanitizePluginConfigForSave(configPatch); + const envPath = (process.env.CODEX_MULTI_AUTH_CONFIG_PATH ?? "").trim(); + + if (envPath.length > 0) { + const merged = { + ...(readConfigRecordFromPath(envPath) ?? {}), + ...sanitizedPatch, + }; + await fs.mkdir(dirname(envPath), { recursive: true }); + await fs.writeFile(envPath, `${JSON.stringify(merged, null, 2)}\n`, "utf8"); + return; + } + + const unifiedConfig = loadUnifiedPluginConfigSync(); + const legacyPath = unifiedConfig ? null : resolvePluginConfigPath(); + const merged = { + ...(unifiedConfig ?? (legacyPath ? readConfigRecordFromPath(legacyPath) : null) ?? {}), + ...sanitizedPatch, + }; + await saveUnifiedPluginConfig(merged); +} + /** * Get the effective CODEX_MODE setting * Priority: environment variable > config file > default (true) @@ -179,22 +375,43 @@ function resolveBooleanSetting( return configValue ?? defaultValue; } +/** + * Resolve a numeric setting using environment override, then config value, then default, and clamp it within optional bounds. + * + * @param envName - Environment variable name checked first (if present and numeric, it overrides other sources) + * @param configValue - Value from the plugin configuration used when the env var is absent + * @param defaultValue - Fallback value used when neither env nor config provide a numeric value + * @param options - Optional bounds to enforce; `min` and `max` are inclusive + * @returns The resolved number, clamped to the provided `min`/`max` bounds + * + * Concurrency: callers may invoke this concurrently; the function is pure and has no shared state. + * Windows filesystem: unrelated to filesystem semantics. + * Token redaction: this function does not log or expose environment values; callers should redact secrets when logging `envName`. + */ function resolveNumberSetting( envName: string, configValue: number | undefined, defaultValue: number, - options?: { min?: number }, + options?: { min?: number; max?: number }, ): number { const envValue = parseNumberEnv(process.env[envName]); const candidate = envValue ?? configValue ?? defaultValue; - const min = options?.min; - if (min !== undefined) { - return Math.max(min, candidate); - } - // istanbul ignore next -- dead code: all callers pass { min: ... } - return candidate; + const min = options?.min ?? Number.NEGATIVE_INFINITY; + const max = options?.max ?? Number.POSITIVE_INFINITY; + return Math.max(min, Math.min(max, candidate)); } +/** + * Determine the effective string setting by preferring an allowed environment value, then an allowed config value, and finally the default. + * + * This function is pure and safe for concurrent use; it performs no filesystem I/O and does not log or mutate inputs. Environment values are read as-is — treat any sensitive tokens stored in environment variables as redacted when logging elsewhere. + * + * @param envName - Name of the environment variable to check + * @param configValue - Value from the plugin configuration to consider if no valid env value exists + * @param defaultValue - Fallback value returned when neither env nor config provide an allowed value + * @param allowedValues - Set of permitted string values; only values contained here will be accepted + * @returns One of `allowedValues`: the environment value if present and allowed, otherwise the config value if allowed, otherwise `defaultValue` + */ function resolveStringSetting( envName: string, configValue: T | undefined, @@ -473,6 +690,17 @@ export function getFetchTimeoutMs(pluginConfig: PluginConfig): number { ); } +/** + * Get the configured stream stall timeout in milliseconds. + * + * @param pluginConfig - Plugin configuration to resolve the setting from + * @returns The stream stall timeout in milliseconds (default 45000, minimum enforced 1000) + * + * Notes: + * - Concurrency: safe to call concurrently; it performs no I/O or shared-state mutation. + * - Windows filesystem: not applicable (no file access). + * - Token redaction: this function does not read, log, or expose any tokens or secrets. + */ export function getStreamStallTimeoutMs(pluginConfig: PluginConfig): number { return resolveNumberSetting( "CODEX_AUTH_STREAM_STALL_TIMEOUT_MS", @@ -481,3 +709,310 @@ export function getStreamStallTimeoutMs(pluginConfig: PluginConfig): number { { min: 1_000 }, ); } + +/** + * Determines whether live account synchronization is enabled. + * + * @param pluginConfig - Plugin configuration to read the setting from; an explicit environment variable override (CODEX_AUTH_LIVE_ACCOUNT_SYNC) takes precedence over this config value. + * @returns `true` if live account synchronization is enabled, `false` otherwise. + * + * @remarks + * Concurrency: setting is read-only and safe to call concurrently. Windows filesystem semantics do not affect this read-only resolution. Any sensitive values sourced from environment variables are handled/redacted by higher-level logging and persistence layers, not by this getter. + */ +export function getLiveAccountSync(pluginConfig: PluginConfig): boolean { + return resolveBooleanSetting( + "CODEX_AUTH_LIVE_ACCOUNT_SYNC", + pluginConfig.liveAccountSync, + true, + ); +} + +/** + * Resolve the effective debounce interval (in milliseconds) used for live account sync. + * + * This value may be overridden by the environment variable `CODEX_AUTH_LIVE_ACCOUNT_SYNC_DEBOUNCE_MS`. + * Concurrency: pure/read-only; no side effects and safe to call concurrently. + * Windows filesystem: not applicable to this setting. + * Token redaction: this function does not access or expose any secrets or tokens. + * + * @param pluginConfig - Plugin configuration object to read the setting from + * @returns The debounce interval in milliseconds (default 250, minimum 50) + */ +export function getLiveAccountSyncDebounceMs(pluginConfig: PluginConfig): number { + return resolveNumberSetting( + "CODEX_AUTH_LIVE_ACCOUNT_SYNC_DEBOUNCE_MS", + pluginConfig.liveAccountSyncDebounceMs, + 250, + { min: 50 }, + ); +} + +/** + * Returns the configured polling interval (in milliseconds) used by live account sync. + * + * @param pluginConfig - Plugin configuration to read the setting from; the function reads `liveAccountSyncPollMs`. + * @returns The resolved polling interval in milliseconds, at least 500 and defaulting to 2000. + */ +export function getLiveAccountSyncPollMs(pluginConfig: PluginConfig): number { + return resolveNumberSetting( + "CODEX_AUTH_LIVE_ACCOUNT_SYNC_POLL_MS", + pluginConfig.liveAccountSyncPollMs, + 2_000, + { min: 500 }, + ); +} + +/** + * Determines whether session affinity is enabled for the plugin. + * + * This value may be overridden by the environment variable `CODEX_AUTH_SESSION_AFFINITY`. + * Concurrency assumption: enabling session affinity assumes the caller can tolerate stable session-to-account bindings across concurrent requests. + * Windows filesystem behavior: unrelated to filesystem; no special handling required on Windows. + * Token redaction: returned boolean does not expose tokens or secrets. + * + * @param pluginConfig - Plugin configuration object to read the sessionAffinity setting from + * @returns `true` if session affinity is enabled, `false` otherwise + */ +export function getSessionAffinity(pluginConfig: PluginConfig): boolean { + return resolveBooleanSetting( + "CODEX_AUTH_SESSION_AFFINITY", + pluginConfig.sessionAffinity, + true, + ); +} + +/** + * Get the configured session-affinity TTL in milliseconds. + * + * Resolves the effective TTL from environment overrides, the provided plugin config, or the default (20 minutes). The returned value is clamped to a minimum of 1000 ms. This setting is treated as a global timing parameter; callers should assume it can be read concurrently from multiple threads or processes and must not rely on filesystem semantics or per-process persistence. This value does not affect token redaction or storage formats. + * + * @param pluginConfig - Plugin configuration to read the session affinity TTL from + * @returns The session-affinity TTL in milliseconds (at least 1000) + */ +export function getSessionAffinityTtlMs(pluginConfig: PluginConfig): number { + return resolveNumberSetting( + "CODEX_AUTH_SESSION_AFFINITY_TTL_MS", + pluginConfig.sessionAffinityTtlMs, + 20 * 60_000, + { min: 1_000 }, + ); +} + +/** + * Determine the maximum number of session-affinity entries to keep. + * + * @param pluginConfig - Plugin configuration to read the configured value from + * @returns The maximum number of entries (at least 8, default 512). Can be overridden by the CODEX_AUTH_SESSION_AFFINITY_MAX_ENTRIES environment variable + */ +export function getSessionAffinityMaxEntries(pluginConfig: PluginConfig): number { + return resolveNumberSetting( + "CODEX_AUTH_SESSION_AFFINITY_MAX_ENTRIES", + pluginConfig.sessionAffinityMaxEntries, + 512, + { min: 8 }, + ); +} + +/** + * Determines whether the proactive token refresh guardian is enabled. + * + * The environment variable `CODEX_AUTH_PROACTIVE_GUARDIAN` takes precedence over the + * value in `pluginConfig`. This getter is safe for concurrent reads and does not + * perform filesystem operations (so it is unaffected by Windows path semantics). + * The returned value does not expose or include any secret tokens and is safe for + * logging or telemetry subject to existing token redaction rules. + * + * @param pluginConfig - Plugin configuration used when the environment does not override the setting + * @returns `true` if proactive refresh guardian is enabled, `false` otherwise + */ +export function getProactiveRefreshGuardian(pluginConfig: PluginConfig): boolean { + return resolveBooleanSetting( + "CODEX_AUTH_PROACTIVE_GUARDIAN", + pluginConfig.proactiveRefreshGuardian, + true, + ); +} + +/** + * Get the effective proactive refresh guardian interval in milliseconds. + * + * The returned value reflects environment-variable overrides and the plugin configuration, + * and is constrained to a minimum of 5,000 ms. + * + * Concurrency: intended for use by a single guardian loop per process; callers should + * coordinate if multiple workers may run concurrently. + * + * Platform notes: this getter performs no filesystem I/O and has no Windows-specific behavior. + * + * Security: this function does not expose or return sensitive tokens; do not log raw tokens here. + * + * @param pluginConfig - Plugin configuration to consult for the default value + * @returns The interval, in milliseconds, used by the proactive refresh guardian (>= 5000) + */ +export function getProactiveRefreshIntervalMs(pluginConfig: PluginConfig): number { + return resolveNumberSetting( + "CODEX_AUTH_PROACTIVE_GUARDIAN_INTERVAL_MS", + pluginConfig.proactiveRefreshIntervalMs, + 60_000, + { min: 5_000 }, + ); +} + +/** + * Get the effective proactive refresh buffer interval in milliseconds. + * + * Resolves the value from the environment override `CODEX_AUTH_PROACTIVE_GUARDIAN_BUFFER_MS`, then the plugin configuration, and falls back to 5 minutes if absent; the returned value will be at least 30,000 ms. + * + * @param pluginConfig - The plugin configuration to consult for the setting + * @returns The proactive refresh buffer interval in milliseconds (at least 30000) + * + * @remarks + * - Concurrency: safe to call concurrently from multiple threads/tasks. + * - Windows filesystem: not applicable to this setting. + * - Token redaction: no sensitive tokens are read or returned by this function. + */ +export function getProactiveRefreshBufferMs(pluginConfig: PluginConfig): number { + return resolveNumberSetting( + "CODEX_AUTH_PROACTIVE_GUARDIAN_BUFFER_MS", + pluginConfig.proactiveRefreshBufferMs, + 5 * 60_000, + { min: 30_000 }, + ); +} + +/** + * Determine the effective network error cooldown interval in milliseconds. + * + * @param pluginConfig - Plugin configuration to resolve the setting from; treated as read-only and safe for concurrent access. + * This function performs no filesystem I/O (unaffected by Windows path semantics) and does not redact or expose tokens. + * @returns The cooldown interval in milliseconds (defaults to 6000, minimum 0). + */ +export function getNetworkErrorCooldownMs(pluginConfig: PluginConfig): number { + return resolveNumberSetting( + "CODEX_AUTH_NETWORK_ERROR_COOLDOWN_MS", + pluginConfig.networkErrorCooldownMs, + 6_000, + { min: 0 }, + ); +} + +/** + * Determines the cooldown period (in milliseconds) to wait after a server error before retrying. + * + * This value is resolved from the CODEX_AUTH_SERVER_ERROR_COOLDOWN_MS environment variable (if present), + * otherwise from pluginConfig.serverErrorCooldownMs, falling back to 4000 ms and constrained to be >= 0. + * + * Concurrency: safe to call from multiple threads/processes — it only reads configuration values and returns a number. + * Windows: environment-variable parsing behaves the same as other platforms. + * Token redaction: this function does not read or expose sensitive tokens. + * + * @param pluginConfig - The plugin configuration to read the server error cooldown from + * @returns The cooldown duration in milliseconds + */ +export function getServerErrorCooldownMs(pluginConfig: PluginConfig): number { + return resolveNumberSetting( + "CODEX_AUTH_SERVER_ERROR_COOLDOWN_MS", + pluginConfig.serverErrorCooldownMs, + 4_000, + { min: 0 }, + ); +} + +/** + * Determines whether persistent storage backups are enabled. + * + * @param pluginConfig - The plugin configuration to consult for the setting + * @returns `true` if storage backup is enabled, `false` otherwise. + * + * @remarks + * - Concurrency: safe to call concurrently from multiple threads/tasks. + * - Filesystem: this function does not perform any filesystem operations and has no Windows-specific behavior. + * - Privacy: returns a boolean only and does not expose or log tokens or secrets. + */ +export function getStorageBackupEnabled(pluginConfig: PluginConfig): boolean { + return resolveBooleanSetting( + "CODEX_AUTH_STORAGE_BACKUP_ENABLED", + pluginConfig.storageBackupEnabled, + true, + ); +} + +/** + * Determines whether preemptive quota checks are enabled. + * + * Safe for concurrent use; performs no filesystem I/O (behavior identical on Windows) and does not expose or log sensitive tokens. + * + * @param pluginConfig - Plugin configuration to read the `preemptiveQuotaEnabled` setting from + * @returns `true` if preemptive quota checks are enabled, `false` otherwise + */ +export function getPreemptiveQuotaEnabled(pluginConfig: PluginConfig): boolean { + return resolveBooleanSetting( + "CODEX_AUTH_PREEMPTIVE_QUOTA_ENABLED", + pluginConfig.preemptiveQuotaEnabled, + true, + ); +} + +/** + * Retrieves the configured preemptive quota remaining percentage for the 5-hour window. + * + * Resolves the value from the `CODEX_AUTH_PREEMPTIVE_QUOTA_5H_REMAINING_PCT` environment variable if present; otherwise uses `pluginConfig.preemptiveQuotaRemainingPercent5h` or the default of 5. The resulting value is constrained to the range 0–100. + * + * Concurrency: pure and safe for concurrent calls; no shared mutable state or I/O. + * File-system / Windows: does not access the file system and has no platform-specific behavior. + * Token redaction: does not read or return secret tokens; callers should treat environment values as sensitive when logging. + * + * @param pluginConfig - Plugin configuration used as the fallback source when the environment variable is not set + * @returns The percentage (0–100) to use for 5-hour preemptive quota checks + */ +export function getPreemptiveQuotaRemainingPercent5h(pluginConfig: PluginConfig): number { + return resolveNumberSetting( + "CODEX_AUTH_PREEMPTIVE_QUOTA_5H_REMAINING_PCT", + pluginConfig.preemptiveQuotaRemainingPercent5h, + 5, + { min: 0, max: 100 }, + ); +} + +/** + * Determine the effective 7-day preemptive quota remaining percentage. + * + * The value is taken from the environment variable `CODEX_AUTH_PREEMPTIVE_QUOTA_7D_REMAINING_PCT` if present, + * otherwise from `pluginConfig.preemptiveQuotaRemainingPercent7d`, falling back to 5. The result is clamped + * to the range 0–100. + * + * Safe to call concurrently; performs no filesystem I/O and behaves consistently on Windows. This function + * does not expose or log sensitive tokens. + * + * @param pluginConfig - Plugin configuration object to read the configured default from + * @returns The percentage (0–100) of remaining quota used for 7-day preemptive quota decisions + */ +export function getPreemptiveQuotaRemainingPercent7d(pluginConfig: PluginConfig): number { + return resolveNumberSetting( + "CODEX_AUTH_PREEMPTIVE_QUOTA_7D_REMAINING_PCT", + pluginConfig.preemptiveQuotaRemainingPercent7d, + 5, + { min: 0, max: 100 }, + ); +} + +/** + * Resolve the maximum preemptive-quota deferral interval in milliseconds. + * + * Resolves the effective `preemptiveQuotaMaxDeferralMs` by considering environment overrides and + * the provided plugin configuration; enforces a minimum of 1000 ms and defaults to 2 hours when + * unspecified. Concurrency: callers can read this concurrently; no internal mutation occurs. + * Windows filesystem note: value resolution is platform-agnostic (no filesystem access). + * Token redaction: this function does not log or expose secrets. + * + * @param pluginConfig - Plugin configuration used as the fallback source when no environment override is set + * @returns The maximum deferral interval in milliseconds (at least 1000 ms, default 7,200,000 ms) + */ +export function getPreemptiveQuotaMaxDeferralMs(pluginConfig: PluginConfig): number { + return resolveNumberSetting( + "CODEX_AUTH_PREEMPTIVE_QUOTA_MAX_DEFERRAL_MS", + pluginConfig.preemptiveQuotaMaxDeferralMs, + 2 * 60 * 60_000, + { min: 1_000 }, + ); +} diff --git a/lib/dashboard-settings.ts b/lib/dashboard-settings.ts new file mode 100644 index 00000000..8858873c --- /dev/null +++ b/lib/dashboard-settings.ts @@ -0,0 +1,449 @@ +import { existsSync, promises as fs } from "node:fs"; +import { join } from "node:path"; +import { getCodexMultiAuthDir } from "./runtime-paths.js"; +import { logWarn } from "./logger.js"; +import { + getUnifiedSettingsPath, + loadUnifiedDashboardSettings, + saveUnifiedDashboardSettings, +} from "./unified-settings.js"; + +export type DashboardThemePreset = "green" | "blue"; +export type DashboardAccentColor = "green" | "cyan" | "blue" | "yellow"; +export type DashboardAccountSortMode = "manual" | "ready-first"; +export type DashboardLayoutMode = "compact-details" | "expanded-rows"; +export type DashboardFocusStyle = "row-invert"; + +export interface DashboardDisplaySettings { + showPerAccountRows: boolean; + showQuotaDetails: boolean; + showForecastReasons: boolean; + showRecommendations: boolean; + showLiveProbeNotes: boolean; + actionAutoReturnMs?: number; + actionPauseOnKey?: boolean; + menuAutoFetchLimits?: boolean; + menuSortEnabled?: boolean; + menuSortMode?: DashboardAccountSortMode; + menuSortPinCurrent?: boolean; + menuSortQuickSwitchVisibleRow?: boolean; + uiThemePreset?: DashboardThemePreset; + uiAccentColor?: DashboardAccentColor; + menuShowStatusBadge?: boolean; + menuShowCurrentBadge?: boolean; + menuShowLastUsed?: boolean; + menuShowQuotaSummary?: boolean; + menuShowQuotaCooldown?: boolean; + menuShowFetchStatus?: boolean; + menuShowDetailsForUnselectedRows?: boolean; + menuLayoutMode?: DashboardLayoutMode; + menuQuotaTtlMs?: number; + menuFocusStyle?: DashboardFocusStyle; + menuHighlightCurrentRow?: boolean; + menuStatuslineFields?: DashboardStatuslineField[]; +} + +export type DashboardStatuslineField = "last-used" | "limits" | "status"; + +export const DASHBOARD_DISPLAY_SETTINGS_VERSION = 1 as const; + +export const DEFAULT_DASHBOARD_DISPLAY_SETTINGS: DashboardDisplaySettings = { + showPerAccountRows: true, + showQuotaDetails: true, + showForecastReasons: true, + showRecommendations: true, + showLiveProbeNotes: true, + actionAutoReturnMs: 2_000, + actionPauseOnKey: true, + menuAutoFetchLimits: true, + menuSortEnabled: true, + menuSortMode: "ready-first", + menuSortPinCurrent: false, + menuSortQuickSwitchVisibleRow: true, + uiThemePreset: "green", + uiAccentColor: "green", + menuShowStatusBadge: true, + menuShowCurrentBadge: true, + menuShowLastUsed: true, + menuShowQuotaSummary: true, + menuShowQuotaCooldown: true, + menuShowFetchStatus: true, + menuShowDetailsForUnselectedRows: false, + menuLayoutMode: "compact-details", + menuQuotaTtlMs: 5 * 60_000, + menuFocusStyle: "row-invert", + menuHighlightCurrentRow: true, + menuStatuslineFields: ["last-used", "limits", "status"], +}; + +const DASHBOARD_SETTINGS_PATH = join(getCodexMultiAuthDir(), "dashboard-settings.json"); + +/** + * Determines whether a value is a non-null object that can be treated as a string-keyed record. + * + * @param value - The value to test + * @returns `true` if `value` is an object and not `null`, `false` otherwise + */ +function isRecord(value: unknown): value is Record { + return value !== null && typeof value === "object"; +} + +/** + * Normalize an unknown value to a boolean, using a fallback when the value is not a boolean. + * + * This function has no side effects, does not perform I/O, and does not redact or inspect tokens; it is safe for concurrent use. + * + * @param value - The value to normalize + * @param fallback - The fallback boolean to use when `value` is not a boolean + * @returns The boolean `value` if `value` is a boolean, otherwise `fallback` + */ +function normalizeBoolean(value: unknown, fallback: boolean): boolean { + return typeof value === "boolean" ? value : fallback; +} + +/** + * Normalize an arbitrary value into a DashboardThemePreset. + * + * This function is pure and has no concurrency or filesystem effects; it behaves the same on Windows and does not read or expose tokens. + * + * @param value - The input value to interpret as a theme preset + * @returns `blue` if `value` is exactly `"blue"`, `green` otherwise + */ +function normalizeThemePreset(value: unknown): DashboardThemePreset { + return value === "blue" ? "blue" : "green"; +} + +/** + * Normalize a user-provided accent color to one of the supported dashboard accent values. + * + * @param value - Candidate accent value to normalize (any type) + * @returns The normalized `DashboardAccentColor`: `"cyan"`, `"blue"`, `"yellow"`, or `"green"` (default) + */ +function normalizeAccentColor(value: unknown): DashboardAccentColor { + switch (value) { + case "cyan": + return "cyan"; + case "blue": + return "blue"; + case "yellow": + return "yellow"; + default: + return "green"; + } +} + +/** + * Normalize a layout mode value, defaulting to the provided fallback when invalid. + * + * This function is synchronous, has no filesystem side effects, and does not perform any token handling or redaction; it is safe to call concurrently. + * + * @param value - The input value to validate as a layout mode + * @param fallback - The layout mode to return when `value` is not `"expanded-rows"` + * @returns `"expanded-rows"` if `value` strictly equals that string, otherwise `fallback` + */ +function normalizeLayoutMode( + value: unknown, + fallback: DashboardLayoutMode, +): DashboardLayoutMode { + return value === "expanded-rows" ? "expanded-rows" : fallback; +} + +/** + * Normalize a dashboard focus style value to a valid DashboardFocusStyle. + * + * This function is deterministic and side-effect free; it is safe for concurrent use. + * It does not access the filesystem (no Windows-specific behavior) and does not handle or redact tokens. + * + * @param value - Input value to normalize + * @returns The normalized focus style value, always `"row-invert"` + */ +function normalizeFocusStyle(value: unknown): DashboardFocusStyle { + return value === "row-invert" ? "row-invert" : "row-invert"; +} + +/** + * Normalize a candidate quota TTL (milliseconds) into a valid bounded millisecond value. + * + * Returns the nearest integer millisecond clamped to the range 60,000 — 1,800,000. + * + * @param value - Candidate TTL in milliseconds; if not a finite number, `fallback` is used + * @param fallback - Millisecond value to use when `value` is invalid + * @returns A rounded millisecond value between 60,000 and 1,800,000 inclusive + * + * Concurrency: pure and side-effect free. Filesystem: not applicable. Token handling: not applicable. + */ +function normalizeQuotaTtlMs(value: unknown, fallback: number): number { + if (typeof value !== "number" || !Number.isFinite(value)) return fallback; + const rounded = Math.round(value); + return Math.max(60_000, Math.min(30 * 60_000, rounded)); +} + +/** + * Normalize a value into a valid dashboard account sort mode. + * + * This is a pure, concurrency-safe helper: it performs no I/O, is unaffected by Windows filesystem semantics, + * and does not log or expose tokens or secrets. + * + * @param value - Candidate value to normalize (may be any type) + * @param fallback - Mode to return when `value` is not a supported mode + * @returns `'ready-first'` or `'manual'` when `value` matches one of those, otherwise `fallback` + */ +function normalizeAccountSortMode(value: unknown, fallback: DashboardAccountSortMode): DashboardAccountSortMode { + if (value === "ready-first" || value === "manual") { + return value; + } + return fallback; +} + +/** + * Normalize an auto-return timeout (milliseconds) into the allowed range. + * + * @param value - The input value to normalize; if not a finite number the `fallback` is used + * @param fallback - Value returned when `value` is invalid + * @returns The timeout in milliseconds rounded to an integer and clamped to the range 0–10000, or `fallback` if `value` is not a finite number + */ +function normalizeAutoReturnMs(value: unknown, fallback: number): number { + if (typeof value !== "number" || !Number.isFinite(value)) return fallback; + const rounded = Math.round(value); + return Math.max(0, Math.min(10_000, rounded)); +} + +/** + * Produces a validated, deduplicated list of statusline fields from an arbitrary value. + * + * @param value - Input expected to be an array of strings; accepted items are `"last-used"`, `"limits"`, and `"status"`. Non-string entries and unknown values are ignored; duplicate entries are removed while preserving first occurrence order. + * @returns An array of allowed `DashboardStatuslineField` values in the original order; if the input is not an array or yields no valid fields, returns the default statusline fields. + * + * Concurrency: Pure and safe for concurrent use. Windows filesystem: not applicable. Token redaction: does not handle or emit sensitive tokens. */ +function normalizeStatuslineFields(value: unknown): DashboardStatuslineField[] { + const defaultFields = [...(DEFAULT_DASHBOARD_DISPLAY_SETTINGS.menuStatuslineFields ?? [])]; + if (!Array.isArray(value)) return defaultFields; + + const allowed = new Set(["last-used", "limits", "status"]); + const fields: DashboardStatuslineField[] = []; + for (const entry of value) { + if (typeof entry !== "string") continue; + if (!allowed.has(entry as DashboardStatuslineField)) continue; + const typed = entry as DashboardStatuslineField; + if (!fields.includes(typed)) { + fields.push(typed); + } + } + + return fields.length > 0 ? fields : defaultFields; +} + +/** + * Produces a plain JSON-serializable record from a DashboardDisplaySettings object. + * + * This is a pure, side-effect-free conversion: it copies all top-level fields as-is, + * is safe for concurrent use, does not perform any token redaction, and does not + * apply any Windows-specific filesystem path normalization. + * + * @param value - The dashboard settings to convert + * @returns A plain Record containing the same top-level fields and values as `value` + */ +function toJsonRecord(value: DashboardDisplaySettings): Record { + const record: Record = {}; + for (const [key, fieldValue] of Object.entries(value)) { + record[key] = fieldValue; + } + return record; +} + +/** + * Gets the filesystem path to the dashboard settings file within the unified settings store. + * + * This path is stable for the current runtime and may be used for reading legacy settings or diagnostics. + * Concurrency: callers should coordinate external writes/reads (no internal locking is provided). + * Windows: path will use platform-native separators and may contain UNC or drive-letter forms. + * Security: the returned path may reference files that contain sensitive tokens; callers must redact or handle contents accordingly. + * + * @returns Absolute filesystem path to the unified dashboard settings file + */ +export function getDashboardSettingsPath(): string { + return getUnifiedSettingsPath(); +} + +/** + * Normalize an untrusted input into a complete DashboardDisplaySettings object. + * + * Produces a validated, fully-populated settings object using defaults and derived values + * (e.g., layout derived from legacy flags). The function is pure and side-effect-free, + * safe for concurrent use, and does not access the filesystem or perform I/O; its behavior + * is independent of Windows filesystem semantics. It also does not persist, log, or redact + * tokens/credentials — it only validates and normalizes fields. + * + * @param value - An untrusted value (typically parsed JSON) to validate and normalize into DashboardDisplaySettings + * @returns A complete DashboardDisplaySettings object with defaults applied and derived fields resolved + */ +export function normalizeDashboardDisplaySettings( + value: unknown, +): DashboardDisplaySettings { + if (!isRecord(value)) { + return { ...DEFAULT_DASHBOARD_DISPLAY_SETTINGS }; + } + const derivedLayoutMode = normalizeLayoutMode( + value.menuLayoutMode, + value.menuShowDetailsForUnselectedRows === true ? "expanded-rows" : "compact-details", + ); + return { + showPerAccountRows: normalizeBoolean( + value.showPerAccountRows, + DEFAULT_DASHBOARD_DISPLAY_SETTINGS.showPerAccountRows, + ), + showQuotaDetails: normalizeBoolean( + value.showQuotaDetails, + DEFAULT_DASHBOARD_DISPLAY_SETTINGS.showQuotaDetails, + ), + showForecastReasons: normalizeBoolean( + value.showForecastReasons, + DEFAULT_DASHBOARD_DISPLAY_SETTINGS.showForecastReasons, + ), + showRecommendations: normalizeBoolean( + value.showRecommendations, + DEFAULT_DASHBOARD_DISPLAY_SETTINGS.showRecommendations, + ), + showLiveProbeNotes: normalizeBoolean( + value.showLiveProbeNotes, + DEFAULT_DASHBOARD_DISPLAY_SETTINGS.showLiveProbeNotes, + ), + actionAutoReturnMs: normalizeAutoReturnMs( + value.actionAutoReturnMs, + DEFAULT_DASHBOARD_DISPLAY_SETTINGS.actionAutoReturnMs ?? 2_000, + ), + actionPauseOnKey: normalizeBoolean( + value.actionPauseOnKey, + DEFAULT_DASHBOARD_DISPLAY_SETTINGS.actionPauseOnKey ?? true, + ), + menuAutoFetchLimits: normalizeBoolean( + value.menuAutoFetchLimits, + DEFAULT_DASHBOARD_DISPLAY_SETTINGS.menuAutoFetchLimits ?? true, + ), + menuSortEnabled: normalizeBoolean( + value.menuSortEnabled, + DEFAULT_DASHBOARD_DISPLAY_SETTINGS.menuSortEnabled ?? false, + ), + menuSortMode: normalizeAccountSortMode( + value.menuSortMode, + DEFAULT_DASHBOARD_DISPLAY_SETTINGS.menuSortMode ?? "ready-first", + ), + menuSortPinCurrent: normalizeBoolean( + value.menuSortPinCurrent, + DEFAULT_DASHBOARD_DISPLAY_SETTINGS.menuSortPinCurrent ?? true, + ), + menuSortQuickSwitchVisibleRow: normalizeBoolean( + value.menuSortQuickSwitchVisibleRow, + DEFAULT_DASHBOARD_DISPLAY_SETTINGS.menuSortQuickSwitchVisibleRow ?? true, + ), + uiThemePreset: normalizeThemePreset( + value.uiThemePreset, + ), + uiAccentColor: normalizeAccentColor( + value.uiAccentColor, + ), + menuShowStatusBadge: normalizeBoolean( + value.menuShowStatusBadge, + DEFAULT_DASHBOARD_DISPLAY_SETTINGS.menuShowStatusBadge ?? true, + ), + menuShowCurrentBadge: normalizeBoolean( + value.menuShowCurrentBadge, + DEFAULT_DASHBOARD_DISPLAY_SETTINGS.menuShowCurrentBadge ?? true, + ), + menuShowLastUsed: normalizeBoolean( + value.menuShowLastUsed, + DEFAULT_DASHBOARD_DISPLAY_SETTINGS.menuShowLastUsed ?? true, + ), + menuShowQuotaSummary: normalizeBoolean( + value.menuShowQuotaSummary, + DEFAULT_DASHBOARD_DISPLAY_SETTINGS.menuShowQuotaSummary ?? true, + ), + menuShowQuotaCooldown: normalizeBoolean( + value.menuShowQuotaCooldown, + DEFAULT_DASHBOARD_DISPLAY_SETTINGS.menuShowQuotaCooldown ?? true, + ), + menuShowFetchStatus: normalizeBoolean( + value.menuShowFetchStatus, + DEFAULT_DASHBOARD_DISPLAY_SETTINGS.menuShowFetchStatus ?? true, + ), + menuShowDetailsForUnselectedRows: derivedLayoutMode === "expanded-rows", + menuLayoutMode: derivedLayoutMode, + menuQuotaTtlMs: normalizeQuotaTtlMs( + value.menuQuotaTtlMs, + DEFAULT_DASHBOARD_DISPLAY_SETTINGS.menuQuotaTtlMs ?? 5 * 60_000, + ), + menuFocusStyle: normalizeFocusStyle(value.menuFocusStyle), + menuHighlightCurrentRow: normalizeBoolean( + value.menuHighlightCurrentRow, + DEFAULT_DASHBOARD_DISPLAY_SETTINGS.menuHighlightCurrentRow ?? true, + ), + menuStatuslineFields: normalizeStatuslineFields(value.menuStatuslineFields), + }; +} + +/** + * Loads dashboard display settings, falling back to defaults and migrating legacy settings when present. + * + * Attempts to read settings from the unified settings store; if absent, reads a legacy JSON file at the + * dashboard settings path, normalizes the values, and migrates them into the unified store when possible. + * If the legacy file is missing or any I/O/parse error occurs, returns the default settings. Migration + * failures are ignored to preserve legacy fallback behavior. + * + * Concurrency: callers should assume other processes may concurrently read or write settings; this function + * does not provide cross-process locking. On Windows, file reads may observe transient sharing/locking issues + * which will cause a fallback to defaults. Any sensitive tokens present in legacy files are not specially + * redacted by this loader; callers and the migration path are responsible for token handling and redaction. + * + * @returns The normalized dashboard display settings object. + */ +export async function loadDashboardDisplaySettings(): Promise { + const unifiedSettings = await loadUnifiedDashboardSettings(); + if (unifiedSettings) { + return normalizeDashboardDisplaySettings(unifiedSettings); + } + + if (!existsSync(DASHBOARD_SETTINGS_PATH)) { + return { ...DEFAULT_DASHBOARD_DISPLAY_SETTINGS }; + } + + try { + const raw = await fs.readFile(DASHBOARD_SETTINGS_PATH, "utf8"); + const parsed = JSON.parse(raw) as unknown; + if (!isRecord(parsed)) { + return { ...DEFAULT_DASHBOARD_DISPLAY_SETTINGS }; + } + const normalized = normalizeDashboardDisplaySettings(parsed.settings); + try { + await saveUnifiedDashboardSettings(toJsonRecord(normalized)); + } catch { + // Keep legacy fallback behavior even if migration write fails. + } + return normalized; + } catch (error) { + logWarn( + `Failed to load dashboard settings from ${DASHBOARD_SETTINGS_PATH}: ${ + error instanceof Error ? error.message : String(error) + }`, + ); + return { ...DEFAULT_DASHBOARD_DISPLAY_SETTINGS }; + } +} + +/** + * Persist dashboard display settings to the unified settings store after normalizing them. + * + * Normalizes `settings` with file-local rules then saves the resulting plain record via the unified + * settings API. Concurrent saves are not coordinated by this function; the last write wins. + * This function does not perform secret or token redaction — callers must ensure no sensitive + * values are present. Behavior of underlying storage (e.g., file locking or atomic writes) may + * vary by platform (Windows filesystem semantics depend on the unified settings implementation). + * + * @param settings - The display settings to normalize and persist + * @returns void + */ +export async function saveDashboardDisplaySettings( + settings: DashboardDisplaySettings, +): Promise { + const normalized = normalizeDashboardDisplaySettings(settings); + await saveUnifiedDashboardSettings(toJsonRecord(normalized)); +} diff --git a/lib/entitlement-cache.ts b/lib/entitlement-cache.ts new file mode 100644 index 00000000..09a190c7 --- /dev/null +++ b/lib/entitlement-cache.ts @@ -0,0 +1,146 @@ +import { createLogger } from "./logger.js"; + +const log = createLogger("entitlement-cache"); + +export interface EntitlementBlock { + model: string; + blockedUntil: number; + reason: "unsupported-model" | "plan-entitlement"; + updatedAt: number; +} + +export interface EntitlementCacheSnapshot { + accounts: Record; +} + +const DEFAULT_BLOCK_TTL_MS = 30 * 60_000; +const MAX_ACCOUNT_BUCKETS = 512; + +export interface EntitlementAccountRef { + accountId?: string; + email?: string; + index?: number; +} + +/** + * Normalize a model identifier into a canonical short form. + * + * @param model - The model identifier (may be undefined, include path segments, or include trailing variant suffixes) + * @returns The normalized model name with any path prefix removed and trailing suffixes `-none`, `-minimal`, `-low`, `-medium`, `-high`, or `-xhigh` stripped; `null` if `model` is undefined or empty after trimming + */ +function normalizeModel(model: string | undefined): string | null { + if (!model) return null; + const trimmed = model.trim().toLowerCase(); + if (!trimmed) return null; + const stripped = trimmed.includes("/") ? (trimmed.split("/").pop() ?? trimmed) : trimmed; + return stripped.replace(/-(none|minimal|low|medium|high|xhigh)$/i, ""); +} + +/** + * Produces a stable string key for an entitlement account reference. + * + * The returned key uses the first available identifier in priority order: accountId, email, then index. + * + * @param ref - Account reference containing optional `accountId`, `email`, or `index` + * @returns A string key prefixed with `id:`, `email:`, or `idx:` (e.g. `id:123`, `email:user@example.com`, `idx:0`) + * + * Concurrency: safe to call concurrently; the function is pure and has no side effects. + * Filesystem: output is independent of platform filesystem semantics (including Windows path rules). + * Sensitive data: keys may contain personally identifying values; redact or avoid logging them in production. + */ +export function resolveEntitlementAccountKey(ref: EntitlementAccountRef): string { + const accountId = typeof ref.accountId === "string" ? ref.accountId.trim() : ""; + if (accountId) return `id:${accountId}`; + const email = typeof ref.email === "string" ? ref.email.trim().toLowerCase() : ""; + if (email) return `email:${email}`; + const index = Number.isFinite(ref.index) ? Math.max(0, Math.floor(ref.index ?? 0)) : 0; + return `idx:${index}`; +} + +export class EntitlementCache { + private readonly blocksByAccount = new Map>(); + + markBlocked( + accountKey: string, + model: string, + reason: EntitlementBlock["reason"], + ttlMs = DEFAULT_BLOCK_TTL_MS, + now = Date.now(), + ): void { + const normalizedModel = normalizeModel(model); + if (!accountKey || !normalizedModel) return; + if (this.blocksByAccount.size >= MAX_ACCOUNT_BUCKETS && !this.blocksByAccount.has(accountKey)) { + const first = this.blocksByAccount.keys().next().value; + if (typeof first === "string") this.blocksByAccount.delete(first); + } + const existing = this.blocksByAccount.get(accountKey) ?? new Map(); + existing.set(normalizedModel, { + model: normalizedModel, + blockedUntil: now + Math.max(1_000, Math.floor(ttlMs)), + reason, + updatedAt: now, + }); + this.blocksByAccount.set(accountKey, existing); + } + + clear(accountKey: string, model?: string): void { + if (!accountKey) return; + if (!model) { + this.blocksByAccount.delete(accountKey); + return; + } + const normalizedModel = normalizeModel(model); + if (!normalizedModel) return; + const accountBlocks = this.blocksByAccount.get(accountKey); + if (!accountBlocks) return; + accountBlocks.delete(normalizedModel); + if (accountBlocks.size === 0) this.blocksByAccount.delete(accountKey); + } + + isBlocked(accountKey: string, model: string, now = Date.now()): { blocked: boolean; waitMs: number; reason?: EntitlementBlock["reason"] } { + const normalizedModel = normalizeModel(model); + if (!accountKey || !normalizedModel) return { blocked: false, waitMs: 0 }; + const accountBlocks = this.blocksByAccount.get(accountKey); + if (!accountBlocks) return { blocked: false, waitMs: 0 }; + const block = accountBlocks.get(normalizedModel); + if (!block) return { blocked: false, waitMs: 0 }; + if (block.blockedUntil <= now) { + accountBlocks.delete(normalizedModel); + if (accountBlocks.size === 0) this.blocksByAccount.delete(accountKey); + return { blocked: false, waitMs: 0 }; + } + return { + blocked: true, + waitMs: Math.max(0, block.blockedUntil - now), + reason: block.reason, + }; + } + + prune(now = Date.now()): number { + let removed = 0; + for (const [accountKey, blocks] of this.blocksByAccount.entries()) { + for (const [model, block] of blocks.entries()) { + if (block.blockedUntil <= now) { + blocks.delete(model); + removed += 1; + } + } + if (blocks.size === 0) { + this.blocksByAccount.delete(accountKey); + } + } + if (removed > 0) { + log.debug("Pruned entitlement cache", { removed }); + } + return removed; + } + + snapshot(now = Date.now()): EntitlementCacheSnapshot { + this.prune(now); + const accounts: Record = {}; + for (const [accountKey, blocks] of this.blocksByAccount.entries()) { + accounts[accountKey] = Array.from(blocks.values()).sort((a, b) => a.model.localeCompare(b.model)); + } + return { accounts }; + } +} diff --git a/lib/forecast.ts b/lib/forecast.ts new file mode 100644 index 00000000..365f7fca --- /dev/null +++ b/lib/forecast.ts @@ -0,0 +1,387 @@ +import { formatAccountLabel, formatWaitTime } from "./accounts.js"; +import type { CodexQuotaSnapshot } from "./quota-probe.js"; +import type { AccountMetadataV3 } from "./storage.js"; +import type { TokenFailure } from "./types.js"; + +export type ForecastAvailability = "ready" | "delayed" | "unavailable"; +export type ForecastRiskLevel = "low" | "medium" | "high"; + +export interface ForecastAccountInput { + index: number; + account: AccountMetadataV3; + isCurrent: boolean; + now: number; + refreshFailure?: TokenFailure; + liveQuota?: CodexQuotaSnapshot; +} + +export interface ForecastAccountResult { + index: number; + label: string; + isCurrent: boolean; + availability: ForecastAvailability; + riskScore: number; + riskLevel: ForecastRiskLevel; + waitMs: number; + reasons: string[]; + hardFailure: boolean; + disabled: boolean; +} + +export interface ForecastRecommendation { + recommendedIndex: number | null; + reason: string; +} + +export interface ForecastSummary { + total: number; + ready: number; + delayed: number; + unavailable: number; + highRisk: number; +} + +/** + * Normalizes a numeric risk score into the 0–100 range. + * + * @param score - The input risk score to normalize + * @returns An integer between 0 and 100; returns 100 if `score` is not a finite number + */ +function clampRisk(score: number): number { + if (!Number.isFinite(score)) return 100; + return Math.max(0, Math.min(100, Math.round(score))); +} + +/** + * Map a numeric risk score to a qualitative risk level. + * + * Uses thresholds: `high` for scores >= 75, `medium` for scores >= 40, otherwise `low`. + * No concurrency, filesystem, or token-redaction side effects. + * + * @param score - Risk score (typically 0–100) + * @returns `'high'` if `score` >= 75, `'medium'` if `score` >= 40, `'low'` otherwise + */ +function getRiskLevel(score: number): ForecastRiskLevel { + if (score >= 75) return "high"; + if (score >= 40) return "medium"; + return "low"; +} + +/** + * Returns the earliest future rate-limit reset timestamp for the given account and family scope. + * + * This function examines the account's rateLimitResetTimes and selects the smallest timestamp + * greater than `now` whose key matches `family` or starts with `family:`. It does not perform I/O + * and does not expose token values. + * + * Concurrency: callers should provide a stable snapshot of `account` if concurrent mutation is possible. + * Filesystem: no filesystem interactions; behavior is unaffected by Windows path semantics. + * Token redaction: only numeric reset timestamps are read or returned; no secrets are exposed. + * + * @param account - Account metadata containing `rateLimitResetTimes` + * @param now - Current time in milliseconds since epoch used to filter past resets + * @param family - Family scope to match (default: "codex") + * @returns The earliest future reset timestamp (ms since epoch) for the family, or `null` if none found + */ +function getRateLimitResetTimeForFamily( + account: AccountMetadataV3, + now: number, + family = "codex", +): number | null { + const times = account.rateLimitResetTimes; + if (!times) return null; + + let minReset: number | null = null; + const prefix = `${family}:`; + for (const [key, value] of Object.entries(times)) { + if (typeof value !== "number") continue; + if (value <= now) continue; + if (key !== family && !key.startsWith(prefix)) continue; + if (minReset === null || value < minReset) { + minReset = value; + } + } + return minReset; +} + +/** + * Compute the remaining wait time until live quota resets based on a quota snapshot. + * + * Examines `primary.resetAtMs` and `secondary.resetAtMs` in the provided `snapshot` and returns the + * largest positive remaining milliseconds until those reset timestamps relative to `now`. + * + * This function is pure and has no side effects; it does not access the filesystem, perform I/O, + * or expose token contents. + * + * @param snapshot - A Codex quota snapshot with `primary.resetAtMs` and `secondary.resetAtMs` (epoch ms or non-number) + * @param now - Current time in milliseconds since epoch used to compute remaining durations + * @returns The maximum remaining milliseconds until a future reset, or `0` if there are no future resets + */ +function getLiveQuotaWaitMs(snapshot: CodexQuotaSnapshot, now: number): number { + const waits: number[] = []; + for (const resetAt of [snapshot.primary.resetAtMs, snapshot.secondary.resetAtMs]) { + if (typeof resetAt !== "number") continue; + if (!Number.isFinite(resetAt)) continue; + const remaining = resetAt - now; + if (remaining > 0) waits.push(remaining); + } + return waits.length > 0 ? Math.max(...waits) : 0; +} + +/** + * Format quota usage as a human-readable percentage string for a given label. + * + * @param label - The quota label to include in the message + * @param usedPercent - The percent of quota used; if not a finite number the function returns `null` + * @returns A string like "`