Skip to content

Commit cd829f7

Browse files
committed
Keep the synthetic resolved row out of the OAuth hand-named set
buildProviderCatalog synthesizes a resolved row when settings is null or empty; when resolved is itself codex/<slug> that row carries the live apiKey with no profile marker and ejected the real marked entry. Derive handNamed from raw settings.providers only.
1 parent 11e6b2f commit cd829f7

2 files changed

Lines changed: 168 additions & 6 deletions

File tree

‎src/config/index.ts‎

Lines changed: 10 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1221,15 +1221,19 @@ export function mergeOAuthCatalog(
12211221
const settingsRows = buildProviderCatalog(settings, resolved);
12221222
// A hand-named codex/<slug> or xai/<slug> API-key row is the operator's
12231223
// explicit config, not an OAuth placeholder: keep it and skip the colliding
1224-
// live profile projection instead of overwriting it (CL-6728).
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.
12251229
const handNamed = new Set(
1226-
settingsRows
1230+
Object.entries(settings?.providers ?? {})
12271231
.filter(
1228-
(e) =>
1229-
(isCodexProviderName(e.name) || isXaiProviderName(e.name)) &&
1230-
isHandNamedProviderEntry(e),
1232+
([name, provider]) =>
1233+
(isCodexProviderName(name) || isXaiProviderName(name)) &&
1234+
isHandNamedProviderEntry(provider),
12311235
)
1232-
.map((e) => e.name),
1236+
.map(([name]) => name),
12331237
);
12341238
return [
12351239
...settingsRows.filter(

‎src/config/oauth-catalog.test.ts‎

Lines changed: 158 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,10 @@ import {
77
codexProfilesToCatalogEntries,
88
codexProvidersAsSettings,
99
} from "./codex-providers.js";
10+
import {
11+
xaiProfilesToCatalogEntries,
12+
xaiProvidersAsSettings,
13+
} from "./xai-providers.js";
1014
import {
1115
dropOrphanedOAuthEntries,
1216
mergeOAuthCatalog,
@@ -241,4 +245,158 @@ describe("CL-6728: OAuth projections do not overwrite hand-named provider entrie
241245
expect(persisted.providers["codex/mine"]?.apiKey).toBe("hand-named-key");
242246
expect(JSON.stringify(persisted)).not.toContain("live-token");
243247
});
248+
249+
test("mergeOAuthCatalog keeps the marked live entry when settings is null and resolved is codex/<slug>", () => {
250+
const resolvedCodexMine: ResolvedProvider = {
251+
providerName: "codex/mine",
252+
baseURL: CODEX_BASE_URL,
253+
apiKey: "live-token",
254+
model: "gpt-5.1-codex-max",
255+
};
256+
const merged = mergeOAuthCatalog(null, resolvedCodexMine, [liveMine], []);
257+
const rows = merged.filter((p) => p.name === "codex/mine");
258+
expect(rows).toHaveLength(1);
259+
expect(rows[0]?.codexProfile).toBe("mine");
260+
expect(rows[0]?.apiKey).toBe("live-token");
261+
});
262+
263+
test("mergeOAuthCatalog keeps the marked live entry when settings is empty and resolved is codex/<slug>", () => {
264+
const resolvedCodexMine: ResolvedProvider = {
265+
providerName: "codex/mine",
266+
baseURL: CODEX_BASE_URL,
267+
apiKey: "live-token",
268+
model: "gpt-5.1-codex-max",
269+
};
270+
const merged = mergeOAuthCatalog(
271+
settingsWith({}),
272+
resolvedCodexMine,
273+
[liveMine],
274+
[],
275+
);
276+
const rows = merged.filter((p) => p.name === "codex/mine");
277+
expect(rows).toHaveLength(1);
278+
expect(rows[0]?.codexProfile).toBe("mine");
279+
expect(rows[0]?.apiKey).toBe("live-token");
280+
});
281+
282+
test("persist round-trip from the empty-settings merge contains no live token", () => {
283+
const resolvedCodexMine: ResolvedProvider = {
284+
providerName: "codex/mine",
285+
baseURL: CODEX_BASE_URL,
286+
apiKey: "live-token",
287+
model: "gpt-5.1-codex-max",
288+
};
289+
const merged = mergeOAuthCatalog(null, resolvedCodexMine, [liveMine], []);
290+
const persisted = providerCatalogToSettings(merged, undefined);
291+
expect(JSON.stringify(persisted)).not.toContain("live-token");
292+
});
293+
294+
test("mergeOAuthCatalog keeps a hand-named xai/<slug> entry when its profile is live", () => {
295+
const handNamedXai = (): ProviderSettings => ({
296+
baseURL: "https://hand-named-xai.example.com/v1",
297+
apiKey: "hand-named-xai-key",
298+
models: ["hand-xai-model"],
299+
});
300+
const merged = mergeOAuthCatalog(
301+
settingsWith({ "xai/work": handNamedXai() }),
302+
resolved,
303+
[],
304+
[xaiWork],
305+
);
306+
const rows = merged.filter((p) => p.name === "xai/work");
307+
expect(rows).toHaveLength(1);
308+
expect(rows[0]?.apiKey).toBe("hand-named-xai-key");
309+
expect(rows[0]?.xaiProfile).toBeUndefined();
310+
});
311+
312+
test("overlayOAuthProjections keeps a hand-named xai/<slug> API-key entry", () => {
313+
const overlaid = overlayOAuthProjections(
314+
settingsWith({
315+
"xai/work": {
316+
baseURL: "https://hand-named-xai.example.com/v1",
317+
apiKey: "hand-named-xai-key",
318+
models: ["hand-xai-model"],
319+
},
320+
}),
321+
xaiProvidersAsSettings([xaiWork]),
322+
);
323+
expect(overlaid?.providers["xai/work"]?.apiKey).toBe("hand-named-xai-key");
324+
});
325+
326+
test("mergeOAuthCatalog keeps a keyless codex/<slug> entry when its profile is live", () => {
327+
const merged = mergeOAuthCatalog(
328+
settingsWith({
329+
"codex/mine": {
330+
baseURL: "https://hand-named.example.com/v1",
331+
keyless: true,
332+
models: ["hand-model"],
333+
},
334+
}),
335+
resolved,
336+
[liveMine],
337+
[],
338+
);
339+
const rows = merged.filter((p) => p.name === "codex/mine");
340+
expect(rows).toHaveLength(1);
341+
expect(rows[0]?.keyless).toBe(true);
342+
expect(rows[0]?.codexProfile).toBeUndefined();
343+
});
344+
345+
test("runtimeSettingsWithCatalog keeps a keyless codex/<slug> entry", () => {
346+
const runtime = runtimeSettingsWithCatalog(
347+
settingsWith({
348+
"codex/mine": {
349+
baseURL: "https://hand-named.example.com/v1",
350+
keyless: true,
351+
models: ["hand-model"],
352+
},
353+
}),
354+
liveCatalog(),
355+
);
356+
expect(runtime.providers["codex/mine"]?.keyless).toBe(true);
357+
expect(runtime.providers["codex/mine"]?.apiKey).toBeUndefined();
358+
});
359+
360+
test("overlayOAuthProjections treats a whitespace apiKey as hand-named", () => {
361+
const overlaid = overlayOAuthProjections(
362+
settingsWith({
363+
"codex/mine": {
364+
baseURL: "https://hand-named.example.com/v1",
365+
apiKey: " ",
366+
models: ["hand-model"],
367+
},
368+
}),
369+
liveProjected(),
370+
);
371+
expect(overlaid?.providers["codex/mine"]?.apiKey).toBe(" ");
372+
});
373+
374+
test("overlayOAuthProjections treats an empty-string apiKey as a placeholder", () => {
375+
const overlaid = overlayOAuthProjections(
376+
settingsWith({
377+
"codex/mine": {
378+
baseURL: "https://hand-named.example.com/v1",
379+
apiKey: "",
380+
models: ["hand-model"],
381+
},
382+
}),
383+
liveProjected(),
384+
);
385+
expect(overlaid?.providers["codex/mine"]?.apiKey).toBe("live-token");
386+
});
387+
388+
test("runtimeSettingsWithCatalog resolves a keyless xai/<slug> entry from the catalog", () => {
389+
const runtime = runtimeSettingsWithCatalog(
390+
settingsWith({
391+
"xai/work": {
392+
baseURL: "https://hand-named-xai.example.com/v1",
393+
keyless: true,
394+
models: ["hand-xai-model"],
395+
},
396+
}),
397+
xaiProfilesToCatalogEntries([xaiWork]),
398+
);
399+
expect(runtime.providers["xai/work"]?.keyless).toBe(true);
400+
expect(runtime.providers["xai/work"]?.apiKey).toBeUndefined();
401+
});
244402
});

0 commit comments

Comments
 (0)