Skip to content

Commit 9c1d126

Browse files
committed
Reproduce CL-6728 OAuth projection overwrite of hand-named providers
1 parent 43e7eef commit 9c1d126

2 files changed

Lines changed: 132 additions & 12 deletions

File tree

‎src/config/index.ts‎

Lines changed: 21 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -135,7 +135,7 @@ 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 {
@@ -158,6 +158,22 @@ function dropOrphanedOAuthEntries(
158158
};
159159
}
160160

161+
// Overlay live OAuth profile projections onto settings for runtime provider
162+
// resolution. Exported for tests; loadConfig is the only production caller.
163+
export function overlayOAuthProjections(
164+
settings: Settings | null,
165+
projected: Record<string, ProviderSettings>,
166+
): Settings | null {
167+
if (Object.keys(projected).length === 0) return settings;
168+
return {
169+
...(settings ?? { providers: {} }),
170+
providers: {
171+
...(settings?.providers ?? {}),
172+
...projected,
173+
},
174+
};
175+
}
176+
161177
function hasExaEntry(servers: MCPServerSettingsEntry[] | undefined): boolean {
162178
return (
163179
servers?.some((server) => server.name === EXA_MCP_SERVER_NAME) === true
@@ -936,16 +952,10 @@ export async function loadConfig(
936952
const liveSettings = useOAuthProfiles
937953
? dropOrphanedOAuthEntries(settings, projectedOAuthProviders)
938954
: 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;
955+
const settingsForResolution: Settings | null = overlayOAuthProjections(
956+
liveSettings,
957+
projectedOAuthProviders,
958+
);
949959

950960
// The per-repo selection file still applies on top of a --config source: that
951961
// file supplies provider definitions, while .corbits/settings.json supplies

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

Lines changed: 111 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,17 @@
11
import { describe, expect, test } from "bun:test";
22
import type { CodexProfile } from "../auth/codex/store.js";
33
import type { XaiProfile } from "../auth/xai/store.js";
4-
import { mergeOAuthCatalog } from "./index.js";
4+
import {
5+
codexProfilesToCatalogEntries,
6+
codexProvidersAsSettings,
7+
} from "./codex-providers.js";
8+
import {
9+
dropOrphanedOAuthEntries,
10+
mergeOAuthCatalog,
11+
overlayOAuthProjections,
12+
providerCatalogToSettings,
13+
runtimeSettingsWithCatalog,
14+
} from "./index.js";
515
import type {
616
ProviderSettings,
717
ResolvedProvider,
@@ -89,3 +99,103 @@ describe("mergeOAuthCatalog legacy bare-row dedupe (CL-5606)", () => {
8999
expect(merged.map((p) => p.name)).toEqual(["openai", "codex/default"]);
90100
});
91101
});
102+
103+
describe("CL-6728: OAuth projections do not overwrite hand-named provider entries", () => {
104+
const handNamed = (): ProviderSettings => ({
105+
baseURL: "https://hand-named.example.com/v1",
106+
apiKey: "hand-named-key",
107+
models: ["hand-model"],
108+
});
109+
const liveMine: CodexProfile = {
110+
name: "mine",
111+
tokens: { access: "live-token", refresh: "r", expiresAt: 1 },
112+
createdAt: 0,
113+
};
114+
const liveProjected = () => codexProvidersAsSettings([liveMine]);
115+
const liveCatalog = () => codexProfilesToCatalogEntries([liveMine]);
116+
117+
test("overlayOAuthProjections keeps a hand-named codex/<slug> API-key entry", () => {
118+
const overlaid = overlayOAuthProjections(
119+
settingsWith({ "codex/mine": handNamed() }),
120+
liveProjected(),
121+
);
122+
expect(overlaid?.providers["codex/mine"]?.apiKey).toBe("hand-named-key");
123+
});
124+
125+
test("overlayOAuthProjections still applies the live token over a credential-less OAuth placeholder", () => {
126+
const overlaid = overlayOAuthProjections(
127+
settingsWith({ "codex/mine": entry() }),
128+
liveProjected(),
129+
);
130+
expect(overlaid?.providers["codex/mine"]?.apiKey).toBe("live-token");
131+
});
132+
133+
test("dropOrphanedOAuthEntries never drops a hand-named API-key entry", () => {
134+
const kept = dropOrphanedOAuthEntries(
135+
settingsWith({ "codex/mine": handNamed() }),
136+
{},
137+
);
138+
expect(kept?.providers["codex/mine"]?.apiKey).toBe("hand-named-key");
139+
});
140+
141+
test("dropOrphanedOAuthEntries still drops a credential-less orphan", () => {
142+
const dropped = dropOrphanedOAuthEntries(
143+
settingsWith({ "codex/mine": entry() }),
144+
{},
145+
);
146+
expect(dropped?.providers["codex/mine"]).toBeUndefined();
147+
});
148+
149+
test("runtimeSettingsWithCatalog keeps a hand-named codex/<slug> API-key entry", () => {
150+
const runtime = runtimeSettingsWithCatalog(
151+
settingsWith({ "codex/mine": handNamed() }),
152+
liveCatalog(),
153+
);
154+
expect(runtime.providers["codex/mine"]?.apiKey).toBe("hand-named-key");
155+
});
156+
157+
test("runtimeSettingsWithCatalog still overlays the live token over a placeholder", () => {
158+
const runtime = runtimeSettingsWithCatalog(
159+
settingsWith({ "codex/mine": entry() }),
160+
liveCatalog(),
161+
);
162+
expect(runtime.providers["codex/mine"]?.apiKey).toBe("live-token");
163+
});
164+
165+
test("mergeOAuthCatalog keeps a hand-named codex/<slug> entry when its profile is live", () => {
166+
const merged = mergeOAuthCatalog(
167+
settingsWith({ "codex/mine": handNamed() }),
168+
resolved,
169+
[liveMine],
170+
[],
171+
);
172+
const rows = merged.filter((p) => p.name === "codex/mine");
173+
expect(rows).toHaveLength(1);
174+
expect(rows[0]?.apiKey).toBe("hand-named-key");
175+
expect(rows[0]?.codexProfile).toBeUndefined();
176+
});
177+
178+
test("mergeOAuthCatalog keeps a hand-named codex/<slug> entry with no live profile", () => {
179+
const merged = mergeOAuthCatalog(
180+
settingsWith({ "codex/mine": handNamed() }),
181+
resolved,
182+
[],
183+
[],
184+
);
185+
expect(merged.find((p) => p.name === "codex/mine")?.apiKey).toBe(
186+
"hand-named-key",
187+
);
188+
});
189+
190+
test("persist round-trip keeps the hand-named key and no login token", () => {
191+
const merged = mergeOAuthCatalog(
192+
settingsWith({ "codex/mine": handNamed() }),
193+
resolved,
194+
[liveMine],
195+
[],
196+
);
197+
const persisted = providerCatalogToSettings(merged, undefined);
198+
expect(persisted.providers["codex/mine"]?.apiKey).toBe("hand-named-key");
199+
expect(JSON.stringify(persisted)).not.toContain("live-token");
200+
});
201+
});

0 commit comments

Comments
 (0)