Skip to content

Commit 4f8b441

Browse files
committed
fix: harden config saves and stabilize regressions
1 parent d7a04e7 commit 4f8b441

6 files changed

Lines changed: 330 additions & 65 deletions

File tree

lib/config.ts

Lines changed: 107 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,13 @@ const UNSUPPORTED_CODEX_POLICIES = new Set(["strict", "fallback"]);
4141
const emittedConfigWarnings = new Set<string>();
4242
const configSaveQueues = new Map<string, Promise<void>>();
4343
const RETRYABLE_FS_CODES = new Set(["EBUSY", "EPERM"]);
44+
const RETRYABLE_CONFIG_READ_CODES = new Set(["EBUSY", "EPERM", "EAGAIN"]);
45+
46+
type ConfigReadState =
47+
| { status: "missing" }
48+
| { status: "ok"; record: Record<string, unknown> }
49+
| { status: "invalid"; errorMessage: string }
50+
| { status: "unreadable"; errorMessage: string };
4451

4552
export type UnsupportedCodexPolicy = "strict" | "fallback";
4653

@@ -456,6 +463,53 @@ function readConfigRecordFromPath(
456463
}
457464
}
458465

466+
async function readConfigRecordForSave(
467+
configPath: string,
468+
): Promise<ConfigReadState> {
469+
if (!existsSync(configPath)) {
470+
return { status: "missing" };
471+
}
472+
473+
for (let attempt = 0; attempt < 5; attempt += 1) {
474+
try {
475+
const fileContent = await fs.readFile(configPath, "utf-8");
476+
const normalizedFileContent = stripUtf8Bom(fileContent);
477+
const parsed = JSON.parse(normalizedFileContent) as unknown;
478+
if (!isRecord(parsed)) {
479+
const errorMessage = `Config at ${configPath} must contain a JSON object at the root.`;
480+
logConfigWarnOnce(errorMessage);
481+
return { status: "invalid", errorMessage };
482+
}
483+
return { status: "ok", record: parsed };
484+
} catch (error) {
485+
const code = (error as NodeJS.ErrnoException | undefined)?.code;
486+
if (code === "ENOENT") {
487+
return { status: "missing" };
488+
}
489+
if (
490+
typeof code === "string" &&
491+
RETRYABLE_CONFIG_READ_CODES.has(code) &&
492+
attempt < 4
493+
) {
494+
await sleep(10 * 2 ** attempt);
495+
continue;
496+
}
497+
const errorMessage = `Failed to read config from ${configPath}: ${
498+
error instanceof Error ? error.message : String(error)
499+
}`;
500+
logConfigWarnOnce(errorMessage);
501+
if (typeof code === "string" && RETRYABLE_CONFIG_READ_CODES.has(code)) {
502+
return { status: "unreadable", errorMessage };
503+
}
504+
return { status: "invalid", errorMessage };
505+
}
506+
}
507+
508+
const errorMessage = `Failed to read config from ${configPath}.`;
509+
logConfigWarnOnce(errorMessage);
510+
return { status: "unreadable", errorMessage };
511+
}
512+
459513
function resolveStoredPluginConfigRecord(): {
460514
configPath: string | null;
461515
storageKind: ConfigExplainStorageKind;
@@ -507,10 +561,17 @@ function resolveStoredPluginConfigRecord(): {
507561
*/
508562
function sanitizePluginConfigForSave(
509563
config: Partial<PluginConfig>,
510-
): Record<string, unknown> {
564+
): { sanitized: Record<string, unknown>; droppedKeys: string[] } {
511565
const normalized = sanitizePluginConfigRecord(config as Record<string, unknown>);
512566
const entries = Object.entries((normalized ?? {}) as Record<string, unknown>);
513567
const sanitized: Record<string, unknown> = {};
568+
const inputRecord = isRecord(config) ? config : {};
569+
const droppedKeys = PLUGIN_CONFIG_KEYS.filter((key) => {
570+
if (!(key in inputRecord) || inputRecord[key] === undefined) {
571+
return false;
572+
}
573+
return !(key in (normalized ?? {}));
574+
});
514575
for (const [key, value] of entries) {
515576
if (value === undefined) continue;
516577
if (typeof value === "number" && !Number.isFinite(value)) continue;
@@ -520,7 +581,7 @@ function sanitizePluginConfigForSave(
520581
}
521582
sanitized[key] = value;
522583
}
523-
return sanitized;
584+
return { sanitized, droppedKeys };
524585
}
525586

526587
/**
@@ -539,14 +600,27 @@ function sanitizePluginConfigForSave(
539600
export async function savePluginConfig(
540601
configPatch: Partial<PluginConfig>,
541602
): Promise<void> {
542-
const sanitizedPatch = sanitizePluginConfigForSave(configPatch);
603+
const { sanitized: sanitizedPatch, droppedKeys } =
604+
sanitizePluginConfigForSave(configPatch);
605+
if (droppedKeys.length > 0) {
606+
logConfigWarnOnce(
607+
`Ignoring invalid plugin config field(s): ${droppedKeys.join(", ")}.`,
608+
);
609+
}
543610
const envPath = (process.env.CODEX_MULTI_AUTH_CONFIG_PATH ?? "").trim();
544611

545612
if (envPath.length > 0) {
546613
await withConfigSaveLock(envPath, async () => {
547-
const existingConfig = sanitizeStoredPluginConfigRecord(
548-
readConfigRecordFromPath(envPath),
549-
);
614+
const envConfigState = await readConfigRecordForSave(envPath);
615+
if (envConfigState.status === "unreadable") {
616+
throw new Error(
617+
`Aborting config save because ${envPath} is unreadable.`,
618+
);
619+
}
620+
const existingConfig =
621+
envConfigState.status === "ok"
622+
? sanitizeStoredPluginConfigRecord(envConfigState.record)
623+
: null;
550624
const merged = {
551625
...(existingConfig ?? {}),
552626
...sanitizedPatch,
@@ -558,14 +632,34 @@ export async function savePluginConfig(
558632

559633
const unifiedPath = getUnifiedSettingsPath();
560634
await withConfigSaveLock(unifiedPath, async () => {
561-
const unifiedConfigRecord = loadUnifiedPluginConfigSync();
562-
const unifiedConfig = sanitizeStoredPluginConfigRecord(
563-
unifiedConfigRecord,
564-
);
565-
const legacyPath = unifiedConfigRecord ? null : resolvePluginConfigPath();
566-
const legacyConfig = legacyPath
567-
? sanitizeStoredPluginConfigRecord(readConfigRecordFromPath(legacyPath))
635+
const unifiedConfigState = await readConfigRecordForSave(unifiedPath);
636+
if (unifiedConfigState.status === "unreadable") {
637+
throw new Error(
638+
`Aborting config save because ${unifiedPath} is unreadable.`,
639+
);
640+
}
641+
const unifiedConfigRecord =
642+
unifiedConfigState.status === "ok"
643+
? unifiedConfigState.record.pluginConfig
644+
: loadUnifiedPluginConfigSync();
645+
const unifiedConfig = sanitizeStoredPluginConfigRecord(unifiedConfigRecord);
646+
const legacyPath =
647+
unifiedConfigState.status === "missing" ||
648+
(unifiedConfigState.status === "ok" && !unifiedConfig)
649+
? resolvePluginConfigPath()
650+
: null;
651+
const legacyConfigState = legacyPath
652+
? await readConfigRecordForSave(legacyPath)
568653
: null;
654+
if (legacyConfigState?.status === "unreadable") {
655+
throw new Error(
656+
`Aborting config save because ${legacyPath} is unreadable.`,
657+
);
658+
}
659+
const legacyConfig =
660+
legacyConfigState?.status === "ok"
661+
? sanitizeStoredPluginConfigRecord(legacyConfigState.record)
662+
: null;
569663
const merged = {
570664
...(unifiedConfig ??
571665
legacyConfig ??

lib/storage/flagged-storage-io.ts

Lines changed: 26 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,13 +2,37 @@ import { existsSync, promises as fs } from "node:fs";
22
import { dirname } from "node:path";
33
import type { FlaggedAccountStorageV1 } from "../storage.js";
44

5+
const RETRYABLE_UNLINK_CODES = new Set(["EBUSY", "EAGAIN", "EPERM"]);
6+
57
/**
68
* Return the ordered backup paths consulted for flagged-account recovery.
79
*/
810
function getFlaggedBackupPaths(path: string): string[] {
911
return [`${path}.bak`, `${path}.bak.1`, `${path}.bak.2`];
1012
}
1113

14+
async function unlinkWithRetry(candidatePath: string): Promise<void> {
15+
for (let attempt = 0; attempt < 5; attempt += 1) {
16+
try {
17+
await fs.unlink(candidatePath);
18+
return;
19+
} catch (error) {
20+
const code = (error as NodeJS.ErrnoException).code;
21+
if (code === "ENOENT") {
22+
return;
23+
}
24+
if (
25+
!code ||
26+
!RETRYABLE_UNLINK_CODES.has(code) ||
27+
attempt >= 4
28+
) {
29+
throw error;
30+
}
31+
await new Promise((resolve) => setTimeout(resolve, 10 * 2 ** attempt));
32+
}
33+
}
34+
}
35+
1236
export async function loadFlaggedAccountsState(params: {
1337
path: string;
1438
legacyPath: string;
@@ -191,7 +215,7 @@ export async function clearFlaggedAccountsOnDisk(params: {
191215
...params.backupPaths,
192216
]) {
193217
try {
194-
await fs.unlink(candidate);
218+
await unlinkWithRetry(candidate);
195219
} catch (error) {
196220
const code = (error as NodeJS.ErrnoException).code;
197221
if (code !== "ENOENT") {
@@ -208,7 +232,7 @@ export async function clearFlaggedAccountsOnDisk(params: {
208232
}
209233
if (!keepResetMarker) {
210234
try {
211-
await fs.unlink(params.markerPath);
235+
await unlinkWithRetry(params.markerPath);
212236
} catch (error) {
213237
const code = (error as NodeJS.ErrnoException).code;
214238
if (code !== "ENOENT") {

test/accounts.test.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3221,6 +3221,7 @@ describe("AccountManager", () => {
32213221
expires: now + 3600000,
32223222
});
32233223

3224+
<<<<<<< HEAD
32243225
expect(account.accountId).toBe("account-enriched");
32253226
expect(account.email).toBe("enriched@example.com");
32263227
expect(getRuntimeAccountIdentityKey(account)).toBe(
@@ -3229,7 +3230,7 @@ describe("AccountManager", () => {
32293230
expect(getRuntimeTrackerKey(account)).toBe(trackerKey);
32303231
expect(healthTracker.getScore(trackerKey, "codex:gpt-5.1")).toBeCloseTo(
32313232
degradedScore,
3232-
6,
3233+
5,
32333234
);
32343235
expect(tokenTracker.getTokens(trackerKey, "codex:gpt-5.1")).toBeLessThan(50);
32353236
} finally {

0 commit comments

Comments
 (0)