diff --git a/src/config.test.ts b/src/config.test.ts index 6c68801e1..0312a28e4 100644 --- a/src/config.test.ts +++ b/src/config.test.ts @@ -2061,6 +2061,58 @@ describe("refreshLiveProviderCatalog", () => { }); }); +describe("catalog credential-removal convergence", () => { + const resolved: ResolvedProvider = { + providerName: "fp", + baseURL: "https://fp/v1", + apiKey: "fp-key", + model: "fp-large", + }; + const credentialed: Settings = { + providers: { + fp: { baseURL: "https://fp/v1", apiKey: "fp-key", models: ["fp-large"] }, + }, + }; + const credentialRemoved: Settings = { + providers: { + fp: { baseURL: "https://fp/v1", models: ["fp-large"] }, + }, + }; + + test("rebuild preserves the manual row across removal and restore", async () => { + expect( + buildProviderCatalog(credentialed, resolved).find((c) => c.name === "fp") + ?.apiKey, + ).toBe("fp-key"); + + const converged = await refreshLiveProviderCatalog( + credentialRemoved, + resolved, + ); + const row = converged.find((c) => c.name === "fp"); + expect(row?.models).toEqual(["fp-large"]); + expect(row?.baseURL).toBe("https://fp/v1"); + expect(row?.apiKey).toBeUndefined(); + + const restored = await refreshLiveProviderCatalog(credentialed, resolved); + expect(restored.find((c) => c.name === "fp")?.apiKey).toBe("fp-key"); + }); + + test("persisting the converged catalog keeps the manual row", async () => { + const converged = await refreshLiveProviderCatalog( + credentialRemoved, + resolved, + ); + const persisted = providerCatalogToSettings( + converged, + undefined, + credentialRemoved, + ); + expect(persisted.providers.fp?.models).toEqual(["fp-large"]); + expect(persisted.providers.fp?.baseURL).toBe("https://fp/v1"); + }); +}); + describe("mergeProviderIntoSettings", () => { test("preserves plugins and non-provider fields when upserting a provider", () => { const existing: Settings = { diff --git a/src/config/index.ts b/src/config/index.ts index b2485ed1b..4ae9a2e66 100644 --- a/src/config/index.ts +++ b/src/config/index.ts @@ -1139,6 +1139,21 @@ export async function loadConfig( // provider connect (mid-session, no restart) can rebuild the picker's // catalog after writing new credentials, instead of only taking effect on // the next process start. +// +// Credential-removal convergence (CL-5446) is rebuild-only: every rebuild +// derives rows from the current settings file plus the live Codex/xAI +// stores, so a provider without a credential is rebuilt without one and +// re-auth restores it on the next rebuild. Settings-file rows are +// re-projected verbatim and never deleted — with one exception: the legacy +// bare `codex`/`xai` row dedupe (CL-5606), which drops the bare settings row +// once that family has a live credential-backed profile. Otherwise the +// disable-not-delete rationale holds: nothing the operator wrote is lost, and +// nothing stale survives past the next rebuild. A dedicated disabled flag +// was rejected: no such state exists on the catalog entry, the picker +// option, or the host boundary, and no removal event drives a refresh +// (there is no logout/disconnect surface or auth-store watcher; refresh +// runs on connect, prefetch, and startup), so removal takes effect on the +// next rebuild, not live. export function mergeOAuthCatalog( settings: Settings | null, resolved: ResolvedProvider,