diff --git a/README.md b/README.md index cc710369a..d55079b21 100644 --- a/README.md +++ b/README.md @@ -308,7 +308,7 @@ codex auth doctor --json ## Release Notes -- Current stable: [docs/releases/v1.2.3.md](docs/releases/v1.2.3.md) +- Current stable: [docs/releases/v1.2.4.md](docs/releases/v1.2.4.md) - Previous stable: [docs/releases/v1.2.2.md](docs/releases/v1.2.2.md) - Earlier stable: [docs/releases/v1.2.1.md](docs/releases/v1.2.1.md) - Archived prerelease: [docs/releases/v0.1.0-beta.0.md](docs/releases/v0.1.0-beta.0.md) diff --git a/docs/README.md b/docs/README.md index 6ff444e7c..c69099b64 100644 --- a/docs/README.md +++ b/docs/README.md @@ -23,7 +23,7 @@ Public documentation for `codex-multi-auth`. | [configuration.md](configuration.md) | Stable defaults, precedence, and environment overrides | | [architecture.md](architecture.md) | Public system overview of the wrapper, storage, and optional plugin runtime | | [privacy.md](privacy.md) | Data handling and local storage behavior | -| [releases/v1.2.3.md](releases/v1.2.3.md) | Stable release notes | +| [releases/v1.2.4.md](releases/v1.2.4.md) | Stable release notes | | [releases/v1.2.2.md](releases/v1.2.2.md) | Previous stable release notes | | [releases/v1.2.1.md](releases/v1.2.1.md) | Earlier stable release notes | | [releases/v1.2.0.md](releases/v1.2.0.md) | Archived stable release notes | @@ -52,7 +52,7 @@ Public documentation for `codex-multi-auth`. | [reference/storage-paths.md](reference/storage-paths.md) | Canonical and compatibility storage paths | | [reference/public-api.md](reference/public-api.md) | Public API stability and semver contract | | [reference/error-contracts.md](reference/error-contracts.md) | CLI, JSON, and helper error semantics | -| [releases/v1.2.3.md](releases/v1.2.3.md) | Current stable release notes | +| [releases/v1.2.4.md](releases/v1.2.4.md) | Current stable release notes | | [releases/v0.1.0-beta.0.md](releases/v0.1.0-beta.0.md) | Archived prerelease reference | | [Daily Use release notes](#daily-use) | Stable, previous, and archived release notes | | [releases/legacy-pre-0.1-history.md](releases/legacy-pre-0.1-history.md) | Archived pre-0.1 changelog history | diff --git a/docs/releases/v1.2.4.md b/docs/releases/v1.2.4.md new file mode 100644 index 000000000..f1d7d884e --- /dev/null +++ b/docs/releases/v1.2.4.md @@ -0,0 +1,35 @@ +# Release v1.2.4 + +Release line: `stable` + +This patch release follows the already-published `v1.2.3` rebuild and lands the post-merge review fixes that were raised afterward. + +## Scope + +- Current package version in `package.json` is `1.2.4`. +- Canonical command family remains `codex auth ...`. +- Canonical package name remains `codex-multi-auth`. +- This patch is prepared from `main` after merge commit `c1da059852d698fd53014b9b529eeeeb2db1d39d`. + +## What Changed + +- preserved standalone `config.json` values when unified settings are malformed, and hardened unified-settings writes so invalid primaries can be rebuilt safely without masking real unreadable-file errors +- retried flagged-account primary, backup, and legacy reads before fallback so transient Windows file locks do not trigger unnecessary recovery paths +- avoided capability-policy failure penalties on fallback stream-failover `429` responses +- retried shadow-home sync-back renames in the Codex wrapper so transient `EBUSY` and `EPERM` locks do not drop auth-state sync +- removed scheduler-fragile midpoint assertions from quota-refresh CLI tests and tightened dashboard/unified-settings regressions around the real legacy/unified read paths + +## Validation + +- `npm run lint` +- `npm run typecheck` +- `npm run build` +- `npm test` +- Full suite passed: `222/222` files, `3307/3307` tests + +## Related + +- [v1.2.3.md](v1.2.3.md) +- [../getting-started.md](../getting-started.md) +- [../upgrade.md](../upgrade.md) +- [../reference/commands.md](../reference/commands.md) diff --git a/index.ts b/index.ts index da086e20e..e705bbb6f 100644 --- a/index.ts +++ b/index.ts @@ -2192,11 +2192,11 @@ export const OpenAIOAuthPlugin: Plugin = async ({ client }: PluginInput) => { modelFamily, model, ); + capabilityPolicyStore.recordFailure( + fallbackEntitlementAccountKey, + capabilityModelKey, + ); } - capabilityPolicyStore.recordFailure( - fallbackEntitlementAccountKey, - capabilityModelKey, - ); continue; } diff --git a/lib/config.ts b/lib/config.ts index b82f0731d..fbbf932fa 100644 --- a/lib/config.ts +++ b/lib/config.ts @@ -647,10 +647,7 @@ export async function savePluginConfig( : loadUnifiedPluginConfigSync(); const unifiedConfig = sanitizeStoredPluginConfigRecord(unifiedConfigRecord); const legacyPath = - unifiedConfigState.status === "missing" || - (unifiedConfigState.status === "ok" && !unifiedConfig) - ? resolvePluginConfigPath() - : null; + unifiedConfig === null ? resolvePluginConfigPath() : null; const legacyConfigState = legacyPath ? await readConfigRecordForSave(legacyPath) : null; diff --git a/lib/storage/flagged-storage-file.ts b/lib/storage/flagged-storage-file.ts index 50f7b6638..af6ab0551 100644 --- a/lib/storage/flagged-storage-file.ts +++ b/lib/storage/flagged-storage-file.ts @@ -8,7 +8,7 @@ function isRetryableReadError(error: unknown): boolean { return typeof code === "string" && RETRYABLE_READ_CODES.has(code); } -async function readFileWithRetry( +export async function readFileWithRetry( path: string, deps: { readFile: typeof import("node:fs").promises.readFile; diff --git a/lib/storage/flagged-storage-io.ts b/lib/storage/flagged-storage-io.ts index a866d86bd..251bd5001 100644 --- a/lib/storage/flagged-storage-io.ts +++ b/lib/storage/flagged-storage-io.ts @@ -1,6 +1,7 @@ import { existsSync, promises as fs } from "node:fs"; import { dirname } from "node:path"; import type { FlaggedAccountStorageV1 } from "../storage.js"; +import { readFileWithRetry } from "./flagged-storage-file.js"; const RETRYABLE_UNLINK_CODES = new Set(["EBUSY", "EAGAIN", "EPERM"]); @@ -75,7 +76,9 @@ export async function loadFlaggedAccountsState(params: { continue; } try { - const backupContent = await fs.readFile(backupPath, "utf-8"); + const backupContent = await readFileWithRetry(backupPath, { + readFile: fs.readFile, + }); const backupData = JSON.parse(backupContent) as unknown; const recovered = params.normalizeFlaggedStorage(backupData); if (!isValidFlaggedStorageCandidate(backupData, recovered)) { @@ -103,6 +106,7 @@ export async function loadFlaggedAccountsState(params: { to: params.path, error: String(persistError), }); + return recovered; } } params.logInfo("Recovered flagged account storage from backup", { @@ -123,7 +127,9 @@ export async function loadFlaggedAccountsState(params: { }; try { - const content = await fs.readFile(params.path, "utf-8"); + const content = await readFileWithRetry(params.path, { + readFile: fs.readFile, + }); const data = JSON.parse(content) as unknown; const loaded = params.normalizeFlaggedStorage(data); if (!isValidFlaggedStorageCandidate(data, loaded)) { @@ -154,7 +160,9 @@ export async function loadFlaggedAccountsState(params: { } try { - const legacyContent = await fs.readFile(params.legacyPath, "utf-8"); + const legacyContent = await readFileWithRetry(params.legacyPath, { + readFile: fs.readFile, + }); const legacyData = JSON.parse(legacyContent) as unknown; const migrated = params.normalizeFlaggedStorage(legacyData); if (migrated.accounts.length > 0) { diff --git a/lib/unified-settings.ts b/lib/unified-settings.ts index c8c703ef7..d0303b229 100644 --- a/lib/unified-settings.ts +++ b/lib/unified-settings.ts @@ -53,10 +53,16 @@ function cloneRecord(value: unknown): JsonRecord | null { return { ...value }; } +class InvalidSettingsRecordError extends Error { + override readonly name = "InvalidSettingsRecordError"; +} + function parseSettingsRecord(content: string): JsonRecord { const parsed = cloneRecord(JSON.parse(content)); if (!parsed) { - throw new Error("Unified settings must contain a JSON object at the root."); + throw new InvalidSettingsRecordError( + "Unified settings must contain a JSON object at the root.", + ); } return parsed; } @@ -98,24 +104,34 @@ async function readSettingsRecordAsyncFromPath( * Best-effort backup reader for sync callers. * * Backup corruption is treated as an unavailable backup so callers can keep - * their legacy null-on-unavailable behavior. + * their legacy null-on-unavailable behavior, but unreadable or locked backups + * still surface so writers do not rebuild from `{}` over a transient failure. */ function readSettingsBackupSync(): JsonRecord | null { try { return readSettingsRecordSyncFromPath(UNIFIED_SETTINGS_BACKUP_PATH); - } catch { - return null; + } catch (error) { + if (isInvalidSettingsRecordError(error)) { + return null; + } + throw error; } } /** * Best-effort backup reader for async callers. + * + * Like the sync variant, only corrupt backups are collapsed to `null`. + * Unreadable or locked backups are rethrown so callers can fail closed. */ async function readSettingsBackupAsync(): Promise { try { return await readSettingsRecordAsyncFromPath(UNIFIED_SETTINGS_BACKUP_PATH); - } catch { - return null; + } catch (error) { + if (isInvalidSettingsRecordError(error)) { + return null; + } + throw error; } } @@ -143,6 +159,13 @@ function shouldFallbackToSettingsBackup( return true; } +function isInvalidSettingsRecordError(error: unknown): boolean { + if (error instanceof SyntaxError) { + return true; + } + return error instanceof InvalidSettingsRecordError; +} + /** * Snapshot the primary settings file into `settings.json.bak` for sync writes. * @@ -221,6 +244,9 @@ function readSettingsRecordSyncInternal(): SettingsReadResult { if (backupRecord) { return { record: backupRecord, usedBackup: true }; } + if (isInvalidSettingsRecordError(error)) { + return { record: null, usedBackup: false }; + } throw error; } @@ -255,6 +281,9 @@ async function readSettingsRecordAsyncInternal(): Promise { if (backupRecord) { return { record: backupRecord, usedBackup: true }; } + if (isInvalidSettingsRecordError(error)) { + return { record: null, usedBackup: false }; + } throw error; } diff --git a/package-lock.json b/package-lock.json index a5479cb1b..7161b73f3 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "codex-multi-auth", - "version": "1.2.3", + "version": "1.2.4", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "codex-multi-auth", - "version": "1.2.3", + "version": "1.2.4", "bundleDependencies": [ "@codex-ai/plugin" ], diff --git a/package.json b/package.json index e658aca9c..1871e4c8e 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "codex-multi-auth", - "version": "1.2.3", + "version": "1.2.4", "description": "Multi-account OAuth manager and codex auth wrapper for the official @openai/codex CLI, with switching, health checks, and recovery tools", "main": "./dist/index.js", "types": "./dist/index.d.ts", diff --git a/scripts/codex.js b/scripts/codex.js index 7c515b289..49672fbe3 100755 --- a/scripts/codex.js +++ b/scripts/codex.js @@ -28,6 +28,12 @@ let shadowHomeCleanupBusyFailuresRemaining = Number.parseInt( process.env.CODEX_MULTI_AUTH_TEST_SHADOW_CLEANUP_BUSY_FAILURES ?? "0", 10, ); +let shadowHomeCleanupPreflightReadBusyFailuresRemaining = Number.parseInt( + process.env.CODEX_MULTI_AUTH_TEST_SHADOW_PREFLIGHT_READ_BUSY_FAILURES ?? "0", + 10, +); +const shadowHomeCleanupRetryMarkerDir = + (process.env.CODEX_MULTI_AUTH_TEST_SHADOW_RETRY_MARKER_DIR ?? "").trim(); function isRetryableShadowHomeCleanupError(error) { const code = error && typeof error === "object" && "code" in error ? error.code : undefined; @@ -44,12 +50,7 @@ function sleepSync(ms) { function removeDirectoryWithRetry(targetPath) { for (let attempt = 0; attempt <= SHADOW_HOME_CLEANUP_BACKOFF_MS.length; attempt += 1) { try { - if (shadowHomeCleanupBusyFailuresRemaining > 0) { - shadowHomeCleanupBusyFailuresRemaining -= 1; - const error = new Error("simulated busy cleanup"); - error.code = "EBUSY"; - throw error; - } + maybeThrowSimulatedShadowHomeBusyError(); rmSync(targetPath, { recursive: true, force: true }); return; } catch (error) { @@ -229,6 +230,80 @@ function addRequestedModelReasoningAliases(alias, normalizedModel) { } } +function maybeThrowSimulatedShadowHomeBusyError() { + if (shadowHomeCleanupBusyFailuresRemaining > 0) { + shadowHomeCleanupBusyFailuresRemaining -= 1; + const error = new Error("simulated busy shadow-home operation"); + error.code = "EBUSY"; + throw error; + } +} + +function maybeThrowSimulatedShadowHomePreflightReadBusyError() { + if (shadowHomeCleanupPreflightReadBusyFailuresRemaining > 0) { + shadowHomeCleanupPreflightReadBusyFailuresRemaining -= 1; + const error = new Error("simulated busy shadow-home preflight read"); + error.code = "EBUSY"; + throw error; + } +} + +function writeShadowHomeCleanupRetryMarker(destinationPath, attempt) { + if (shadowHomeCleanupRetryMarkerDir.length === 0) { + return; + } + try { + mkdirSync(shadowHomeCleanupRetryMarkerDir, { recursive: true }); + writeFileSync( + join( + shadowHomeCleanupRetryMarkerDir, + `${basename(destinationPath)}.retry-${attempt + 1}`, + ), + `${attempt + 1}\n`, + "utf8", + ); + } catch { + // Best-effort test hook only. + } +} + +function ensureShadowHomeDestinationMatchesSnapshot(destinationPath, expectedState) { + if (!expectedState) { + return; + } + const currentState = captureShadowHomeState(destinationPath, { + rethrowRetryableReadErrors: true, + }); + if (!shadowHomeStateMatches(currentState, expectedState)) { + const error = new Error("shadow-home destination changed during sync-back retry"); + error.code = "EEXIST"; + throw error; + } +} + +function renameFileWithRetry(sourcePath, destinationPath, expectedDestinationState) { + for (let attempt = 0; attempt <= SHADOW_HOME_CLEANUP_BACKOFF_MS.length; attempt += 1) { + try { + ensureShadowHomeDestinationMatchesSnapshot( + destinationPath, + expectedDestinationState, + ); + maybeThrowSimulatedShadowHomeBusyError(); + renameSync(sourcePath, destinationPath); + return; + } catch (error) { + if ( + !isRetryableShadowHomeCleanupError(error) || + attempt === SHADOW_HOME_CLEANUP_BACKOFF_MS.length + ) { + throw error; + } + writeShadowHomeCleanupRetryMarker(destinationPath, attempt); + sleepSync(SHADOW_HOME_CLEANUP_BACKOFF_MS[attempt]); + } + } +} + function seedRequestedModelAliases() { addRequestedModelReasoningAliases("gpt-5.4", "gpt-5.4"); addRequestedModelReasoningAliases("gpt-5.4-pro", "gpt-5.4-pro"); @@ -488,16 +563,22 @@ function ensureTrailingNewline(value) { return value.endsWith("\n") ? value : `${value}\n`; } -function captureShadowHomeState(filePath) { +function captureShadowHomeState(filePath, options = {}) { try { if (!existsSync(filePath)) { return { exists: false, content: null }; } + if (options.rethrowRetryableReadErrors) { + maybeThrowSimulatedShadowHomePreflightReadBusyError(); + } return { exists: true, content: readFileSync(filePath, "utf8"), }; - } catch { + } catch (error) { + if (options.rethrowRetryableReadErrors && isRetryableShadowHomeCleanupError(error)) { + throw error; + } return { exists: true, content: null, unreadable: true }; } } @@ -510,7 +591,11 @@ function shadowHomeStateMatches(left, right) { ); } -function syncShadowHomeStateFile(sourcePath, destinationPath) { +function syncShadowHomeStateFile( + sourcePath, + destinationPath, + expectedDestinationState, +) { const tempPath = join( dirname(destinationPath), `.${basename(destinationPath)}.codex-multi-auth-sync-${process.pid}.tmp`, @@ -518,7 +603,7 @@ function syncShadowHomeStateFile(sourcePath, destinationPath) { try { mkdirSync(dirname(destinationPath), { recursive: true }); copyFileSync(sourcePath, tempPath); - renameSync(tempPath, destinationPath); + renameFileWithRetry(tempPath, destinationPath, expectedDestinationState); } catch (error) { try { rmSync(tempPath, { force: true }); @@ -640,7 +725,7 @@ function createCompatibilityCodexHome( if (shadowHomeStateMatches(shadowState, originalSnapshot)) { continue; } - syncShadowHomeStateFile(shadowPath, originalPath); + syncShadowHomeStateFile(shadowPath, originalPath, originalSnapshot); tightenShadowHomePermissions(originalPath); } catch { // Best-effort only; runtime auth refreshes should not fail cleanup. diff --git a/test/codex-bin-wrapper.test.ts b/test/codex-bin-wrapper.test.ts index 0568a0927..df9584574 100644 --- a/test/codex-bin-wrapper.test.ts +++ b/test/codex-bin-wrapper.test.ts @@ -94,6 +94,16 @@ function injectShadowCleanupBusyFailures( }; } +function injectShadowPreflightReadBusyFailures( + failuresBeforeSuccess = 2, +): NodeJS.ProcessEnv { + return { + CODEX_MULTI_AUTH_TEST_SHADOW_PREFLIGHT_READ_BUSY_FAILURES: String( + failuresBeforeSuccess, + ), + }; +} + function createFakeGlobalCodexInstall(rootDir: string): string { const fakeBin = join(rootDir, "@openai", "codex", "bin", "codex.js"); mkdirSync(dirname(fakeBin), { recursive: true }); @@ -526,6 +536,7 @@ describe("codex bin wrapper", () => { TMP: controlledTmp, TEMP: controlledTmp, TMPDIR: controlledTmp, + ...injectShadowCleanupBusyFailures(), }, ); @@ -535,6 +546,125 @@ describe("codex bin wrapper", () => { expect(readFileSync(join(originalHome, ".codex-global-state.json"), "utf8").trim()).toBe('{"last":"shadow"}'); }); + it("does not clobber sync-back files that change during rename retry backoff", () => { + const fixtureRoot = createWrapperFixture(); + const retryMarkerDir = join(fixtureRoot, "retry-markers"); + const accountsRetryMarker = join(retryMarkerDir, "accounts.json.retry-1"); + const fakeBin = createCustomFakeCodexBin(fixtureRoot, [ + "#!/usr/bin/env node", + 'const { spawn } = require("node:child_process");', + 'const fs = require("node:fs");', + 'const path = require("node:path");', + 'const home = process.env.CODEX_HOME ?? "";', + 'const retryMarker = process.env.CODEX_MULTI_AUTH_TEST_RETRY_MARKER ?? "";', + 'const originalHome = process.env.CODEX_MULTI_AUTH_TEST_EXTERNAL_HOME ?? "";', + 'fs.writeFileSync(path.join(home, "accounts.json"), \'{"accounts":["shadow"]}\\n\', "utf8");', + 'fs.writeFileSync(path.join(home, ".codex-global-state.json"), \'{"last":"shadow"}\\n\', "utf8");', + "if (originalHome && retryMarker) {", + " const mutateScript = [", + ' \'const fs = require("node:fs");\',', + ' \'const path = require("node:path");\',', + ' \'const markerPath = process.argv[1];\',', + ' \'const target = process.argv[2];\',', + ' \'const startedAt = Date.now();\',', + ' \'const waitForMarker = () => {\',', + ' \' if (fs.existsSync(markerPath)) {\',', + ' \' fs.writeFileSync(path.join(target, \"accounts.json\"), \"{\\\\\"accounts\\\\\":[\\\\\"external-during-retry\\\\\"]}\\\\n\", \"utf8\");\',', + ' \' fs.writeFileSync(path.join(target, \".codex-global-state.json\"), \"{\\\\\"last\\\\\":\\\\\"external-during-retry\\\\\"}\\\\n\", \"utf8\");\',', + ' \' process.exit(0);\',', + ' \' }\',', + ' \' if (Date.now() - startedAt > 5000) {\',', + ' \' process.exit(2);\',', + ' \' }\',', + ' \' setTimeout(waitForMarker, 5);\',', + ' \'};\',', + ' \'waitForMarker();\',', + " ].join(\"\\n\");", + " const mutator = spawn(process.execPath, [\"-e\", mutateScript, retryMarker, originalHome], {", + " detached: true,", + ' stdio: "ignore",', + " });", + " mutator.unref();", + "}", + "process.exit(0);", + ]); + const originalHome = join(fixtureRoot, "codex-home"); + const controlledTmp = join(fixtureRoot, "tmp"); + mkdirSync(originalHome, { recursive: true }); + mkdirSync(controlledTmp, { recursive: true }); + mkdirSync(retryMarkerDir, { recursive: true }); + writeFileSync(join(originalHome, "auth.json"), '{"token":"original"}\n', "utf8"); + writeFileSync(join(originalHome, "accounts.json"), '{"accounts":["original"]}\n', "utf8"); + writeFileSync(join(originalHome, ".codex-global-state.json"), '{"last":"original"}\n', "utf8"); + writeFileSync(join(originalHome, "config.toml"), 'model_reasoning_effort = "xhigh"\n', "utf8"); + + const result = runWrapper( + fixtureRoot, + ["exec", "status", "--model", "gpt-5.1"], + { + CODEX_MULTI_AUTH_REAL_CODEX_BIN: fakeBin, + CODEX_HOME: originalHome, + CODEX_MULTI_AUTH_TEST_EXTERNAL_HOME: originalHome, + CODEX_MULTI_AUTH_TEST_RETRY_MARKER: accountsRetryMarker, + CODEX_MULTI_AUTH_TEST_SHADOW_RETRY_MARKER_DIR: retryMarkerDir, + TMP: controlledTmp, + TEMP: controlledTmp, + TMPDIR: controlledTmp, + ...injectShadowCleanupBusyFailures(3), + }, + ); + + expect(result.status).toBe(0); + expect(readFileSync(join(originalHome, "auth.json"), "utf8").trim()).toBe('{"token":"original"}'); + expect(readFileSync(join(originalHome, "accounts.json"), "utf8").trim()).toBe( + '{"accounts":["external-during-retry"]}', + ); + expect( + readFileSync(join(originalHome, ".codex-global-state.json"), "utf8").trim(), + ).toBe('{"last":"external-during-retry"}'); + }); + + it("retries preflight destination reads when the sync-back target is transiently locked", () => { + const fixtureRoot = createWrapperFixture(); + const fakeBin = createCustomFakeCodexBin(fixtureRoot, [ + "#!/usr/bin/env node", + 'const fs = require("node:fs");', + 'const path = require("node:path");', + 'const home = process.env.CODEX_HOME ?? "";', + 'fs.writeFileSync(path.join(home, "auth.json"), \'{"token":"shadow"}\\n\', "utf8");', + 'fs.writeFileSync(path.join(home, "accounts.json"), \'{"accounts":["shadow"]}\\n\', "utf8");', + 'fs.writeFileSync(path.join(home, ".codex-global-state.json"), \'{"last":"shadow"}\\n\', "utf8");', + "process.exit(0);", + ]); + const originalHome = join(fixtureRoot, "codex-home"); + const controlledTmp = join(fixtureRoot, "tmp"); + mkdirSync(originalHome, { recursive: true }); + mkdirSync(controlledTmp, { recursive: true }); + writeFileSync(join(originalHome, "auth.json"), '{"token":"original"}\n', "utf8"); + writeFileSync(join(originalHome, "accounts.json"), '{"accounts":["original"]}\n', "utf8"); + writeFileSync(join(originalHome, ".codex-global-state.json"), '{"last":"original"}\n', "utf8"); + writeFileSync(join(originalHome, "config.toml"), 'model_reasoning_effort = "xhigh"\n', "utf8"); + + const result = runWrapper( + fixtureRoot, + ["exec", "status", "--model", "gpt-5.1"], + { + CODEX_MULTI_AUTH_REAL_CODEX_BIN: fakeBin, + CODEX_HOME: originalHome, + TMP: controlledTmp, + TEMP: controlledTmp, + TMPDIR: controlledTmp, + ...injectShadowCleanupBusyFailures(1), + ...injectShadowPreflightReadBusyFailures(2), + }, + ); + + expect(result.status).toBe(0); + expect(readFileSync(join(originalHome, "auth.json"), "utf8").trim()).toBe('{"token":"shadow"}'); + expect(readFileSync(join(originalHome, "accounts.json"), "utf8").trim()).toBe('{"accounts":["shadow"]}'); + expect(readFileSync(join(originalHome, ".codex-global-state.json"), "utf8").trim()).toBe('{"last":"shadow"}'); + }); + it("rewrites unquoted config reasoning effort values for mini compatibility models", () => { const fixtureRoot = createWrapperFixture(); const fakeBin = createCustomFakeCodexBin(fixtureRoot, [ diff --git a/test/codex-manager-cli.test.ts b/test/codex-manager-cli.test.ts index ca9c44c32..8f30b3952 100644 --- a/test/codex-manager-cli.test.ts +++ b/test/codex-manager-cli.test.ts @@ -7561,7 +7561,6 @@ describe("codex manager cli commands", () => { "becomes-degraded@example.com", ]); expect(typeof options?.statusMessage?.()).toBe("string"); - expect(fetchCodexQuotaSnapshotMock).toHaveBeenCalledTimes(3); releaseSecondRefresh.resolve(); await vi.waitFor(() => { @@ -7731,7 +7730,6 @@ describe("codex manager cli commands", () => { promptCallCount += 1; expect(promptCallCount).toBe(2); expect(typeof options?.statusMessage?.()).toBe("string"); - expect(fetchCodexQuotaSnapshotMock).toHaveBeenCalledTimes(3); releaseSecondRefresh.resolve(); await vi.waitFor(() => { diff --git a/test/config-save.test.ts b/test/config-save.test.ts index 5c33bbc05..580e2dd9b 100644 --- a/test/config-save.test.ts +++ b/test/config-save.test.ts @@ -296,6 +296,30 @@ describe("plugin config save paths", () => { expect(parsed.dashboardDisplaySettings).toEqual({ uiThemePreset: "green" }); }); + it("falls back to standalone config when unified settings are invalid", async () => { + delete process.env.CODEX_MULTI_AUTH_CONFIG_PATH; + const unifiedPath = join(tempDir, "settings.json"); + const standalonePath = join(tempDir, "config.json"); + await fs.writeFile(unifiedPath, "{ invalid json", "utf8"); + await fs.writeFile( + standalonePath, + JSON.stringify({ codexMode: true, preserved: 1 }), + "utf8", + ); + + const { savePluginConfig } = await import("../lib/config.js"); + await savePluginConfig({ fastSession: true }); + + const parsed = JSON.parse(await fs.readFile(unifiedPath, "utf8")) as { + pluginConfig?: Record; + }; + expect(parsed.pluginConfig).toEqual({ + codexMode: true, + preserved: 1, + fastSession: true, + }); + }); + it("resolves parallel probing settings and clamps concurrency", async () => { const { getParallelProbing, getParallelProbingMaxConcurrency } = await import("../lib/config.js"); diff --git a/test/dashboard-settings.test.ts b/test/dashboard-settings.test.ts index 008470e4b..544c39365 100644 --- a/test/dashboard-settings.test.ts +++ b/test/dashboard-settings.test.ts @@ -217,11 +217,21 @@ describe("dashboard settings", () => { const error = Object.assign(new Error("permission denied"), { code: "EACCES", }); - const readSpy = vi.spyOn(fs, "readFile").mockRejectedValueOnce(error); + const originalReadFile = fs.readFile.bind(fs); + const readSpy = vi.spyOn(fs, "readFile").mockImplementation(async (...args) => { + const [targetPath] = args; + if (targetPath === legacyPath) { + throw error; + } + return originalReadFile(...args); + }); - const loaded = await loadDashboardDisplaySettings(); - expect(loaded).toEqual(DEFAULT_DASHBOARD_DISPLAY_SETTINGS); - readSpy.mockRestore(); + try { + const loaded = await loadDashboardDisplaySettings(); + expect(loaded).toEqual(DEFAULT_DASHBOARD_DISPLAY_SETTINGS); + } finally { + readSpy.mockRestore(); + } }); it("falls back to defaults when legacy file contains malformed JSON", async () => { @@ -250,15 +260,23 @@ describe("dashboard settings", () => { const originalReadFile = fs.readFile.bind(fs); const readSpy = vi.spyOn(fs, "readFile"); const busy = Object.assign(new Error("busy"), { code: "EBUSY" }); - readSpy - .mockRejectedValueOnce(busy) - .mockImplementation(async (...args) => originalReadFile(...args)); + let legacyReadAttempts = 0; + readSpy.mockImplementation(async (...args) => { + const [targetPath] = args; + if (targetPath === legacyPath) { + legacyReadAttempts += 1; + if (legacyReadAttempts === 1) { + throw busy; + } + } + return originalReadFile(...args); + }); try { const loaded = await loadDashboardDisplaySettings(); expect(loaded.showPerAccountRows).toBe(false); expect(loaded.menuShowQuotaSummary).toBe(false); - expect(readSpy).toHaveBeenCalledTimes(2); + expect(legacyReadAttempts).toBe(2); } finally { readSpy.mockRestore(); } @@ -274,14 +292,23 @@ describe("dashboard settings", () => { "utf8", ); + const originalReadFile = fs.readFile.bind(fs); const readSpy = vi.spyOn(fs, "readFile"); const locked = Object.assign(new Error("locked"), { code: "EPERM" }); - readSpy.mockRejectedValue(locked); + let legacyReadAttempts = 0; + readSpy.mockImplementation(async (...args) => { + const [targetPath] = args; + if (targetPath === legacyPath) { + legacyReadAttempts += 1; + throw locked; + } + return originalReadFile(...args); + }); try { const loaded = await loadDashboardDisplaySettings(); expect(loaded).toEqual(DEFAULT_DASHBOARD_DISPLAY_SETTINGS); - expect(readSpy).toHaveBeenCalledTimes(4); + expect(legacyReadAttempts).toBe(4); } finally { readSpy.mockRestore(); } @@ -415,8 +442,14 @@ describe("dashboard settings", () => { "utf8", ); - const readSpy = vi.spyOn(fs, "readFile"); - readSpy.mockRejectedValueOnce("legacy-read-string-failure"); + const originalReadFile = fs.readFile.bind(fs); + const readSpy = vi.spyOn(fs, "readFile").mockImplementation(async (...args) => { + const [targetPath] = args; + if (targetPath === legacyPath) { + throw "legacy-read-string-failure"; + } + return originalReadFile(...args); + }); try { const loaded = await loadDashboardDisplaySettings(); diff --git a/test/index.test.ts b/test/index.test.ts index 62b7fca83..b5aed1272 100644 --- a/test/index.test.ts +++ b/test/index.test.ts @@ -4500,6 +4500,7 @@ describe("OpenAIOAuthPlugin runtime toast forwarding", () => { it("uses the floored cooldown for stream failover 429s", async () => { const { AccountManager } = await import("../lib/accounts.js"); + const { CapabilityPolicyStore } = await import("../lib/capability-policy.js"); const fetchHelpersModule = await import("../lib/request/fetch-helpers.js"); const rateLimitBackoffModule = await import("../lib/request/rate-limit-backoff.js"); const streamFailoverModule = await import("../lib/request/stream-failover.js"); @@ -4519,7 +4520,12 @@ describe("OpenAIOAuthPlugin runtime toast forwarding", () => { }; const markRateLimitedWithReason = vi.fn(); const recordRateLimit = vi.fn(); + const recordFailure = vi.fn(); const saveToDiskDebounced = vi.fn(); + const capabilityFailureSpy = vi.spyOn( + CapabilityPolicyStore.prototype, + "recordFailure", + ); const pendingFailovers: Array> = []; const fallback429Response = new Response( new ReadableStream({ @@ -4560,7 +4566,7 @@ describe("OpenAIOAuthPlugin runtime toast forwarding", () => { syncCodexCliActiveSelectionForIndex: async () => {}, markSwitched: () => {}, removeAccount: () => {}, - recordFailure: () => {}, + recordFailure, recordSuccess: () => {}, recordRateLimit, getMinWaitTimeForFamily: () => 0, @@ -4631,10 +4637,147 @@ describe("OpenAIOAuthPlugin runtime toast forwarding", () => { "gpt-5.1", "gpt-5.1", ); + expect(recordFailure).not.toHaveBeenCalled(); + expect(capabilityFailureSpy).not.toHaveBeenCalled(); expect(saveToDiskDebounced).toHaveBeenCalledTimes(1); expect(fallbackCancelSpy).toHaveBeenCalledTimes(1); }); + it("records capability failure once for non-429 stream failover fallback errors", async () => { + const { AccountManager } = await import("../lib/accounts.js"); + const { CapabilityPolicyStore } = await import("../lib/capability-policy.js"); + const fetchHelpersModule = await import("../lib/request/fetch-helpers.js"); + const streamFailoverModule = await import("../lib/request/stream-failover.js"); + const currentAccount = { + index: 0, + accountId: "acc-1", + email: "alpha@example.com", + refreshToken: "refresh-1", + accessToken: "access-alpha", + }; + const fallbackAccount = { + index: 1, + accountId: "acc-2", + email: "beta@example.com", + refreshToken: "refresh-2", + accessToken: "access-beta", + }; + const recordFailure = vi.fn(); + const saveToDiskDebounced = vi.fn(); + const capabilityFailureSpy = vi.spyOn( + CapabilityPolicyStore.prototype, + "recordFailure", + ); + const pendingFailovers: Array> = []; + const fallbackErrorResponse = new Response( + new ReadableStream({ + start(controller) { + controller.enqueue(new TextEncoder().encode("server error")); + controller.close(); + }, + }), + { status: 500 }, + ); + const manager = { + getAccountCount: () => 2, + getCurrentOrNextForFamilyHybrid: () => currentAccount, + getCurrentOrNextForFamily: () => currentAccount, + getCurrentWorkspace: () => null, + getAccountByIndex: (index: number) => + index === fallbackAccount.index ? fallbackAccount : currentAccount, + getAccountsSnapshot: () => [currentAccount, fallbackAccount], + isAccountAvailableForFamily: (index: number) => index === fallbackAccount.index, + toAuthDetails: (account: typeof currentAccount | typeof fallbackAccount) => ({ + type: "oauth" as const, + access: account.accessToken, + refresh: account.refreshToken, + expires: Date.now() + 60_000, + }), + hasRefreshToken: () => true, + saveToDiskDebounced, + updateFromAuth: () => {}, + clearAuthFailures: () => {}, + incrementAuthFailures: () => 1, + saveToDisk: async () => {}, + markAccountCoolingDown: () => {}, + markRateLimited: () => {}, + markRateLimitedWithReason: () => {}, + consumeToken: () => true, + refundToken: () => {}, + syncCodexCliActiveSelectionForIndex: async () => {}, + markSwitched: () => {}, + removeAccount: () => {}, + recordFailure, + recordSuccess: () => {}, + recordRateLimit: () => {}, + getMinWaitTimeForFamily: () => 0, + shouldShowAccountToast: () => false, + markToastShown: () => {}, + setActiveIndex: () => null, + }; + vi.spyOn(AccountManager, "loadFromDisk").mockResolvedValueOnce(manager as never); + vi.mocked(fetchHelpersModule.handleErrorResponse).mockImplementation( + async (response: Response) => + ({ + response, + errorBody: "server error", + }) as never, + ); + vi.mocked(fetchHelpersModule.transformRequestForCodex).mockImplementation( + async (init, _url, _userConfig, _codexMode, body) => ({ + updatedInit: { + ...(init as RequestInit), + body: JSON.stringify(body ?? {}), + }, + body: (body ?? { + model: "gpt-5.1", + stream: true, + }) as { + model: string; + stream?: boolean; + }, + }), + ); + vi.spyOn(streamFailoverModule, "withStreamingFailover").mockImplementation( + (initialResponse, getFallbackResponse) => { + pendingFailovers.push(getFallbackResponse(1, 0)); + return initialResponse; + }, + ); + let fetchCount = 0; + globalThis.fetch = vi.fn().mockImplementation(async () => { + fetchCount += 1; + if (fetchCount === 1) { + return new Response("data: ok\n\n", { status: 200 }); + } + return fallbackErrorResponse; + }); + + const mockClient = createMockClient(); + const { OpenAIOAuthPlugin } = await import("../index.js"); + const plugin = await OpenAIOAuthPlugin({ client: mockClient } as never) as unknown as PluginType; + const sdk = await plugin.auth.loader(getOAuthAuth, { options: {}, models: {} }); + const response = await sdk.fetch!("https://api.openai.com/v1/chat/completions", { + method: "POST", + body: JSON.stringify({ model: "gpt-5.1", stream: true }), + }); + await Promise.all(pendingFailovers); + + expect(response.status).toBe(200); + expect(recordFailure).toHaveBeenCalledTimes(1); + expect(recordFailure).toHaveBeenCalledWith( + fallbackAccount, + "gpt-5.1", + "gpt-5.1", + ); + expect(capabilityFailureSpy).toHaveBeenCalledTimes(1); + expect(capabilityFailureSpy.mock.calls[0]?.[0]).toMatch( + /^account:acc-2::email:/, + ); + expect(capabilityFailureSpy.mock.calls[0]?.[1]).toBe("gpt-5.1"); + expect(saveToDiskDebounced).not.toHaveBeenCalled(); + }); + it("forwards persistence error toast arguments through manual OAuth flow", async () => { const authModule = await import("../lib/auth/auth.js"); const storageModule = await import("../lib/storage.js"); diff --git a/test/storage-flagged.test.ts b/test/storage-flagged.test.ts index 9f92b21fe..79fdf2b84 100644 --- a/test/storage-flagged.test.ts +++ b/test/storage-flagged.test.ts @@ -333,12 +333,70 @@ describe("flagged account storage", () => { return originalReadFile(...args); }); - const flagged = await loadFlaggedAccounts(); - expect(flagged.accounts).toHaveLength(1); - expect(flagged.accounts[0]?.refreshToken).toBe("primary-flagged"); - expect(existsSync(flaggedPath)).toBe(true); + const flagged = await loadFlaggedAccounts(); + expect(flagged.accounts).toHaveLength(1); + expect(flagged.accounts[0]?.refreshToken).toBe("primary-flagged"); + expect(existsSync(flaggedPath)).toBe(true); - readSpy.mockRestore(); + readSpy.mockRestore(); + }); + + it("retries transient flagged primary read errors before falling back to backup", async () => { + await saveFlaggedAccounts({ + version: 1, + accounts: [ + { + refreshToken: "older-flagged", + flaggedAt: 1, + addedAt: 1, + lastUsed: 1, + }, + ], + }); + + await saveFlaggedAccounts({ + version: 1, + accounts: [ + { + refreshToken: "latest-flagged", + flaggedAt: 2, + addedAt: 2, + lastUsed: 2, + }, + ], + }); + + const flaggedPath = getFlaggedAccountsPath(); + const originalReadFile = fs.readFile.bind(fs); + let primaryReadAttempts = 0; + const readSpy = vi + .spyOn(fs, "readFile") + .mockImplementation(async (...args) => { + const [targetPath] = args; + if (targetPath === flaggedPath) { + primaryReadAttempts += 1; + if (primaryReadAttempts === 1) { + const error = new Error("EBUSY flagged read") as NodeJS.ErrnoException; + error.code = "EBUSY"; + throw error; + } + } + return originalReadFile(...args); + }); + + try { + const flagged = await loadFlaggedAccounts(); + expect(flagged.accounts).toHaveLength(1); + expect(flagged.accounts[0]?.refreshToken).toBe("latest-flagged"); + expect(primaryReadAttempts).toBe(2); + expect( + readSpy.mock.calls.some( + ([targetPath]) => targetPath === `${flaggedPath}.bak`, + ), + ).toBe(false); + } finally { + readSpy.mockRestore(); + } }); it("skips invalid latest flagged backups and falls back to older valid snapshots", async () => { @@ -658,4 +716,307 @@ describe("flagged storage extracted helpers", () => { expect.objectContaining({ path: "flagged.json" }), ); }); + + it("does not log successful backup recovery when persisting the recovery fails", async () => { + const { loadFlaggedAccountsState } = await import( + "../lib/storage/flagged-storage-io.js" + ); + const fixtureRoot = join( + tmpdir(), + `codex-flagged-io-${Math.random().toString(36).slice(2)}`, + ); + const flaggedPath = join(fixtureRoot, "flagged.json"); + const resetMarkerPath = `${flaggedPath}.reset`; + const logError = vi.fn(); + const logInfo = vi.fn(); + + try { + await fs.mkdir(fixtureRoot, { recursive: true }); + await fs.writeFile( + `${flaggedPath}.bak`, + JSON.stringify( + { + version: 1, + accounts: [ + { + refreshToken: "backup-token", + flaggedAt: 1, + addedAt: 1, + lastUsed: 1, + }, + ], + }, + null, + 2, + ), + "utf8", + ); + + await expect( + loadFlaggedAccountsState({ + path: flaggedPath, + legacyPath: `${flaggedPath}.legacy`, + resetMarkerPath, + normalizeFlaggedStorage: (data) => data as never, + persistRecoveredBackup: vi.fn(async () => { + throw new Error("persist failed"); + }), + saveFlaggedAccounts: vi.fn(async () => {}), + logError, + logInfo, + }), + ).resolves.toEqual({ + version: 1, + accounts: [ + { + refreshToken: "backup-token", + flaggedAt: 1, + addedAt: 1, + lastUsed: 1, + }, + ], + }); + expect(logError).toHaveBeenCalledWith( + "Failed to persist recovered flagged account storage", + expect.objectContaining({ from: `${flaggedPath}.bak`, to: flaggedPath }), + ); + expect(logInfo).not.toHaveBeenCalled(); + } finally { + await removeWithRetry(fixtureRoot, { recursive: true, force: true }); + } + }); + + it("returns empty and does not log successful backup recovery when persist returns false", async () => { + const { loadFlaggedAccountsState } = await import( + "../lib/storage/flagged-storage-io.js" + ); + const fixtureRoot = join( + tmpdir(), + `codex-flagged-io-${Math.random().toString(36).slice(2)}`, + ); + const flaggedPath = join(fixtureRoot, "flagged.json"); + const resetMarkerPath = `${flaggedPath}.reset`; + const logError = vi.fn(); + const logInfo = vi.fn(); + const persistRecoveredBackup = vi.fn(async () => false); + + try { + await fs.mkdir(fixtureRoot, { recursive: true }); + await fs.writeFile( + `${flaggedPath}.bak`, + JSON.stringify({ + version: 1, + accounts: [ + { + refreshToken: "backup-token", + flaggedAt: 1, + addedAt: 1, + lastUsed: 1, + }, + ], + }), + "utf8", + ); + + await expect( + loadFlaggedAccountsState({ + path: flaggedPath, + legacyPath: `${flaggedPath}.legacy`, + resetMarkerPath, + normalizeFlaggedStorage: (data) => data as never, + persistRecoveredBackup, + saveFlaggedAccounts: vi.fn(async () => {}), + logError, + logInfo, + }), + ).resolves.toEqual({ version: 1, accounts: [] }); + expect(persistRecoveredBackup).toHaveBeenCalledTimes(1); + expect(logInfo).not.toHaveBeenCalled(); + expect(logError).not.toHaveBeenCalled(); + } finally { + await removeWithRetry(fixtureRoot, { recursive: true, force: true }); + } + }); + + it("retries transient backup read locks before recovering flagged storage", async () => { + const { loadFlaggedAccountsState } = await import( + "../lib/storage/flagged-storage-io.js" + ); + const fixtureRoot = join( + tmpdir(), + `codex-flagged-io-${Math.random().toString(36).slice(2)}`, + ); + const flaggedPath = join(fixtureRoot, "flagged.json"); + const backupPath = `${flaggedPath}.bak`; + const resetMarkerPath = `${flaggedPath}.reset`; + const originalReadFile = fs.readFile.bind(fs); + const logError = vi.fn(); + const logInfo = vi.fn(); + const persistRecoveredBackup = vi.fn(async () => true); + let backupReadAttempts = 0; + let readSpy: ReturnType | undefined; + + try { + await fs.mkdir(fixtureRoot, { recursive: true }); + await fs.writeFile( + backupPath, + JSON.stringify({ + version: 1, + accounts: [ + { + refreshToken: "backup-token", + flaggedAt: 1, + addedAt: 1, + lastUsed: 1, + }, + ], + }), + "utf8", + ); + + readSpy = vi + .spyOn(fs, "readFile") + .mockImplementation(async (...args) => { + const [targetPath] = args; + if (targetPath === backupPath) { + backupReadAttempts += 1; + if (backupReadAttempts === 1) { + const error = new Error("EBUSY backup read") as NodeJS.ErrnoException; + error.code = "EBUSY"; + throw error; + } + } + return originalReadFile(...args); + }); + + await expect( + loadFlaggedAccountsState({ + path: flaggedPath, + legacyPath: `${flaggedPath}.legacy`, + resetMarkerPath, + normalizeFlaggedStorage: (data) => data as never, + persistRecoveredBackup, + saveFlaggedAccounts: vi.fn(async () => {}), + logError, + logInfo, + }), + ).resolves.toEqual({ + version: 1, + accounts: [ + { + refreshToken: "backup-token", + flaggedAt: 1, + addedAt: 1, + lastUsed: 1, + }, + ], + }); + expect(backupReadAttempts).toBe(2); + expect(persistRecoveredBackup).toHaveBeenCalledTimes(1); + expect(logInfo).toHaveBeenCalledWith( + "Recovered flagged account storage from backup", + expect.objectContaining({ from: backupPath, to: flaggedPath, accounts: 1 }), + ); + expect(logError).not.toHaveBeenCalled(); + } finally { + readSpy?.mockRestore(); + await removeWithRetry(fixtureRoot, { recursive: true, force: true }); + } + }); + + it("retries transient legacy read locks before migrating flagged storage", async () => { + const { loadFlaggedAccountsState } = await import( + "../lib/storage/flagged-storage-io.js" + ); + const fixtureRoot = join( + tmpdir(), + `codex-flagged-io-${Math.random().toString(36).slice(2)}`, + ); + const flaggedPath = join(fixtureRoot, "flagged.json"); + const legacyPath = `${flaggedPath}.legacy`; + const resetMarkerPath = `${flaggedPath}.reset`; + const originalReadFile = fs.readFile.bind(fs); + const logError = vi.fn(); + const logInfo = vi.fn(); + const saveFlaggedAccounts = vi.fn(async () => {}); + let legacyReadAttempts = 0; + let readSpy: ReturnType | undefined; + + try { + await fs.mkdir(fixtureRoot, { recursive: true }); + await fs.writeFile( + legacyPath, + JSON.stringify({ + version: 1, + accounts: [ + { + refreshToken: "legacy-token", + flaggedAt: 2, + addedAt: 2, + lastUsed: 2, + }, + ], + }), + "utf8", + ); + + readSpy = vi + .spyOn(fs, "readFile") + .mockImplementation(async (...args) => { + const [targetPath] = args; + if (targetPath === legacyPath) { + legacyReadAttempts += 1; + if (legacyReadAttempts === 1) { + const error = new Error("EBUSY legacy read") as NodeJS.ErrnoException; + error.code = "EBUSY"; + throw error; + } + } + return originalReadFile(...args); + }); + + await expect( + loadFlaggedAccountsState({ + path: flaggedPath, + legacyPath, + resetMarkerPath, + normalizeFlaggedStorage: (data) => data as never, + persistRecoveredBackup: vi.fn(async () => true), + saveFlaggedAccounts, + logError, + logInfo, + }), + ).resolves.toEqual({ + version: 1, + accounts: [ + { + refreshToken: "legacy-token", + flaggedAt: 2, + addedAt: 2, + lastUsed: 2, + }, + ], + }); + expect(legacyReadAttempts).toBe(2); + expect(saveFlaggedAccounts).toHaveBeenCalledWith({ + version: 1, + accounts: [ + { + refreshToken: "legacy-token", + flaggedAt: 2, + addedAt: 2, + lastUsed: 2, + }, + ], + }); + expect(logInfo).toHaveBeenCalledWith( + "Migrated legacy flagged account storage", + expect.objectContaining({ from: legacyPath, to: flaggedPath, accounts: 1 }), + ); + expect(logError).not.toHaveBeenCalled(); + } finally { + readSpy?.mockRestore(); + await removeWithRetry(fixtureRoot, { recursive: true, force: true }); + } + }); }); diff --git a/test/unified-settings.test.ts b/test/unified-settings.test.ts index 2681b5ecd..687f09b68 100644 --- a/test/unified-settings.test.ts +++ b/test/unified-settings.test.ts @@ -146,6 +146,83 @@ describe("unified settings", () => { }); }); + it("rethrows sync backup read errors when the primary settings file is invalid", async () => { + const { + getUnifiedSettingsPath, + saveUnifiedPluginConfig, + } = await import("../lib/unified-settings.js"); + + await saveUnifiedPluginConfig({ codexMode: true, fetchTimeoutMs: 45_000 }); + await saveUnifiedPluginConfig({ codexMode: false, fetchTimeoutMs: 90_000 }); + await fs.writeFile(getUnifiedSettingsPath(), "{ invalid json", "utf8"); + + const backupPath = `${getUnifiedSettingsPath()}.bak`; + vi.resetModules(); + vi.doMock("node:fs", async () => { + const actual = await vi.importActual("node:fs"); + return { + ...actual, + readFileSync: (...args: Parameters) => { + const [filePath] = args; + if (String(filePath) === backupPath) { + const error = new Error("busy") as NodeJS.ErrnoException; + error.code = "EBUSY"; + throw error; + } + return actual.readFileSync(...args); + }, + }; + }); + + try { + const { saveUnifiedPluginConfigSync } = await import("../lib/unified-settings.js"); + expect(() => + saveUnifiedPluginConfigSync({ + codexMode: true, + fetchTimeoutMs: 120_000, + }), + ).toThrow(/busy/); + } finally { + vi.doUnmock("node:fs"); + vi.resetModules(); + } + }); + + it("rethrows async backup read errors when the primary settings file is invalid", async () => { + const { + getUnifiedSettingsPath, + saveUnifiedPluginConfig, + } = await import("../lib/unified-settings.js"); + + await saveUnifiedPluginConfig({ codexMode: true, fetchTimeoutMs: 45_000 }); + await saveUnifiedPluginConfig({ codexMode: false, fetchTimeoutMs: 90_000 }); + await fs.writeFile(getUnifiedSettingsPath(), "{ invalid json", "utf8"); + + const backupPath = `${getUnifiedSettingsPath()}.bak`; + const originalReadFile = fs.readFile; + const readSpy = vi.spyOn(fs, "readFile"); + readSpy.mockImplementation((...args: Parameters) => { + const [filePath] = args; + if (String(filePath) === backupPath) { + const error = new Error("denied") as NodeJS.ErrnoException; + error.code = "EPERM"; + throw error; + } + return originalReadFile(...args); + }); + + try { + await expect( + saveUnifiedPluginConfig({ + codexMode: true, + fetchTimeoutMs: 120_000, + }), + ).rejects.toMatchObject({ code: "EPERM" }); + } finally { + readSpy.mockRestore(); + } + }); + it("preserves the last good backup when a write fails after a backup-derived read", async () => { const { getUnifiedSettingsPath, @@ -318,6 +395,63 @@ describe("unified settings", () => { expect(fileContent).toContain('"version": 1'); }); + it("overwrites invalid primary settings when saving without a usable backup", async () => { + const { + getUnifiedSettingsPath, + saveUnifiedPluginConfig, + saveUnifiedDashboardSettings, + } = await import("../lib/unified-settings.js"); + + await fs.writeFile(getUnifiedSettingsPath(), "{ invalid json", "utf8"); + + await saveUnifiedPluginConfig({ codexMode: true, fetchTimeoutMs: 45_000 }); + await saveUnifiedDashboardSettings({ + menuShowLastUsed: false, + uiThemePreset: "blue", + }); + + const parsed = JSON.parse( + await fs.readFile(getUnifiedSettingsPath(), "utf8"), + ) as { + pluginConfig?: Record; + dashboardDisplaySettings?: Record; + }; + expect(parsed.pluginConfig).toEqual({ + codexMode: true, + fetchTimeoutMs: 45_000, + }); + expect(parsed.dashboardDisplaySettings).toEqual({ + menuShowLastUsed: false, + uiThemePreset: "blue", + }); + }); + + it("overwrites invalid primary settings with sync plugin saves when no usable backup exists", async () => { + const { + getUnifiedSettingsPath, + saveUnifiedPluginConfigSync, + loadUnifiedPluginConfigSync, + } = await import("../lib/unified-settings.js"); + + await fs.writeFile(getUnifiedSettingsPath(), "{ invalid json", "utf8"); + + saveUnifiedPluginConfigSync({ codexMode: true, fetchTimeoutMs: 45_000 }); + + expect(loadUnifiedPluginConfigSync()).toEqual({ + codexMode: true, + fetchTimeoutMs: 45_000, + }); + const parsed = JSON.parse( + await fs.readFile(getUnifiedSettingsPath(), "utf8"), + ) as { + pluginConfig?: Record; + }; + expect(parsed.pluginConfig).toEqual({ + codexMode: true, + fetchTimeoutMs: 45_000, + }); + }); + it("returns null for missing pluginConfig section", async () => { const { getUnifiedSettingsPath, loadUnifiedPluginConfigSync } = await import("../lib/unified-settings.js");