diff --git a/CHANGELOG.md b/CHANGELOG.md index b78d4fdee5..fbbef2b81f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -438,6 +438,17 @@ already in use` and no way forward short of finding the file in a temporary ### Provider setup +- **Signing in after "Disconnect provider" works again** — disconnecting a + provider in Settings marks it disabled in the global config, and that mark + outranked everything the sign-in flow wrote: logging in again rewrote the + provider setup but never cleared the mark, so no provider ever came back, + and the setup dialog reopened right over its own "CoDev configured" toast — + every time, surviving restarts, until something rewrote the config file + wholesale (which is why `codevhub config` fixed it). Completing a sign-in — + SSO or custom provider, desktop app or terminal — now clears that provider's + disabled mark, and only its own: other providers you disconnected on purpose + stay disconnected. + ([#325](https://github.com/quickbeard/codev-code/pull/325)) - **The gateway API key no longer sits in codev.json** — every configure flow (the TUI and desktop SSO login, the manual-key fallback) wrote the key in plain sight into the global `codev.json`, the same file people open to diff --git a/packages/app/src/components/gateway/dialog-configure.tsx b/packages/app/src/components/gateway/dialog-configure.tsx index a5ee62fa66..bff75d4828 100644 --- a/packages/app/src/components/gateway/dialog-configure.tsx +++ b/packages/app/src/components/gateway/dialog-configure.tsx @@ -126,11 +126,24 @@ export function DialogConfigure(props: { dismissable?: boolean } = {}) { // — codev.json must not carry the credential. Auth first: a keyless // config with no auth entry is a provider that 401s on the first chat. await client.auth.set({ providerID: result.providerID, auth: { type: "api", key: result.apiKey } }) + // A provider the user disconnected earlier sits in disabled_providers, + // which outranks everything this flow writes: the registry skips it, the + // gate sees nothing connected, and this dialog reopens right over the + // success toast — forever, since each retry rewrites the block but never + // the flag. Same guard as the custom-provider dialog's save. + const disabled = serverSync().data.config.disabled_providers ?? [] // Raw update instead of serverSync().updateConfig: its onSuccess refetch // races the forked server-side instance reload. The explicit dispose // below emits global.disposed, which refreshes every window (mirrors the // TUI's update -> dispose -> bootstrap ordering). - await client.global.config.update({ config: result.config as Config }) + await client.global.config.update({ + config: { + ...result.config, + ...(disabled.includes(result.providerID) + ? { disabled_providers: disabled.filter((id) => id !== result.providerID) } + : {}), + } as Config, + }) await client.global.dispose() const arrived = await waitForModel(result.providerID, model) if (arrived) { diff --git a/packages/tui/src/component/dialog-configure.tsx b/packages/tui/src/component/dialog-configure.tsx index 54fa84eb2f..c36698964d 100644 --- a/packages/tui/src/component/dialog-configure.tsx +++ b/packages/tui/src/component/dialog-configure.tsx @@ -325,12 +325,22 @@ async function runModelFlow(ctx: FlowContext, creds: GatewayCreds) { // codev.json must not carry the credential. Auth first: a keyless config // with no auth entry is a provider that 401s on the first chat. await ctx.sdk.client.auth.set({ providerID: GATEWAY_PROVIDER.id, auth: { type: "api", key: creds.apiKey } }) + // A provider the user disconnected earlier sits in disabled_providers, + // which outranks everything this flow writes: the registry skips it, no + // provider connects, and the gate reopens right over the success toast — + // forever, since each retry rewrites the block but never the flag. + const disabled = ctx.sync.data.config.disabled_providers ?? [] const result = await ctx.sdk.client.global.config.update({ - config: buildGatewayConfig({ - baseUrl: creds.gatewayBaseUrl, - model, - models, - }), + config: { + ...buildGatewayConfig({ + baseUrl: creds.gatewayBaseUrl, + model, + models, + }), + ...(disabled.includes(GATEWAY_PROVIDER.id) + ? { disabled_providers: disabled.filter((id) => id !== GATEWAY_PROVIDER.id) } + : {}), + }, }) if (result.error) throw new Error(JSON.stringify(result.error)) }) diff --git a/packages/tui/src/component/dialog-custom-provider.tsx b/packages/tui/src/component/dialog-custom-provider.tsx index 14ae8b899a..7cf9b8750b 100644 --- a/packages/tui/src/component/dialog-custom-provider.tsx +++ b/packages/tui/src/component/dialog-custom-provider.tsx @@ -57,13 +57,20 @@ export async function runCustomProviderFlow(ctx: CustomProviderFlowContext) { if (models) { setStatus(`Configuring ${provider.id}...`) + // Reconnecting a provider the user disconnected earlier must clear it from + // disabled_providers, or the flag keeps the rewritten block dead (the app's + // custom-provider dialog does the same on save). + const disabled = ctx.sync.data.config.disabled_providers ?? [] const result = await ctx.sdk.client.global.config.update({ - config: buildCustomProviderConfig({ - providerID: provider.id, - name: provider.name, - baseUrl: baseUrl.trim(), - models, - }), + config: { + ...buildCustomProviderConfig({ + providerID: provider.id, + name: provider.name, + baseUrl: baseUrl.trim(), + models, + }), + ...(disabled.includes(provider.id) ? { disabled_providers: disabled.filter((id) => id !== provider.id) } : {}), + }, }) if (result.error) { ctx.toast.show({ variant: "error", message: JSON.stringify(result.error) }) diff --git a/packages/tui/test/custom-provider.view.test.tsx b/packages/tui/test/custom-provider.view.test.tsx index 39ca1cb8b1..532fdb5148 100644 --- a/packages/tui/test/custom-provider.view.test.tsx +++ b/packages/tui/test/custom-provider.view.test.tsx @@ -223,6 +223,55 @@ test("Custom provider with a base URL discovers models and writes the codev.json } }, 60_000) +// A provider the user disconnected earlier sits in disabled_providers, which +// outranks the rewritten block: the registry skips it and the gate reopens +// over the success toast, forever. Reconnecting must clear the flag — and only +// its own id, not another provider's opt-out. +test("reconnecting a disconnected provider clears it from disabled_providers", async () => { + const models = Bun.serve({ + port: 0, + fetch: () => Response.json({ data: [{ id: "mock-small" }] }), + }) + const modelsBase = `http://127.0.0.1:${models.port}` + let configured = false + const provider = { id: "mockgw", name: "mockgw", models: { "mock-small": { name: "mock-small" } } } + const boot_ = await boot( + (url) => { + if (url.pathname === "/config") return json({ disabled_providers: ["mockgw", "othergw"] }) + if (url.pathname === "/config/providers") + return json( + configured ? { providers: [provider], default: { mockgw: "mock-small" } } : { providers: [], default: {} }, + ) + if (url.pathname === "/provider") + return json( + configured + ? { all: [provider], default: {}, connected: ["mockgw"] } + : { all: [], default: {}, connected: [] }, + ) + return undefined + }, + () => (configured = true), + ) + try { + await waitFrame(boot_.setup, (f) => f.includes(GATE)) + boot_.setup.mockInput.pressArrow("down") // Custom provider + boot_.setup.mockInput.pressEnter() + + await waitFrame(boot_.setup, (f) => f.includes("Provider name")) + await answer(boot_.setup, "mockgw") + await waitFrame(boot_.setup, (f) => f.includes("API key")) + await answer(boot_.setup, "sk-mock") + await waitFrame(boot_.setup, (f) => f.includes("OpenAI-compatible endpoint")) + await answer(boot_.setup, modelsBase) + + await waitUntil(boot_.setup, () => boot_.captured.config.length > 0) + expect(boot_.captured.config[0].disabled_providers).toEqual(["othergw"]) + } finally { + models.stop(true) + await teardown(boot_) + } +}, 60_000) + // The Windows path: ctrl+v arrives as a key event (no bracketed paste), so the // dialog's paste command must read the clipboard itself. CR/CRLF from the // Windows clipboard must not survive into the field.