diff --git a/CHANGELOG.md b/CHANGELOG.md index cc0b13fe5..196df50e4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -79,6 +79,17 @@ file is the human-readable summary of what changed and why. ### Gateway +- **A gateway that refuses your account says so, once** — when the CoDev + gateway declines to issue a key for an account (today: an email domain + outside its allowlist, such as a sub-domain of a listed one), **Login with + SSO** used to retry the request + three times and then quit with a generic "setup failed" wrapped around a + nested 502/400 status chain. The refusal is a policy answer, not a flaky + network, so it is no longer retried; the TUI and the desktop app now land on + the manual API-key step with the gateway's own reason and a note that + retrying will not change it, so a key issued by hand gets you going. Needs + the matching codev-backend release, which reports the refusal as `403 +key_refused`; against an older backend the previous behaviour stands. - **The terminal app notices a dead gateway key at startup** — gateway keys have a lifetime CoDev does not control, and until now only the desktop app checked the stored one when it launched. In the TUI a key the gateway had diff --git a/packages/app/src/components/gateway/dialog-configure.tsx b/packages/app/src/components/gateway/dialog-configure.tsx index c12eeeb53..27e292c57 100644 --- a/packages/app/src/components/gateway/dialog-configure.tsx +++ b/packages/app/src/components/gateway/dialog-configure.tsx @@ -19,7 +19,7 @@ const PROVIDER_POLL_MS = 250 type Step = | { kind: "menu" } | { kind: "sso" } - | { kind: "apikey" } + | { kind: "apikey"; refused?: string } | { kind: "model"; models: string[] } | { kind: "busy"; text: string } @@ -77,7 +77,7 @@ export function DialogConfigure(props: { dismissable?: boolean } = {}) { setStep({ kind: "busy", text: language.t("gateway.configure.status.setup") }) const setup = await sso.fetchSetup() if (!setup.hasApiKey) { - setStep({ kind: "apikey" }) + setStep({ kind: "apikey", refused: setup.keyRefused }) return } await toModels() @@ -255,7 +255,14 @@ export function DialogConfigure(props: { dismissable?: boolean } = {}) {
{language.t("gateway.configure.apikey.title")}
-

{language.t("gateway.configure.apikey.description")}

+

+ {(() => { + const refused = (step() as Extract).refused + return refused + ? language.t("gateway.configure.apikey.refused", { reason: refused }) + : language.t("gateway.configure.apikey.description") + })()} +

({}))) as ErrorResponse - throw new Error(`Backend /auth/exchange failed (${res.status}): ${body.error || res.statusText}`) + const reason = body.error || res.statusText + throw new BackendError(`Backend /auth/exchange failed (${res.status}): ${reason}`, res.status, reason, body.code) } const data = (await res.json()) as { api_key?: string } return data.api_key ?? "" diff --git a/packages/codev-gateway/src/index.ts b/packages/codev-gateway/src/index.ts index f99a13fb7..ab74c486c 100644 --- a/packages/codev-gateway/src/index.ts +++ b/packages/codev-gateway/src/index.ts @@ -14,10 +14,12 @@ export { type CodevConfig, } from "./auth-file" export { + BackendError, fetchApiKey, fetchCodevConfig, fetchModels, fetchModelWindows, + isKeyRefusal, supabaseExchange, validateApiKey, type AnalysisBackendSession, diff --git a/packages/codev-gateway/test/backend.test.ts b/packages/codev-gateway/test/backend.test.ts index 313529cbf..7ae724d70 100644 --- a/packages/codev-gateway/test/backend.test.ts +++ b/packages/codev-gateway/test/backend.test.ts @@ -1,5 +1,5 @@ import { afterAll, describe, expect, test } from "bun:test" -import { fetchModels, fetchModelWindows, validateApiKey } from "../src/backend" +import { BackendError, fetchApiKey, fetchModels, fetchModelWindows, isKeyRefusal, validateApiKey } from "../src/backend" // A real loopback server (not a mock) that records the path + auth header of the // last request, so tests assert the exact endpoint fetchModels/validateApiKey @@ -18,6 +18,39 @@ const base = `http://127.0.0.1:${server.port}` afterAll(() => server.stop(true)) +describe("fetchApiKey", () => { + // fetchApiKey targets the baked-in BACKEND_URL, so these go through the + // fetchImpl seam rather than the loopback server. + const respond = (res: Response) => ({ fetchImpl: (async () => res) as unknown as typeof fetch }) + + test("returns the api_key on a 2xx", async () => { + expect(await fetchApiKey("t", respond(Response.json({ api_key: "sk-1" })))).toBe("sk-1") + }) + + test("a 403 key_refused becomes a BackendError carrying the code and reason", async () => { + const reason = "The gateway declined to issue a key for u@os.example.com: Only @example.com emails are supported" + const err = await fetchApiKey("t", respond(Response.json({ error: reason, code: "key_refused" }, { status: 403 }))) + .then(() => undefined) + .catch((e: unknown) => e) + if (!(err instanceof BackendError)) throw new Error("expected a BackendError") + expect(err.status).toBe(403) + expect(err.code).toBe("key_refused") + expect(err.reason).toBe(reason) + expect(err.message).toBe(`Backend /auth/exchange failed (403): ${reason}`) + expect(isKeyRefusal(err)).toBe(true) + }) + + test("a plain 502 is a BackendError but not a refusal", async () => { + const err = await fetchApiKey("t", respond(Response.json({ error: "upstream" }, { status: 502 }))) + .then(() => undefined) + .catch((e: unknown) => e) + if (!(err instanceof BackendError)) throw new Error("expected a BackendError") + expect(err.code).toBeUndefined() + expect(isKeyRefusal(err)).toBe(false) + expect(isKeyRefusal(new Error("x"))).toBe(false) + }) +}) + describe("fetchModels", () => { test("returns the model ids from an OpenAI-shaped /v1/models response", async () => { handler = () => Response.json({ data: [{ id: "a" }, { id: "b" }] }) diff --git a/packages/desktop/src/main/gateway-sso/controller.test.ts b/packages/desktop/src/main/gateway-sso/controller.test.ts index 74d7e3063..7f4b1ff54 100644 --- a/packages/desktop/src/main/gateway-sso/controller.test.ts +++ b/packages/desktop/src/main/gateway-sso/controller.test.ts @@ -1,5 +1,5 @@ import { describe, expect, test } from "bun:test" -import { GATEWAY_PROVIDER, LEGACY_GATEWAY_PROVIDER_IDS, type AuthData } from "@codev/gateway" +import { BackendError, GATEWAY_PROVIDER, LEGACY_GATEWAY_PROVIDER_IDS, type AuthData } from "@codev/gateway" import { createGatewaySsoController } from "./controller" const auth: AuthData = { @@ -162,6 +162,28 @@ describe("gateway sso controller", () => { expect(setup).toEqual({ gatewayUrl: "https://gw.example.test", hasApiKey: false }) }) + test("fetchSetup does not retry a key refusal and reports the gateway's reason", async () => { + let keyCalls = 0 + const reason = + "The gateway declined to issue a key for user@os.example.test: Only @example.test emails are supported" + const controller = controllerWith({ + silentSso: async () => auth, + fetchCodevConfig: async () => ({ supabaseUrl: "s", supabaseAnonKey: "k", gatewayUrl: "https://gw.example.test" }), + fetchApiKey: async () => { + keyCalls++ + throw new BackendError(`Backend /auth/exchange failed (403): ${reason}`, 403, reason, "key_refused") + }, + saveCodevConfig: () => {}, + }) + + await controller.startLogin() + const setup = await controller.fetchSetup() + // One attempt: the answer is a policy, not a flaky network. + expect(keyCalls).toBe(1) + expect(setup).toEqual({ gatewayUrl: "https://gw.example.test", hasApiKey: false, keyRefused: reason }) + expect(controller.getState().phase).toBe("ready") + }) + test("retry exhaustion surfaces the last error", async () => { const controller = controllerWith({ silentSso: async () => auth, diff --git a/packages/desktop/src/main/gateway-sso/controller.ts b/packages/desktop/src/main/gateway-sso/controller.ts index c51eb7e00..a8811cd84 100644 --- a/packages/desktop/src/main/gateway-sso/controller.ts +++ b/packages/desktop/src/main/gateway-sso/controller.ts @@ -8,6 +8,7 @@ import { fetchModels, fetchModelWindows, GATEWAY_PROVIDER, + isKeyRefusal, loadAccount, loadApiKey, loadAuth, @@ -194,7 +195,10 @@ export function createGatewaySsoController(deps: Deps) { try { return await run() } catch (error) { - if (attempt >= RETRIES) throw error + // A refusal is a policy answer, not a flaky network: the same request + // gets the same answer, so spending the budget on it only delays the + // renderer's fallback. + if (attempt >= RETRIES || isKeyRefusal(error)) throw error setState({ status: `${label} failed — retrying (${attempt}/${RETRIES})...` }) } } @@ -272,10 +276,27 @@ export function createGatewaySsoController(deps: Deps) { gateway.saveCodevConfig(config) gatewayUrl = config.gatewayUrl setState({ status: "Fetching gateway API key..." }) - apiKey = await withRetries("Fetching gateway API key", () => gateway.fetchApiKey(auth.access_token, fetchOpts)) + // A refusal — the gateway declined this account, e.g. an email domain + // outside its allowlist — is reported beside the no-key result rather + // than thrown: the renderer's manual-key step is the right place to land + // either way, and it shows the gateway's reason so the user knows why. + const minted = await withRetries("Fetching gateway API key", () => + gateway.fetchApiKey(auth.access_token, fetchOpts), + ).then( + (key) => ({ key, refusal: undefined as string | undefined }), + (error: unknown) => { + if (!isKeyRefusal(error)) throw error + return { key: "", refusal: error.reason } + }, + ) + apiKey = minted.key manualKey = false setState({ phase: "ready", status: "" }) - return { gatewayUrl: config.gatewayUrl, hasApiKey: apiKey.length > 0 } + return { + gatewayUrl: config.gatewayUrl, + hasApiKey: apiKey.length > 0, + ...(minted.refusal ? { keyRefused: minted.refusal } : {}), + } }, // Startup pre-flight, the desktop twin of codev-hub's ensureFreshGatewayKey. // Gateway keys have a lifetime CoDev does not control, and nothing here diff --git a/packages/tui/src/component/dialog-configure.tsx b/packages/tui/src/component/dialog-configure.tsx index 540584ff8..3b9485ad0 100644 --- a/packages/tui/src/component/dialog-configure.tsx +++ b/packages/tui/src/component/dialog-configure.tsx @@ -6,6 +6,7 @@ import { fetchModels, fetchModelWindows, GATEWAY_PROVIDER, + isKeyRefusal, isLoginCancelled, login, saveApiKey, @@ -206,13 +207,28 @@ async function runSsoFlow(ctx: FlowContext) { saveCodevConfig(config) setStatus("Fetching gateway API key...") - const apiKey = await withRetries("Fetching gateway API key", setStatus, () => fetchApiKey(session.access_token)) + // A refusal — the gateway declined this account, e.g. an email domain + // outside its allowlist — is neither retryable nor a reason to quit: the + // manual path below works with a key issued by hand, so it lands there + // with the gateway's own reason instead of in failAndQuit's generic + // "setup failed". Same handling as codev-hub's install step. + const minted = await withRetries("Fetching gateway API key", setStatus, () => + fetchApiKey(session.access_token), + ).then( + (key) => ({ key, refusal: undefined as string | undefined }), + (err: unknown) => { + if (!isKeyRefusal(err)) throw err + return { key: "", refusal: err.reason } + }, + ) // The backend returns an empty key for accounts without a provisioned // gateway key — same as the hub, route those to manual entry. - if (!apiKey) { + if (!minted.key) { ctx.toast.show({ - variant: "info", - message: "Your account has no gateway API key. Enter it manually.", + variant: minted.refusal ? "warning" : "info", + message: minted.refusal + ? `${minted.refusal}. Retrying won't change this — enter an API key issued to you by hand.` + : "Your account has no gateway API key. Enter it manually.", }) const pasted = await DialogPrompt.show(ctx.dialog, "API Key", { placeholder: "sk-...", persistent: true }) if (!pasted?.trim()) return @@ -224,7 +240,7 @@ async function runSsoFlow(ctx: FlowContext) { return } - await runModelFlow(ctx, { apiKey, gatewayBaseUrl: config.gatewayUrl }) + await runModelFlow(ctx, { apiKey: minted.key, gatewayBaseUrl: config.gatewayUrl }) } catch (err) { // The user backed out with escape: the dialog is already closed and the // gate (if still unconfigured) has the entry chooser back up. diff --git a/packages/tui/src/component/dialog-custom-provider.tsx b/packages/tui/src/component/dialog-custom-provider.tsx index 7cf9b8750..180e39f65 100644 --- a/packages/tui/src/component/dialog-custom-provider.tsx +++ b/packages/tui/src/component/dialog-custom-provider.tsx @@ -2,6 +2,7 @@ import { buildCustomProviderConfig, CUSTOM_FALLBACK_PROVIDER, fetchModels, + isKeyRefusal, providerFromName, slugifyProviderName, } from "@codev/gateway" @@ -174,7 +175,10 @@ export async function withRetries( try { return await op() } catch (err) { - if (attempt >= RETRIES) throw err + // A refusal is a policy answer, not a flaky network: the same request + // gets the same answer, so spending the budget on it only delays the + // caller's fallback. + if (attempt >= RETRIES || isKeyRefusal(err)) throw err onStatus(`${label} failed — retrying (${attempt + 1}/${RETRIES})...`) } }