Skip to content

Commit 57bb18f

Browse files
committed
fix(auth):_close_backup_restore_review_gaps
1 parent 3c9aab4 commit 57bb18f

7 files changed

Lines changed: 99 additions & 13 deletions

File tree

lib/accounts.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -92,7 +92,12 @@ export interface ManagedAccount {
9292
expires?: number;
9393
addedAt: number;
9494
lastUsed: number;
95-
lastSwitchReason?: "rate-limit" | "initial" | "rotation" | "best";
95+
lastSwitchReason?:
96+
| "rate-limit"
97+
| "initial"
98+
| "rotation"
99+
| "best"
100+
| "restore";
96101
lastRateLimitReason?: RateLimitReason;
97102
rateLimitResetTimes: RateLimitStateV3;
98103
coolingDownUntil?: number;

lib/codex-manager.ts

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ import {
3737
summarizeForecast,
3838
type ForecastAccountResult,
3939
} from "./forecast.js";
40+
import { createLogger } from "./logger.js";
4041
import { MODEL_FAMILIES, type ModelFamily } from "./prompts/codex.js";
4142
import {
4243
fetchCodexQuotaSnapshot,
@@ -92,6 +93,7 @@ type TokenSuccessWithAccount = TokenSuccess & {
9293
accountLabel?: string;
9394
};
9495
type PromptTone = "accent" | "success" | "warning" | "danger" | "muted";
96+
const log = createLogger("codex-manager");
9597

9698
function stylePromptText(text: string, tone: PromptTone): string {
9799
if (!output.isTTY) return text;
@@ -4359,7 +4361,10 @@ async function runAuthLogin(): Promise<number> {
43594361
if (existingCount === 0) {
43604362
try {
43614363
namedBackups = await getNamedBackups();
4362-
} catch {
4364+
} catch (error) {
4365+
log.debug("getNamedBackups failed, skipping restore option", {
4366+
error: error instanceof Error ? error.message : String(error),
4367+
});
43634368
namedBackups = [];
43644369
}
43654370
}
@@ -4413,7 +4418,7 @@ async function runAuthLogin(): Promise<number> {
44134418
storage: restoredStorage,
44144419
targetIndex,
44154420
parsed: targetIndex + 1,
4416-
switchReason: "rotation",
4421+
switchReason: "restore",
44174422
preserveActiveIndexByFamily: true,
44184423
});
44194424
console.log(
@@ -4541,7 +4546,7 @@ async function persistAndSyncSelectedAccount({
45414546
storage: NonNullable<Awaited<ReturnType<typeof loadAccounts>>>;
45424547
targetIndex: number;
45434548
parsed: number;
4544-
switchReason: "rotation" | "best";
4549+
switchReason: "rotation" | "best" | "restore";
45454550
initialSyncIdToken?: string;
45464551
preserveActiveIndexByFamily?: boolean;
45474552
}): Promise<{ synced: boolean; wasDisabled: boolean }> {

lib/schemas.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -82,7 +82,13 @@ export type CooldownReasonFromSchema = z.infer<typeof CooldownReasonSchema>;
8282
/**
8383
* Last switch reason for account rotation tracking.
8484
*/
85-
export const SwitchReasonSchema = z.enum(["rate-limit", "initial", "rotation", "best"]);
85+
export const SwitchReasonSchema = z.enum([
86+
"rate-limit",
87+
"initial",
88+
"rotation",
89+
"best",
90+
"restore",
91+
]);
8692

8793
export type SwitchReasonFromSchema = z.infer<typeof SwitchReasonSchema>;
8894

lib/storage.ts

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -909,7 +909,19 @@ export async function restoreAccountsFromBackup(
909909
throw new Error(`Backup path must stay inside ${resolvedBackupRoot}: ${path}`);
910910
}
911911

912-
const { normalized } = await loadAccountsFromPath(resolvedBackupPath);
912+
const { normalized } = await (async () => {
913+
try {
914+
return await loadAccountsFromPath(resolvedBackupPath);
915+
} catch (error) {
916+
const code = (error as NodeJS.ErrnoException).code;
917+
if (code === "ENOENT") {
918+
throw new Error(
919+
`Backup file no longer exists: ${resolvedBackupPath}`,
920+
);
921+
}
922+
throw error;
923+
}
924+
})();
913925
if (!normalized || normalized.accounts.length === 0) {
914926
throw new Error(`Backup does not contain any accounts: ${resolvedBackupPath}`);
915927
}
@@ -2173,7 +2185,8 @@ function normalizeFlaggedStorage(data: unknown): FlaggedAccountStorageV1 {
21732185
value === "rate-limit" ||
21742186
value === "initial" ||
21752187
value === "rotation" ||
2176-
value === "best";
2188+
value === "best" ||
2189+
value === "restore";
21772190
const isCooldownReason = (
21782191
value: unknown,
21792192
): value is AccountMetadataV3["cooldownReason"] =>

lib/storage/migrations.ts

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,12 @@ export interface AccountMetadataV1 {
2525
enabled?: boolean;
2626
addedAt: number;
2727
lastUsed: number;
28-
lastSwitchReason?: "rate-limit" | "initial" | "rotation" | "best";
28+
lastSwitchReason?:
29+
| "rate-limit"
30+
| "initial"
31+
| "rotation"
32+
| "best"
33+
| "restore";
2934
rateLimitResetTime?: number;
3035
coolingDownUntil?: number;
3136
cooldownReason?: CooldownReason;
@@ -50,7 +55,12 @@ export interface AccountMetadataV3 {
5055
enabled?: boolean;
5156
addedAt: number;
5257
lastUsed: number;
53-
lastSwitchReason?: "rate-limit" | "initial" | "rotation" | "best";
58+
lastSwitchReason?:
59+
| "rate-limit"
60+
| "initial"
61+
| "rotation"
62+
| "best"
63+
| "restore";
5464
rateLimitResetTimes?: RateLimitStateV3;
5565
coolingDownUntil?: number;
5666
cooldownReason?: CooldownReason;

test/codex-manager-cli.test.ts

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -30,13 +30,17 @@ const detectOcChatgptMultiAuthTargetMock = vi.fn();
3030
const normalizeAccountStorageMock = vi.fn((value) => value);
3131
const withAccountStorageTransactionMock = vi.fn();
3232
const withAccountAndFlaggedStorageTransactionMock = vi.fn();
33+
const loggerDebugMock = vi.fn();
34+
const loggerInfoMock = vi.fn();
35+
const loggerWarnMock = vi.fn();
36+
const loggerErrorMock = vi.fn();
3337

3438
vi.mock("../lib/logger.js", () => ({
3539
createLogger: vi.fn(() => ({
36-
debug: vi.fn(),
37-
info: vi.fn(),
38-
warn: vi.fn(),
39-
error: vi.fn(),
40+
debug: loggerDebugMock,
41+
info: loggerInfoMock,
42+
warn: loggerWarnMock,
43+
error: loggerErrorMock,
4044
})),
4145
logWarn: vi.fn(),
4246
}));
@@ -3244,6 +3248,10 @@ describe("codex manager cli commands", () => {
32443248
expect(signInItems.some((item) => item.value === "restore-backup")).toBe(
32453249
false,
32463250
);
3251+
expect(loggerDebugMock).toHaveBeenCalledWith(
3252+
"getNamedBackups failed, skipping restore option",
3253+
{ error: "backups directory is locked" },
3254+
);
32473255
});
32483256

32493257
it("keeps the restored pool when Codex sync returns false", async () => {

test/storage-last-backup.test.ts

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -360,6 +360,45 @@ describe("storage last backup restore", () => {
360360
}
361361
});
362362

363+
it("throws a clear error when a backup disappears after restore realpath", async () => {
364+
const backupPath = buildNamedBackupPath("backup-disappeared-after-realpath");
365+
await fs.mkdir(dirname(backupPath), { recursive: true });
366+
await fs.writeFile(
367+
backupPath,
368+
JSON.stringify({
369+
version: 3,
370+
activeIndex: 0,
371+
activeIndexByFamily: { codex: 0 },
372+
accounts: [{ refreshToken: "gone-refresh", addedAt: 1, lastUsed: 1 }],
373+
}),
374+
"utf-8",
375+
);
376+
377+
const originalReadFile = fs.readFile.bind(fs);
378+
const readFileSpy = vi.spyOn(fs, "readFile").mockImplementation(async (path, options) => {
379+
if (String(path) === backupPath) {
380+
await fs.rm(backupPath, { force: true });
381+
const error = new Error(
382+
`ENOENT: no such file or directory, open '${backupPath}'`,
383+
) as NodeJS.ErrnoException;
384+
error.code = "ENOENT";
385+
throw error;
386+
}
387+
return originalReadFile(
388+
path as Parameters<typeof originalReadFile>[0],
389+
options as Parameters<typeof originalReadFile>[1],
390+
);
391+
});
392+
393+
try {
394+
await expect(restoreAccountsFromBackup(backupPath)).rejects.toThrow(
395+
`Backup file no longer exists: ${backupPath}`,
396+
);
397+
} finally {
398+
readFileSpy.mockRestore();
399+
}
400+
});
401+
363402
it("rejects restore paths outside the managed named-backup root", async () => {
364403
const backupPath = buildNamedBackupPath("backup-inside-root");
365404
const escapedBackupPath = join(testRoot, "backup-outside-root.json");

0 commit comments

Comments
 (0)