Skip to content

Commit addf0ae

Browse files
committed
fix: tighten config and recovery review follow-ups
1 parent 940d91a commit addf0ae

9 files changed

Lines changed: 368 additions & 14 deletions

docs/reference/storage-paths.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,12 @@ Backup metadata:
5252
- `settings.json.bak` stores the last valid unified settings snapshot before each write and is used as a recovery fallback when `settings.json` is unreadable.
5353
- Flagged-account backup recovery is suppressed whenever the flagged reset marker is still present, so partial clears cannot revive previously cleared flagged entries.
5454

55+
Upgrade note:
56+
57+
- Restore workflows now distinguish between unreadable state and intentionally cleared state. `settings.json.bak` is only used when `settings.json` exists but cannot be read, while flagged-account backups stay suppressed whenever the reset marker survives a partial clear.
58+
- Operators validating a restore or clear flow should use `codex auth verify-flagged`, `codex auth fix --dry-run`, and `codex auth doctor --fix` to confirm what will be recovered, what stays cleared, and whether manual repair is still needed.
59+
- Maintainers validating the on-disk upgrade behavior can run `npm run build` plus `npm test -- --run test/unified-settings.test.ts test/storage-recovery-paths.test.ts test/storage-flagged.test.ts` before shipping backup or restore changes.
60+
5561
---
5662

5763
## Project-Scoped Account Paths

lib/config.ts

Lines changed: 22 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -320,11 +320,18 @@ function sanitizePluginConfigRecord(
320320
return sanitized as Partial<PluginConfig>;
321321
}
322322

323+
/**
324+
* Sanitize a stored plugin-config record while preserving unknown keys.
325+
*
326+
* Known plugin-config keys are schema-validated before they are merged back
327+
* into runtime state. Unknown keys are kept so legacy and forward-compatible
328+
* settings survive env-path saves.
329+
*/
323330
function sanitizeStoredPluginConfigRecord(
324331
data: unknown,
325-
): Record<string, unknown> {
332+
): Record<string, unknown> | null {
326333
if (!isRecord(data)) {
327-
return {};
334+
return null;
328335
}
329336

330337
const sanitized: Record<string, unknown> = {};
@@ -551,10 +558,11 @@ export async function savePluginConfig(
551558

552559
const unifiedPath = getUnifiedSettingsPath();
553560
await withConfigSaveLock(unifiedPath, async () => {
561+
const unifiedConfigRecord = loadUnifiedPluginConfigSync();
554562
const unifiedConfig = sanitizeStoredPluginConfigRecord(
555-
loadUnifiedPluginConfigSync(),
563+
unifiedConfigRecord,
556564
);
557-
const legacyPath = unifiedConfig ? null : resolvePluginConfigPath();
565+
const legacyPath = unifiedConfigRecord ? null : resolvePluginConfigPath();
558566
const legacyConfig = legacyPath
559567
? sanitizeStoredPluginConfigRecord(readConfigRecordFromPath(legacyPath))
560568
: null;
@@ -631,7 +639,11 @@ function resolveBooleanSetting(
631639
): boolean {
632640
const rawEnvValue = process.env[envName];
633641
const envValue = parseBooleanEnv(rawEnvValue);
634-
if (rawEnvValue !== undefined && envValue === undefined) {
642+
if (
643+
rawEnvValue !== undefined &&
644+
rawEnvValue.trim().length > 0 &&
645+
envValue === undefined
646+
) {
635647
logConfigWarnOnce(
636648
`Ignoring invalid boolean env ${envName}=${JSON.stringify(rawEnvValue)}. Expected 0/1, true/false, or yes/no.`,
637649
);
@@ -659,7 +671,11 @@ function resolveNumberSetting(
659671
): number {
660672
const rawEnvValue = process.env[envName];
661673
const envValue = parseNumberEnv(rawEnvValue);
662-
if (rawEnvValue !== undefined && envValue === undefined) {
674+
if (
675+
rawEnvValue !== undefined &&
676+
rawEnvValue.trim().length > 0 &&
677+
envValue === undefined
678+
) {
663679
logConfigWarnOnce(
664680
`Ignoring invalid numeric env ${envName}=${JSON.stringify(rawEnvValue)}. Expected a finite number.`,
665681
);

lib/storage/flagged-storage-io.ts

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

5+
/**
6+
* Return the ordered backup paths consulted for flagged-account recovery.
7+
*/
58
function getFlaggedBackupPaths(path: string): string[] {
69
return [`${path}.bak`, `${path}.bak.1`, `${path}.bak.2`];
710
}
@@ -28,6 +31,9 @@ export async function loadFlaggedAccountsState(params: {
2831
const backupContent = await fs.readFile(backupPath, "utf-8");
2932
const backupData = JSON.parse(backupContent) as unknown;
3033
const recovered = params.normalizeFlaggedStorage(backupData);
34+
if (existsSync(params.resetMarkerPath)) {
35+
return empty;
36+
}
3137
params.logInfo("Recovered flagged account storage from backup", {
3238
from: backupPath,
3339
to: params.path,

lib/unified-settings.ts

Lines changed: 73 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,9 @@ export const UNIFIED_SETTINGS_VERSION = 1 as const;
1919
const UNIFIED_SETTINGS_PATH = join(getCodexMultiAuthDir(), "settings.json");
2020
const UNIFIED_SETTINGS_BACKUP_PATH = `${UNIFIED_SETTINGS_PATH}.bak`;
2121
const RETRYABLE_FS_CODES = new Set(["EBUSY", "EPERM"]);
22+
const TRANSIENT_READ_FS_CODES = new Set(["EBUSY", "EAGAIN"]);
2223
let settingsWriteQueue: Promise<void> = Promise.resolve();
24+
let backupDerivedReadPending = false;
2325

2426
function isRetryableFsError(error: unknown): boolean {
2527
const code = (error as NodeJS.ErrnoException | undefined)?.code;
@@ -55,13 +57,22 @@ function parseSettingsRecord(content: string): JsonRecord {
5557
return parsed;
5658
}
5759

60+
/**
61+
* Read a unified-settings record from a specific path.
62+
*
63+
* Returns `null` when the file is absent and throws when the file exists but
64+
* cannot be parsed into the expected object shape.
65+
*/
5866
function readSettingsRecordSyncFromPath(filePath: string): JsonRecord | null {
5967
if (!existsSync(filePath)) {
6068
return null;
6169
}
6270
return parseSettingsRecord(readFileSync(filePath, "utf8"));
6371
}
6472

73+
/**
74+
* Async variant of `readSettingsRecordSyncFromPath`.
75+
*/
6576
async function readSettingsRecordAsyncFromPath(
6677
filePath: string,
6778
): Promise<JsonRecord | null> {
@@ -71,6 +82,12 @@ async function readSettingsRecordAsyncFromPath(
7182
return parseSettingsRecord(await fs.readFile(filePath, "utf8"));
7283
}
7384

85+
/**
86+
* Best-effort backup reader for sync callers.
87+
*
88+
* Backup corruption is treated as an unavailable backup so callers can keep
89+
* their legacy null-on-unavailable behavior.
90+
*/
7491
function readSettingsBackupSync(): JsonRecord | null {
7592
try {
7693
return readSettingsRecordSyncFromPath(UNIFIED_SETTINGS_BACKUP_PATH);
@@ -79,6 +96,9 @@ function readSettingsBackupSync(): JsonRecord | null {
7996
}
8097
}
8198

99+
/**
100+
* Best-effort backup reader for async callers.
101+
*/
82102
async function readSettingsBackupAsync(): Promise<JsonRecord | null> {
83103
try {
84104
return await readSettingsRecordAsyncFromPath(UNIFIED_SETTINGS_BACKUP_PATH);
@@ -87,7 +107,40 @@ async function readSettingsBackupAsync(): Promise<JsonRecord | null> {
87107
}
88108
}
89109

110+
/**
111+
* Decide whether a primary settings read should fall back to the backup file.
112+
*
113+
* Missing primaries stay missing so callers do not merge stale backup state.
114+
* Corrupt or unreadable primaries may fall back, while transient lock errors
115+
* are rethrown so callers can retry.
116+
*/
117+
function shouldFallbackToSettingsBackup(
118+
primaryExists: boolean,
119+
error: unknown,
120+
): boolean {
121+
if (!primaryExists) {
122+
return false;
123+
}
124+
const code = (error as NodeJS.ErrnoException | undefined)?.code;
125+
if (code === "ENOENT") {
126+
return false;
127+
}
128+
if (typeof code === "string" && TRANSIENT_READ_FS_CODES.has(code)) {
129+
return false;
130+
}
131+
return true;
132+
}
133+
134+
/**
135+
* Snapshot the primary settings file into `settings.json.bak` for sync writes.
136+
*
137+
* When the current in-memory state was recovered from backup, snapshotting is
138+
* skipped so a corrupt primary cannot overwrite the only known-good backup.
139+
*/
90140
function trySnapshotUnifiedSettingsBackupSync(): void {
141+
if (backupDerivedReadPending) {
142+
return;
143+
}
91144
if (!existsSync(UNIFIED_SETTINGS_PATH)) {
92145
return;
93146
}
@@ -104,7 +157,13 @@ function trySnapshotUnifiedSettingsBackupSync(): void {
104157
}
105158
}
106159

160+
/**
161+
* Async variant of `trySnapshotUnifiedSettingsBackupSync`.
162+
*/
107163
async function trySnapshotUnifiedSettingsBackupAsync(): Promise<void> {
164+
if (backupDerivedReadPending) {
165+
return;
166+
}
108167
if (!existsSync(UNIFIED_SETTINGS_PATH)) {
109168
return;
110169
}
@@ -132,20 +191,25 @@ async function trySnapshotUnifiedSettingsBackupAsync(): Promise<void> {
132191
* - Sensitive data: this function performs no token or secret redaction; any sensitive values present in the file are returned as-is and callers are responsible for redaction before logging or external exposure.
133192
*/
134193
function readSettingsRecordSync(): JsonRecord | null {
194+
const primaryExists = existsSync(UNIFIED_SETTINGS_PATH);
135195
try {
136196
const primaryRecord = readSettingsRecordSyncFromPath(UNIFIED_SETTINGS_PATH);
137197
if (primaryRecord) {
138198
return primaryRecord;
139199
}
140200
} catch (error) {
201+
if (!shouldFallbackToSettingsBackup(primaryExists, error)) {
202+
throw error;
203+
}
141204
const backupRecord = readSettingsBackupSync();
142205
if (backupRecord) {
206+
backupDerivedReadPending = true;
143207
return backupRecord;
144208
}
145209
throw error;
146210
}
147211

148-
return readSettingsBackupSync();
212+
return null;
149213
}
150214

151215
/**
@@ -156,6 +220,7 @@ function readSettingsRecordSync(): JsonRecord | null {
156220
* @returns The parsed settings record as an object clone, or `null` if unavailable or invalid.
157221
*/
158222
async function readSettingsRecordAsync(): Promise<JsonRecord | null> {
223+
const primaryExists = existsSync(UNIFIED_SETTINGS_PATH);
159224
try {
160225
const primaryRecord = await readSettingsRecordAsyncFromPath(
161226
UNIFIED_SETTINGS_PATH,
@@ -164,14 +229,18 @@ async function readSettingsRecordAsync(): Promise<JsonRecord | null> {
164229
return primaryRecord;
165230
}
166231
} catch (error) {
232+
if (!shouldFallbackToSettingsBackup(primaryExists, error)) {
233+
throw error;
234+
}
167235
const backupRecord = await readSettingsBackupAsync();
168236
if (backupRecord) {
237+
backupDerivedReadPending = true;
169238
return backupRecord;
170239
}
171240
throw error;
172241
}
173242

174-
return readSettingsBackupAsync();
243+
return null;
175244
}
176245

177246
/**
@@ -219,6 +288,7 @@ function writeSettingsRecordSync(record: JsonRecord): void {
219288
try {
220289
renameSync(tempPath, UNIFIED_SETTINGS_PATH);
221290
moved = true;
291+
backupDerivedReadPending = false;
222292
return;
223293
} catch (error) {
224294
if (!isRetryableFsError(error) || attempt >= 4) {
@@ -271,6 +341,7 @@ async function writeSettingsRecordAsync(record: JsonRecord): Promise<void> {
271341
try {
272342
await fs.rename(tempPath, UNIFIED_SETTINGS_PATH);
273343
moved = true;
344+
backupDerivedReadPending = false;
274345
return;
275346
} catch (error) {
276347
if (!isRetryableFsError(error) || attempt >= 4) {

test/config-save.test.ts

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -161,6 +161,35 @@ describe("plugin config save paths", () => {
161161
).toBe(1);
162162
});
163163

164+
it("does not warn for blank numeric env overrides", async () => {
165+
process.env.CODEX_AUTH_PARALLEL_PROBING_MAX_CONCURRENCY = "";
166+
vi.resetModules();
167+
const logWarnMock = vi.fn();
168+
169+
vi.doMock("../lib/logger.js", async () => {
170+
const actual =
171+
await vi.importActual<typeof import("../lib/logger.js")>(
172+
"../lib/logger.js",
173+
);
174+
return {
175+
...actual,
176+
logWarn: logWarnMock,
177+
};
178+
});
179+
180+
try {
181+
const { getParallelProbingMaxConcurrency } = await import(
182+
"../lib/config.js"
183+
);
184+
expect(
185+
getParallelProbingMaxConcurrency({ parallelProbingMaxConcurrency: 4 }),
186+
).toBe(4);
187+
expect(logWarnMock).not.toHaveBeenCalled();
188+
} finally {
189+
vi.doUnmock("../lib/logger.js");
190+
}
191+
});
192+
164193
it("normalizes fallback chain and drops invalid entries", async () => {
165194
const { getUnsupportedCodexFallbackChain } =
166195
await import("../lib/config.js");

test/plugin-config.test.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -663,11 +663,11 @@ describe('Plugin Configuration', () => {
663663

664664
it('should ignore invalid env values and fall back to config/default', () => {
665665
process.env.CODEX_MODE = 'maybe';
666-
const config: PluginConfig = { codexMode: true };
666+
const config: PluginConfig = { codexMode: false };
667667

668668
const result = getCodexMode(config);
669669

670-
expect(result).toBe(true);
670+
expect(result).toBe(false);
671671
});
672672

673673
it('should use config codexMode=true when explicitly set', () => {
@@ -1036,6 +1036,7 @@ describe('Plugin Configuration', () => {
10361036
process.env.CODEX_AUTH_FETCH_TIMEOUT_MS = '';
10371037
expect(getFetchTimeoutMs({ fetchTimeoutMs: 90000 })).toBe(90000);
10381038
delete process.env.CODEX_AUTH_FETCH_TIMEOUT_MS;
1039+
expect(getFetchTimeoutMs({})).toBe(60000);
10391040
});
10401041

10411042
it('should read stream stall timeout from env', () => {

test/storage-flagged.test.ts

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -341,6 +341,43 @@ describe("flagged account storage", () => {
341341
readSpy.mockRestore();
342342
});
343343

344+
it("honors the reset marker even when it appears during backup recovery", async () => {
345+
const backupPath = `${getFlaggedAccountsPath()}.bak`;
346+
const resetMarkerPath = `${getFlaggedAccountsPath()}.reset-intent`;
347+
await fs.writeFile(
348+
backupPath,
349+
JSON.stringify({
350+
version: 1,
351+
accounts: [
352+
{
353+
refreshToken: "backup-race",
354+
flaggedAt: 1,
355+
addedAt: 1,
356+
lastUsed: 1,
357+
},
358+
],
359+
}),
360+
"utf8",
361+
);
362+
363+
const originalReadFile = fs.readFile.bind(fs);
364+
const readSpy = vi.spyOn(fs, "readFile").mockImplementation(async (...args) => {
365+
const [targetPath] = args;
366+
const result = await originalReadFile(...args);
367+
if (targetPath === backupPath) {
368+
await fs.writeFile(resetMarkerPath, "reset", "utf8");
369+
}
370+
return result;
371+
});
372+
373+
try {
374+
const flagged = await loadFlaggedAccounts();
375+
expect(flagged.accounts).toHaveLength(0);
376+
} finally {
377+
readSpy.mockRestore();
378+
}
379+
});
380+
344381
it("clears discovered flagged backup artifacts so manual snapshots cannot revive after clear", async () => {
345382
await saveFlaggedAccounts({
346383
version: 1,

0 commit comments

Comments
 (0)