Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
52 changes: 52 additions & 0 deletions src/config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down
15 changes: 15 additions & 0 deletions src/config/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Loading