@@ -41,6 +41,13 @@ const UNSUPPORTED_CODEX_POLICIES = new Set(["strict", "fallback"]);
4141const emittedConfigWarnings = new Set < string > ( ) ;
4242const configSaveQueues = new Map < string , Promise < void > > ( ) ;
4343const 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
4552export 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+
459513function resolveStoredPluginConfigRecord ( ) : {
460514 configPath : string | null ;
461515 storageKind : ConfigExplainStorageKind ;
@@ -507,10 +561,17 @@ function resolveStoredPluginConfigRecord(): {
507561 */
508562function 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(
539600export 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 ??
0 commit comments