From 3f3efa8f2ee8ce9cbb9f1911088a929e31ccf25a Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Sat, 12 Sep 2026 20:12:29 -0700 Subject: [PATCH 1/2] Document catalog credential-removal convergence as rebuild-only Every catalog rebuild derives rows from the current settings file plus the live credential stores, so removal converges on the next rebuild and manual rows are never deleted. A disabled flag has no home and no removal event to drive it. --- src/config.test.ts | 52 +++++++++++++++++++++++++++++++++++++++++++++ src/config/index.ts | 13 ++++++++++++ 2 files changed, 65 insertions(+) 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..dd693d367 100644 --- a/src/config/index.ts +++ b/src/config/index.ts @@ -1139,6 +1139,19 @@ 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. Manual rows are never deleted — +// settings-file providers are re-projected verbatim — which is the +// disable-not-delete rationale: 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, From 132006f26f64e4ba8a82a713a30c80d797391a0d Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Sat, 12 Sep 2026 21:55:14 -0700 Subject: [PATCH 2/2] Carve the bare-row dedupe out of the never-deleted rule The legacy bare codex/xai settings row is dropped once its family has a live credential-backed profile (CL-5606), so settings rows are verbatim except for that dedupe. --- src/config/index.ts | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/src/config/index.ts b/src/config/index.ts index dd693d367..4ae9a2e66 100644 --- a/src/config/index.ts +++ b/src/config/index.ts @@ -1143,9 +1143,11 @@ export async function loadConfig( // 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. Manual rows are never deleted — -// settings-file providers are re-projected verbatim — which is the -// disable-not-delete rationale: nothing the operator wrote is lost, 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