Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 18 additions & 8 deletions docs/reference/settings.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# Settings Reference

Reference for dashboard and backend settings available from `codex auth login` -> `Settings`.
Reference for the settings surface available from `codex auth login` -> `Settings`.

---

Expand All @@ -19,9 +19,11 @@ When `CODEX_MULTI_AUTH_DIR` is set, this root moves accordingly.

---

## Dashboard Display Settings
## Everyday Settings

### Account List View
The top-level settings flow now separates everyday dashboard preferences from advanced operator controls.

### List Appearance

Controls account-row display and sorting behavior:

Expand All @@ -34,15 +36,15 @@ Controls account-row display and sorting behavior:
- smart sort enable and mode
- compact versus expanded layout mode

### Summary Fields
### Details Line

Controls detail-line fields and order:

- `last-used`
- `limits`
- `status`

### Behavior
### Results and Refresh

Controls result-screen and fetch behavior:

Expand All @@ -51,17 +53,21 @@ Controls result-screen and fetch behavior:
- auto-fetch limits
- fetch TTL

### Theme
### Colors

Controls display style:

- theme preset
- accent color
- focus style

### Sync Center
---

## Advanced and Operator Controls

The settings hub includes a preview-first sync center for Codex CLI account sync.
### Codex CLI Sync

The advanced section includes a preview-first sync center for Codex CLI account sync.

Before applying sync, it shows:

Expand All @@ -74,6 +80,10 @@ Before applying sync, it shows:

---

### Advanced Backend Controls

Expert backend controls stay available without changing the saved settings schema. They are grouped into categories so the default path can stay simpler for day-to-day use.

## Backend Categories

### Session and Sync
Expand Down
2 changes: 2 additions & 0 deletions docs/reference/storage-paths.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ Override root:
| --- | --- |
| Unified settings | `~/.codex/multi-auth/settings.json` |
| Accounts | `~/.codex/multi-auth/openai-codex-accounts.json` |
| Named backups | `~/.codex/multi-auth/backups/<name>.json` |
| Accounts backup | `~/.codex/multi-auth/openai-codex-accounts.json.bak` |
| Accounts WAL | `~/.codex/multi-auth/openai-codex-accounts.json.wal` |
| Flagged accounts | `~/.codex/multi-auth/openai-codex-flagged-accounts.json` |
Expand All @@ -43,6 +44,7 @@ Ownership note:
When project-scoped behavior is enabled:

- `~/.codex/multi-auth/projects/<project-key>/openai-codex-accounts.json`
- `~/.codex/multi-auth/projects/<project-key>/backups/<name>.json`

`<project-key>` is derived as:

Expand Down
11 changes: 11 additions & 0 deletions lib/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@ export type LoginMode =
| "check"
| "deep-check"
| "verify-flagged"
| "restore-backup"
| "cancel";

export interface ExistingAccountInfo {
Expand Down Expand Up @@ -212,6 +213,14 @@ async function promptLoginModeFallback(
if (normalized === "r" || normalized === "reset") {
return { mode: "reset" };
}
if (
normalized === "u" ||
normalized === "restore" ||
normalized === "backup" ||
normalized === "restore-backup"
) {
return { mode: "restore-backup" };
}
Comment on lines +216 to +223
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

add fallback-parser regression coverage for restore aliases.

this adds new fallback commands (lib/cli.ts:216), but there is no explicit non-tty regression test proving u/restore/backup routes to restore-backup. current coverage shown is tty flow (test/codex-manager-cli.test.ts:1636).

As per coding guidelines, "lib/**: ... verify every change cites affected tests (vitest) ..."

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@lib/cli.ts` around lines 216 - 223, The new fallback branch in lib/cli.ts
that maps normalized values ("u", "restore", "backup", "restore-backup") to {
mode: "restore-backup" } lacks a non-TTY regression test; add a vitest that
calls the same fallback/argument-parsing entry used by the CLI (the function
that computes normalized and returns the mode) with each alias
("u","restore","backup","restore-backup") in a non-tty scenario and assert the
returned object equals { mode: "restore-backup" }; add the test to
test/codex-manager-cli.test.ts near the existing tty coverage (the test around
line ~1636) so future changes to the fallback parser (the code using normalized
and returning mode "restore-backup") are explicitly verified.

if (normalized === "c" || normalized === "check")
return { mode: "check" };
if (normalized === "d" || normalized === "deep") {
Expand Down Expand Up @@ -279,6 +288,8 @@ export async function promptLoginMode(
return { mode: "deep-check" };
case "verify-flagged":
return { mode: "verify-flagged" };
case "restore-backup":
return { mode: "restore-backup" };
case "select-account": {
const accountAction = await showAccountDetails(action.account);
if (accountAction === "delete") {
Expand Down
109 changes: 109 additions & 0 deletions lib/codex-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -73,17 +73,22 @@ import { queuedRefresh } from "./refresh-queue.js";
import {
type AccountMetadataV3,
type AccountStorageV3,
assessNamedBackupRestore,
type FlaggedAccountMetadataV1,
type FlaggedAccountStorageV1,
getNamedBackupsDirectoryPath,
getStoragePath,
listNamedBackups,
loadAccounts,
loadFlaggedAccounts,
restoreNamedBackup,
saveAccounts,
saveFlaggedAccounts,
setStoragePath,
} from "./storage.js";
import type { AccountIdSource, TokenFailure, TokenResult } from "./types.js";
import { ANSI } from "./ui/ansi.js";
import { confirm } from "./ui/confirm.js";
import { UI_COPY } from "./ui/copy.js";
import { paintUiText, quotaToneFromLeftPercent } from "./ui/format.js";
import { getUiRuntimeOptions } from "./ui/runtime.js";
Expand Down Expand Up @@ -130,6 +135,17 @@ function formatReasonLabel(reason: string | undefined): string | undefined {
return normalized.length > 0 ? normalized : undefined;
}

function formatRelativeDateShort(
timestamp: number | null | undefined,
): string | null {
if (!timestamp) return null;
const days = Math.floor((Date.now() - timestamp) / 86_400_000);
if (days <= 0) return "today";
if (days === 1) return "yesterday";
if (days < 7) return `${days}d ago`;
return new Date(timestamp).toLocaleDateString();
}

function extractErrorMessageFromPayload(payload: unknown): string | undefined {
if (!payload || typeof payload !== "object") return undefined;
const record = payload as Record<string, unknown>;
Expand Down Expand Up @@ -4069,6 +4085,95 @@ async function handleManageAction(
}
}

type BackupMenuAction =
| {
type: "restore";
assessment: Awaited<ReturnType<typeof assessNamedBackupRestore>>;
}
| { type: "back" };

async function runBackupRestoreManager(
displaySettings: DashboardDisplaySettings,
): Promise<void> {
const backupDir = getNamedBackupsDirectoryPath();
const backups = await listNamedBackups();
if (backups.length === 0) {
console.log(`No named backups found. Place backup files in ${backupDir}.`);
return;
}

const currentStorage = await loadAccounts();
const assessments = await Promise.all(
backups.map((backup) =>
assessNamedBackupRestore(backup.name, { currentStorage }),
),
);
Comment on lines 4085 to +4110
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Double I/O per backup — antivirus lock risk on Windows

runBackupRestoreManager calls listNamedBackups() first, which already reads and stats every backup file. then it immediately calls assessNamedBackupRestore(backup.name, ...) for each one — which calls loadBackupCandidate and fs.stat again. that's 4 I/O operations per backup file to build the menu. on Windows where antivirus rescans files on every open, you can get EBUSY errors or multi-second stalls proportional to backup count.

since listNamedBackups already returns full NamedBackupMetadata (including valid, accountCount, etc.), you can build the assessment directly from what you already have rather than re-reading from disk:

const backups = await listNamedBackups();
const currentStorage = await loadAccounts();
const currentAccounts = currentStorage?.accounts ?? [];

const assessments = backups.map((backup) => {
  if (!backup.valid || !backup.accountCount || backup.accountCount <= 0) {
    return { backup, currentAccountCount: currentAccounts.length, mergedAccountCount: null, imported: null, skipped: null, wouldExceedLimit: false, valid: false, error: backup.loadError ?? "Backup is empty or invalid" };
  }
  // build merged count inline without re-reading disk
  ...
});

this avoids the second round of file I/O entirely.

Prompt To Fix With AI
This is a comment left during a code review.
Path: lib/codex-manager.ts
Line: 4085-4110

Comment:
**Double I/O per backup — antivirus lock risk on Windows**

`runBackupRestoreManager` calls `listNamedBackups()` first, which already reads and stats every backup file. then it immediately calls `assessNamedBackupRestore(backup.name, ...)` for each one — which calls `loadBackupCandidate` and `fs.stat` again. that's 4 I/O operations per backup file to build the menu. on Windows where antivirus rescans files on every open, you can get `EBUSY` errors or multi-second stalls proportional to backup count.

since `listNamedBackups` already returns full `NamedBackupMetadata` (including `valid`, `accountCount`, etc.), you can build the assessment directly from what you already have rather than re-reading from disk:

```ts
const backups = await listNamedBackups();
const currentStorage = await loadAccounts();
const currentAccounts = currentStorage?.accounts ?? [];

const assessments = backups.map((backup) => {
  if (!backup.valid || !backup.accountCount || backup.accountCount <= 0) {
    return { backup, currentAccountCount: currentAccounts.length, mergedAccountCount: null, imported: null, skipped: null, wouldExceedLimit: false, valid: false, error: backup.loadError ?? "Backup is empty or invalid" };
  }
  // build merged count inline without re-reading disk
  ...
});
```

this avoids the second round of file I/O entirely.

How can I resolve this? If you propose a fix, please make it concise.

Fix in Codex

Comment on lines +4099 to +4110
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

avoid double backup reads while constructing the restore menu.

this reads each backup once in listNamedBackups() and again in assessNamedBackupRestore(). latency scales linearly with file count and can stall the menu. reference: lib/codex-manager.ts:4099-4110.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@lib/codex-manager.ts` around lines 4099 - 4110, The code currently calls
listNamedBackups() to get backup metadata and then calls
assessNamedBackupRestore(backup.name, ...) which re-reads the same backup files,
causing double I/O; fix by reading each backup once and passing the read data
into the assessor (either change assessNamedBackupRestore signature to accept a
backup object/content or add a new helper assessNamedBackupRestoreFromData),
i.e., replace the backups.map call to pass the already-loaded backup data (from
listNamedBackups) into the assessor and update assessNamedBackupRestore (or add
the new function) to use that data instead of re-opening the file; keep function
names listNamedBackups and assessNamedBackupRestore referenced so you can locate
and update both places.


const items: MenuItem<BackupMenuAction>[] = assessments.map((assessment) => {
const status =
assessment.valid && !assessment.wouldExceedLimit
? "ready"
: assessment.wouldExceedLimit
? "limit"
: "invalid";
const lastUpdated = formatRelativeDateShort(assessment.backup.updatedAt);
const parts = [
assessment.backup.accountCount !== null
? `${assessment.backup.accountCount} account${assessment.backup.accountCount === 1 ? "" : "s"}`
: undefined,
lastUpdated ? `updated ${lastUpdated}` : undefined,
assessment.wouldExceedLimit
? `would exceed ${ACCOUNT_LIMITS.MAX_ACCOUNTS}`
: undefined,
assessment.error ?? assessment.backup.loadError,
].filter(
(value): value is string =>
typeof value === "string" && value.trim().length > 0,
);

return {
label: assessment.backup.name,
hint: parts.length > 0 ? parts.join(" | ") : undefined,
value: { type: "restore", assessment },
color:
status === "ready" ? "green" : status === "limit" ? "red" : "yellow",
disabled: !assessment.valid || assessment.wouldExceedLimit,
};
});

items.push({ label: "Back", value: { type: "back" } });

const ui = getUiRuntimeOptions();
const selection = await select(items, {
message: "Restore From Backup",
subtitle: backupDir,
help: UI_COPY.mainMenu.helpCompact,
clearScreen: true,
selectedEmphasis: "minimal",
focusStyle: displaySettings.menuFocusStyle ?? "row-invert",
theme: ui.theme,
});

if (!selection || selection.type === "back") {
return;
}

const assessment = selection.assessment;
if (!assessment.valid || assessment.wouldExceedLimit) {
console.log(assessment.error ?? "Backup is not eligible for restore.");
return;
}

const confirmMessage = `Restore backup "${assessment.backup.name}"? This will merge ${assessment.backup.accountCount ?? 0} account(s) into ${assessment.currentAccountCount} current (${assessment.mergedAccountCount ?? assessment.currentAccountCount} after dedupe).`;
const confirmed = await confirm(confirmMessage);
if (!confirmed) return;

const result = await restoreNamedBackup(assessment.backup.name);
console.log(
`Restored backup "${assessment.backup.name}". Imported ${result.imported}, skipped ${result.skipped}, total ${result.total}.`,
);
Comment on lines +4171 to +4174
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

catch restore failures so login mode does not abort on i/o or schema errors.

restoreNamedBackup can throw for invalid/corrupt backups. right now that exception escapes the menu path and can terminate the interactive flow. reference: lib/codex-manager.ts:4171-4174.

proposed fix
-	const result = await restoreNamedBackup(assessment.backup.name);
-	console.log(
-		`Restored backup "${assessment.backup.name}". Imported ${result.imported}, skipped ${result.skipped}, total ${result.total}.`,
-	);
+	try {
+		const result = await restoreNamedBackup(assessment.backup.name);
+		console.log(
+			`Restored backup "${assessment.backup.name}". Imported ${result.imported}, skipped ${result.skipped}, total ${result.total}.`,
+		);
+	} catch (error) {
+		console.error(
+			`Restore failed for "${assessment.backup.name}": ${error instanceof Error ? error.message : String(error)}`,
+		);
+	}
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const result = await restoreNamedBackup(assessment.backup.name);
console.log(
`Restored backup "${assessment.backup.name}". Imported ${result.imported}, skipped ${result.skipped}, total ${result.total}.`,
);
try {
const result = await restoreNamedBackup(assessment.backup.name);
console.log(
`Restored backup "${assessment.backup.name}". Imported ${result.imported}, skipped ${result.skipped}, total ${result.total}.`,
);
} catch (error) {
console.error(
`Restore failed for "${assessment.backup.name}": ${error instanceof Error ? error.message : String(error)}`,
);
}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@lib/codex-manager.ts` around lines 4171 - 4174, The restoreNamedBackup call
can throw and should be wrapped in a try/catch so interactive login/menu flow
doesn't abort; inside the same block where
restoreNamedBackup(assessment.backup.name) is invoked (and where result is
used), add a try { const result = await
restoreNamedBackup(assessment.backup.name); ... } catch (err) {
processLogger.error or console.error with the backup name and err, and set any
local status variables (e.g. treat imported/skipped/total as zero or mark
restore failed) so the code continues the menu/login path instead of throwing.
Ensure you reference restoreNamedBackup and assessment.backup.name and keep the
downstream logic that prints the restored/imported/skipped/total behavior
conditional on success vs failure.

}

async function runAuthLogin(): Promise<number> {
setStoragePath(null);
let pendingMenuQuotaRefresh: Promise<void> | null = null;
Expand Down Expand Up @@ -4192,6 +4297,10 @@ async function runAuthLogin(): Promise<number> {
);
continue;
}
if (menuResult.mode === "restore-backup") {
await runBackupRestoreManager(displaySettings);
continue;
}
if (menuResult.mode === "fresh" && menuResult.deleteAll) {
await runActionPanel(
DESTRUCTIVE_ACTION_COPY.deleteSavedAccounts.label,
Expand Down
24 changes: 17 additions & 7 deletions lib/codex-manager/settings-hub.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2742,35 +2742,45 @@ async function promptSettingsHub(
},
{
label: UI_COPY.settings.accountList,
hint: UI_COPY.settings.accountListHint,
value: { type: "account-list" },
color: "green",
},
{
label: UI_COPY.settings.syncCenter,
value: { type: "sync-center" },
color: "green",
},
{
label: UI_COPY.settings.summaryFields,
hint: UI_COPY.settings.summaryFieldsHint,
value: { type: "summary-fields" },
color: "green",
},
{
label: UI_COPY.settings.behavior,
hint: UI_COPY.settings.behaviorHint,
value: { type: "behavior" },
color: "green",
},
{ label: UI_COPY.settings.theme, value: { type: "theme" }, color: "green" },
{
label: UI_COPY.settings.theme,
hint: UI_COPY.settings.themeHint,
value: { type: "theme" },
color: "green",
},
{ label: "", value: { type: "back" }, separator: true },
{
label: UI_COPY.settings.advancedTitle,
value: { type: "back" },
kind: "heading",
},
{
label: UI_COPY.settings.syncCenter,
hint: UI_COPY.settings.syncCenterHint,
value: { type: "sync-center" },
color: "yellow",
},
{
label: UI_COPY.settings.backend,
hint: UI_COPY.settings.backendHint,
value: { type: "backend" },
color: "green",
color: "yellow",
},
{ label: "", value: { type: "back" }, separator: true },
{
Expand Down
Loading