Skip to content

Commit 2dc73dd

Browse files
committed
fix(auth): harden onboarding restore followups
1 parent d2fad38 commit 2dc73dd

4 files changed

Lines changed: 92 additions & 9 deletions

File tree

docs/getting-started.md

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -43,15 +43,15 @@ codex auth login
4343

4444
Expected flow:
4545

46-
Backup restore appears under the `Recover saved accounts` heading in the onboarding menu. This flow does not add any new CLI flags or npm scripts.
46+
Backup restore appears as `Restore Saved Backup` under the `Recover saved accounts` heading in the onboarding menu. This flow does not add any new CLI flags or npm scripts.
4747
See upgrade note: [onboarding restore behavior](upgrade.md#onboarding-restore-note).
4848

4949
1. If no accounts are saved yet, the terminal opens directly to the sign-in menu.
5050
2. Choose one of the sign-in options:
5151
- `Open Browser (Easy)` for the normal OAuth flow
5252
- `Manual / Incognito` when you need to paste the callback yourself
53-
- `Recover saved accounts` when the current pool is empty and at least one valid named backup exists under `~/.codex/multi-auth/backups` by default, or under `%CODEX_MULTI_AUTH_DIR%\backups` if you override the storage root with `CODEX_MULTI_AUTH_DIR`
54-
3. If you choose `Recover saved accounts`, the next menu lets you either:
53+
- `Restore Saved Backup` under the `Recover saved accounts` heading when the current pool is empty and at least one valid named backup exists under `~/.codex/multi-auth/backups` by default, or under `%CODEX_MULTI_AUTH_DIR%\backups` if you override the storage root with `CODEX_MULTI_AUTH_DIR`
54+
3. If you choose `Restore Saved Backup`, the next menu lets you either:
5555
- load the newest valid backup automatically
5656
- pick a specific backup from a newest-first list
5757
Empty, unreadable, or non-JSON backup sidecar files are skipped, so the menu entry appears only when at least one backup parses successfully and contains at least one account.

lib/codex-manager.ts

Lines changed: 16 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1155,6 +1155,7 @@ export function formatBackupSavedAt(mtimeMs: number): string {
11551155

11561156
async function promptOAuthSignInMode(
11571157
backupOption: NamedBackupSummary | null,
1158+
backupDiscoveryWarning: string | null = null,
11581159
): Promise<OAuthSignInMode> {
11591160
if (!input.isTTY || !output.isTTY) {
11601161
return "browser";
@@ -1187,7 +1188,9 @@ async function promptOAuthSignInMode(
11871188

11881189
const selected = await select<OAuthSignInMode>(items, {
11891190
message: UI_COPY.oauth.chooseModeTitle,
1190-
subtitle: UI_COPY.oauth.chooseModeSubtitle,
1191+
subtitle: backupDiscoveryWarning
1192+
? `${UI_COPY.oauth.chooseModeSubtitle} ${backupDiscoveryWarning}`
1193+
: UI_COPY.oauth.chooseModeSubtitle,
11911194
help: backupOption
11921195
? UI_COPY.oauth.chooseModeHelpWithBackup
11931196
: UI_COPY.oauth.chooseModeHelp,
@@ -4357,11 +4360,14 @@ async function runAuthLogin(): Promise<number> {
43574360
const refreshedStorage = await loadAccounts();
43584361
let existingCount = refreshedStorage?.accounts.length ?? 0;
43594362
let forceNewLogin = existingCount > 0;
4363+
let onboardingBackupDiscoveryWarning: string | null = null;
43604364
const loadNamedBackupsForOnboarding = async (): Promise<NamedBackupSummary[]> => {
43614365
if (existingCount > 0) {
4366+
onboardingBackupDiscoveryWarning = null;
43624367
return [];
43634368
}
43644369
try {
4370+
onboardingBackupDiscoveryWarning = null;
43654371
return await getNamedBackups();
43664372
} catch (error) {
43674373
const code = (error as NodeJS.ErrnoException).code;
@@ -4370,17 +4376,24 @@ async function runAuthLogin(): Promise<number> {
43704376
error: error instanceof Error ? error.message : String(error),
43714377
});
43724378
if (code && code !== "ENOENT") {
4379+
onboardingBackupDiscoveryWarning =
4380+
"Named backup discovery failed. Continuing with browser or manual sign-in only.";
43734381
console.warn(
4374-
"Named backup discovery failed. Continuing with browser or manual sign-in only.",
4382+
onboardingBackupDiscoveryWarning,
43754383
);
4384+
} else {
4385+
onboardingBackupDiscoveryWarning = null;
43764386
}
43774387
return [];
43784388
}
43794389
};
43804390
let namedBackups = await loadNamedBackupsForOnboarding();
43814391
while (true) {
43824392
const latestNamedBackup = namedBackups[0] ?? null;
4383-
const signInMode = await promptOAuthSignInMode(latestNamedBackup);
4393+
const signInMode = await promptOAuthSignInMode(
4394+
latestNamedBackup,
4395+
onboardingBackupDiscoveryWarning,
4396+
);
43844397
if (signInMode === "cancel") {
43854398
if (existingCount > 0) {
43864399
console.log(stylePromptText(UI_COPY.oauth.cancelledBackToMenu, "muted"));

test/codex-manager-cli.test.ts

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3100,6 +3100,12 @@ describe("codex manager cli commands", () => {
31003100
expect.objectContaining({
31013101
activeIndex: 1,
31023102
activeIndexByFamily: { codex: 1 },
3103+
accounts: expect.arrayContaining([
3104+
expect.objectContaining({
3105+
accountId: "acc_restored",
3106+
lastSwitchReason: "restore",
3107+
}),
3108+
]),
31033109
}),
31043110
);
31053111
expect(setCodexCliActiveSelectionMock).toHaveBeenCalledWith(
@@ -3344,6 +3350,12 @@ describe("codex manager cli commands", () => {
33443350
expect(signInItems.some((item) => item.value === "restore-backup")).toBe(
33453351
false,
33463352
);
3353+
const signInOptions = selectMock.mock.calls[0]?.[1] as {
3354+
subtitle?: string;
3355+
};
3356+
expect(signInOptions.subtitle).toContain(
3357+
"Named backup discovery failed. Continuing with browser or manual sign-in only.",
3358+
);
33473359
expect(loggerDebugMock).toHaveBeenCalledWith(
33483360
"getNamedBackups failed, skipping restore option",
33493361
{ code: "EPERM", error: "backups directory is locked" },
@@ -3560,6 +3572,61 @@ describe("codex manager cli commands", () => {
35603572
);
35613573
});
35623574

3575+
it("returns to the existing-account menu when restore fails after storage is already written", async () => {
3576+
const now = Date.now();
3577+
setInteractiveTTY(true);
3578+
const restoredStorage = {
3579+
version: 3 as const,
3580+
activeIndex: 0,
3581+
activeIndexByFamily: { codex: 0 },
3582+
accounts: [
3583+
{
3584+
email: "persisted-after-error@example.com",
3585+
accountId: "acc_persisted_after_error",
3586+
refreshToken: "refresh-persisted-after-error",
3587+
accessToken: "access-persisted-after-error",
3588+
expiresAt: now + 3_600_000,
3589+
addedAt: now - 1_000,
3590+
lastUsed: now - 1_000,
3591+
enabled: true,
3592+
},
3593+
],
3594+
};
3595+
let storageState: typeof restoredStorage | null = null;
3596+
loadAccountsMock.mockImplementation(async () =>
3597+
storageState == null ? null : structuredClone(storageState),
3598+
);
3599+
saveAccountsMock.mockImplementationOnce(async (nextStorage) => {
3600+
storageState = structuredClone(nextStorage);
3601+
throw new Error("save selected account failed");
3602+
});
3603+
getNamedBackupsMock.mockResolvedValue([
3604+
{
3605+
path: "/mock/backups/persisted-after-error.json",
3606+
fileName: "persisted-after-error.json",
3607+
accountCount: 1,
3608+
mtimeMs: now,
3609+
},
3610+
]);
3611+
restoreAccountsFromBackupMock.mockResolvedValue(structuredClone(restoredStorage));
3612+
selectMock
3613+
.mockResolvedValueOnce("restore-backup")
3614+
.mockResolvedValueOnce("latest");
3615+
promptLoginModeMock.mockResolvedValueOnce({ mode: "cancel" });
3616+
const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
3617+
const { runCodexMultiAuthCli } = await import("../lib/codex-manager.js");
3618+
3619+
const exitCode = await runCodexMultiAuthCli(["auth", "login"]);
3620+
3621+
expect(exitCode).toBe(0);
3622+
expect(saveAccountsMock).toHaveBeenCalledTimes(1);
3623+
expect(promptLoginModeMock).toHaveBeenCalledTimes(1);
3624+
expect(selectMock).toHaveBeenCalledTimes(2);
3625+
expect(errorSpy).toHaveBeenCalledWith(
3626+
"Backup restore failed: save selected account failed",
3627+
);
3628+
});
3629+
35633630
it("does not restore the latest backup when confirmation is declined", async () => {
35643631
const now = Date.now();
35653632
setInteractiveTTY(true);

test/storage-last-backup.test.ts

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -266,7 +266,7 @@ describe("storage last backup restore", () => {
266266
const originalStat = fs.stat.bind(fs);
267267
const statSpy = vi.spyOn(fs, "stat").mockImplementation(async (path, options) => {
268268
if (String(path) === flakyBackupPath) {
269-
await fs.rm(flakyBackupPath, { force: true });
269+
await removeWithRetry(flakyBackupPath, { force: true });
270270
const error = new Error(
271271
`ENOENT: no such file or directory, stat '${flakyBackupPath}'`,
272272
) as NodeJS.ErrnoException;
@@ -405,7 +405,7 @@ describe("storage last backup restore", () => {
405405
const originalRealpath = fs.realpath.bind(fs);
406406
const realpathSpy = vi.spyOn(fs, "realpath").mockImplementation(async (path, options) => {
407407
if (String(path) === backupPath) {
408-
await fs.rm(backupPath, { force: true });
408+
await removeWithRetry(backupPath, { force: true });
409409
const error = new Error(
410410
`ENOENT: no such file or directory, realpath '${backupPath}'`,
411411
) as NodeJS.ErrnoException;
@@ -445,7 +445,7 @@ describe("storage last backup restore", () => {
445445
const originalReadFile = fs.readFile.bind(fs);
446446
const readFileSpy = vi.spyOn(fs, "readFile").mockImplementation(async (path, options) => {
447447
if (String(path) === backupPath || String(path) === resolvedBackupPath) {
448-
await fs.rm(backupPath, { force: true });
448+
await removeWithRetry(backupPath, { force: true });
449449
const error = new Error(
450450
`ENOENT: no such file or directory, open '${backupPath}'`,
451451
) as NodeJS.ErrnoException;
@@ -468,6 +468,9 @@ describe("storage last backup restore", () => {
468468
});
469469

470470
it("accepts UNC-resolved backups inside the managed root and rejects cross-share escapes", async () => {
471+
if (process.platform !== "win32") {
472+
return;
473+
}
471474
const insideBackupPath = buildNamedBackupPath("backup-unc-inside");
472475
const outsideBackupPath = buildNamedBackupPath("backup-unc-outside");
473476
const backupRoot = dirname(insideBackupPath);

0 commit comments

Comments
 (0)