@@ -135,16 +135,17 @@ function applyPersistedOAuthDefaults(
135135// OAuth entries in settings.json carry no credentials; they are only usable
136136// while a matching auth-store profile exists. Drop orphans in memory so a
137137// removed profile does not pin resolution to an unauthenticatable provider.
138- function dropOrphanedOAuthEntries (
138+ export function dropOrphanedOAuthEntries (
139139 settings : Settings | null ,
140140 projected : Record < string , ProviderSettings > ,
141141) : Settings | null {
142142 if ( settings === null ) return null ;
143143 const providers = Object . fromEntries (
144144 Object . entries ( settings . providers ) . filter (
145- ( [ name ] ) =>
145+ ( [ name , provider ] ) =>
146146 ( ! isCodexProviderName ( name ) && ! isXaiProviderName ( name ) ) ||
147- projected [ name ] !== undefined ,
147+ projected [ name ] !== undefined ||
148+ isHandNamedProviderEntry ( provider ) ,
148149 ) ,
149150 ) ;
150151 const { defaultProvider, ...rest } = settings ;
@@ -158,6 +159,35 @@ function dropOrphanedOAuthEntries(
158159 } ;
159160}
160161
162+ // A codex/<slug> or xai/<slug> settings row carrying its own credential is the
163+ // operator's explicit config, not an OAuth placeholder: OAuth profile
164+ // projections must never overwrite it, orphan-sweep it, or drop it from the
165+ // catalog. Credential-less namespaced rows stay placeholders (CL-6728).
166+ function isHandNamedProviderEntry (
167+ entry : Pick < ProviderSettings , "apiKey" | "keyless" > | undefined ,
168+ ) : boolean {
169+ if ( entry === undefined ) return false ;
170+ if ( entry . keyless === true ) return true ;
171+ return typeof entry . apiKey === "string" && entry . apiKey . length > 0 ;
172+ }
173+
174+ // Overlay live OAuth profile projections onto settings for runtime provider
175+ // resolution. Exported for tests; loadConfig is the only production caller.
176+ export function overlayOAuthProjections (
177+ settings : Settings | null ,
178+ projected : Record < string , ProviderSettings > ,
179+ ) : Settings | null {
180+ if ( Object . keys ( projected ) . length === 0 ) return settings ;
181+ const providers = { ...( settings ?. providers ?? { } ) } ;
182+ for ( const [ name , entry ] of Object . entries ( projected ) ) {
183+ if ( ! isHandNamedProviderEntry ( providers [ name ] ) ) providers [ name ] = entry ;
184+ }
185+ return {
186+ ...( settings ?? { providers : { } } ) ,
187+ providers,
188+ } ;
189+ }
190+
161191function hasExaEntry ( servers : MCPServerSettingsEntry [ ] | undefined ) : boolean {
162192 return (
163193 servers ?. some ( ( server ) => server . name === EXA_MCP_SERVER_NAME ) === true
@@ -936,16 +966,10 @@ export async function loadConfig(
936966 const liveSettings = useOAuthProfiles
937967 ? dropOrphanedOAuthEntries ( settings , projectedOAuthProviders )
938968 : settings ;
939- const settingsForResolution : Settings | null =
940- Object . keys ( projectedOAuthProviders ) . length > 0
941- ? {
942- ...( liveSettings ?? { providers : { } } ) ,
943- providers : {
944- ...( liveSettings ?. providers ?? { } ) ,
945- ...projectedOAuthProviders ,
946- } ,
947- }
948- : liveSettings ;
969+ const settingsForResolution : Settings | null = overlayOAuthProjections (
970+ liveSettings ,
971+ projectedOAuthProviders ,
972+ ) ;
949973
950974 // The per-repo selection file still applies on top of a --config source: that
951975 // file supplies provider definitions, while .corbits/settings.json supplies
@@ -1194,15 +1218,33 @@ export function mergeOAuthCatalog(
11941218 ? [ "xai" ]
11951219 : [ ] ) ,
11961220 ] ) ;
1221+ const settingsRows = buildProviderCatalog ( settings , resolved ) ;
1222+ // A hand-named codex/<slug> or xai/<slug> API-key row is the operator's
1223+ // explicit config, not an OAuth placeholder: keep it and skip the colliding
1224+ // live profile projection instead of overwriting it (CL-6728). Read the raw
1225+ // settings rows only: buildProviderCatalog synthesizes a [resolved] row when
1226+ // settings is null/empty, and when resolved is itself codex/<slug> that row
1227+ // carries the live apiKey with no profile marker — treating it as hand-named
1228+ // would eject the real marked entry for a stale token snapshot.
1229+ const handNamed = new Set (
1230+ Object . entries ( settings ?. providers ?? { } )
1231+ . filter (
1232+ ( [ name , provider ] ) =>
1233+ ( isCodexProviderName ( name ) || isXaiProviderName ( name ) ) &&
1234+ isHandNamedProviderEntry ( provider ) ,
1235+ )
1236+ . map ( ( [ name ] ) => name ) ,
1237+ ) ;
11971238 return [
1198- ...buildProviderCatalog ( settings , resolved ) . filter (
1239+ ...settingsRows . filter (
11991240 ( e ) =>
1200- ! isCodexProviderName ( e . name ) &&
1201- ! isXaiProviderName ( e . name ) &&
1202- ! dropBare . has ( e . name ) ,
1241+ handNamed . has ( e . name ) ||
1242+ ( ! isCodexProviderName ( e . name ) &&
1243+ ! isXaiProviderName ( e . name ) &&
1244+ ! dropBare . has ( e . name ) ) ,
12031245 ) ,
1204- ...codexEntries ,
1205- ...xaiEntries ,
1246+ ...codexEntries . filter ( ( e ) => ! handNamed . has ( e . name ) ) ,
1247+ ...xaiEntries . filter ( ( e ) => ! handNamed . has ( e . name ) ) ,
12061248 ] . map ( ( entry ) =>
12071249 isOpenCodeGoProvider ( entry )
12081250 ? { ...entry , models : [ ...selectableGoModelIds ( ) ] }
@@ -1272,11 +1314,20 @@ export function runtimeSettingsWithCatalog(
12721314 if ( settings === undefined ) {
12731315 return { providers : fromCatalog } ;
12741316 }
1317+ // OAuth-marked catalog rows carry live profile tokens; they overlay
1318+ // credential-less placeholders but never a hand-named API-key row (CL-6728).
1319+ const overlaid = Object . fromEntries (
1320+ Object . entries ( fromCatalog ) . filter (
1321+ ( [ name ] ) =>
1322+ ( ! isCodexProviderName ( name ) && ! isXaiProviderName ( name ) ) ||
1323+ ! isHandNamedProviderEntry ( settings . providers [ name ] ) ,
1324+ ) ,
1325+ ) ;
12751326 return {
12761327 ...settings ,
12771328 providers : {
12781329 ...settings . providers ,
1279- ...fromCatalog ,
1330+ ...overlaid ,
12801331 } ,
12811332 } ;
12821333}
0 commit comments