diff --git a/CHANGELOG.md b/CHANGELOG.md index 384002d3d..f8e331ec6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -79,6 +79,30 @@ file is the human-readable summary of what changed and why. ### Gateway +- **The desktop sign-in keeps its fallback controls** — choosing **Login with + SSO** in the desktop app showed the **Open link**, **Copy** and paste-back + controls for a split second and then hid them, because the status line + logged while the browser was being opened wiped the sign-in URL they hang + off. If the browser never opened (no default browser, a remote session), the + only way out was **Back** or the five-minute timeout. The URL now survives + until the sign-in ends. + ([#351](https://github.com/quickbeard/codev-code/pull/351)) +- **Backing out of a sign-in actually stops it** — **Back** in the desktop + dialog, **Log out**, and now **esc** on the TUI's SSO step abort the attempt: + the local callback listener closes and a browser callback that lands + afterwards can no longer write a session behind your back (previously the + listener kept waiting for up to five minutes and a late callback would sign + you back in after a logout). In the TUI, escaping the SSO step returns to the + setup chooser while nothing is configured, and simply closes the dialog + otherwise — before, the step could only be left by quitting the app or + waiting for the timeout, which also quit the app. + ([#351](https://github.com/quickbeard/codev-code/pull/351)) +- **Desktop sign-in honours the system proxy end to end** — the token exchange + and silent refresh behind SSO now go through the same host-aware fetch as + the gateway calls, so proxies, PAC files and enterprise root certificates + apply to the whole flow. Before, only the gateway half did, and a corporate + proxy could pass the browser step and then fail the code exchange. + ([#351](https://github.com/quickbeard/codev-code/pull/351)) - **GLM-5.3-Flash gets its served window** — the model is served with the same 262,144-token window as MiniMax-M3, so it now has its own entry in the built-in table instead of falling back to the safe 200K default. Signing in diff --git a/packages/codev-gateway/src/index.ts b/packages/codev-gateway/src/index.ts index c8320c0f7..6d2b47ba8 100644 --- a/packages/codev-gateway/src/index.ts +++ b/packages/codev-gateway/src/index.ts @@ -34,6 +34,7 @@ export { type ProviderIdentity, } from "./provider" export { + isLoginCancelled, login, logout, parseCallbackParams, diff --git a/packages/codev-gateway/src/sso.ts b/packages/codev-gateway/src/sso.ts index 89aebed61..2b8043209 100644 --- a/packages/codev-gateway/src/sso.ts +++ b/packages/codev-gateway/src/sso.ts @@ -48,6 +48,41 @@ export interface LoginOptions { // Browser launcher; the TUI passes its own `open` util. Fire-and-forget — // the loopback server resolves the flow whether or not a browser launched. openUrl: (url: string) => Promise + // Callers embedded in Electron pass the host-aware net.fetch so the token + // exchange rides the same proxies and PAC files as the backend calls; + // everything else uses the runtime's fetch. + fetchImpl?: typeof fetch + // Aborting closes the loopback server and rejects with a cancellation + // (isLoginCancelled) at whichever step the flow is on, so a sign-in the user + // backed out of can't complete later and write a session behind their back. + signal?: AbortSignal +} + +// Whether a login() rejection is the caller's own abort rather than a failure +// worth surfacing. +export function isLoginCancelled(error: unknown): boolean { + return error instanceof Error && error.name === "LoginCancelled" +} + +function loginCancelled(): Error { + const error = new Error("Login cancelled") + error.name = "LoginCancelled" + return error +} + +// Combines the per-request timeout with the caller's abort, when there is one. +function requestSignal(timeoutMs: number, signal?: AbortSignal): AbortSignal { + const timeout = AbortSignal.timeout(timeoutMs) + return signal ? AbortSignal.any([timeout, signal]) : timeout +} + +// An abort mid-request surfaces as the fetch's own AbortError; report it as +// the cancellation it is so callers don't show it as a failure. +function rethrow(signal?: AbortSignal) { + return (error: unknown): never => { + if (signal?.aborted) throw loginCancelled() + throw error + } } /** @@ -91,11 +126,12 @@ export async function login(options: LoginOptions): Promise { if (stale?.refresh_token) { try { log("Refreshing session...") - const authData = await refreshToAuthData(stale.refresh_token) + const authData = await refreshToAuthData(stale.refresh_token, options.fetchImpl, options.signal) saveAuth(authData) log(`Logged in as ${authData.user.email}`) return authData } catch { + if (options.signal?.aborted) throw loginCancelled() log("Refresh failed, starting full login...") } } @@ -113,8 +149,10 @@ export async function login(options: LoginOptions): Promise { const { code, redirectUri } = await getAuthCode(options, state, challenge, nonce, forceLogin) - const tokenRes = await exchangeCode(code, redirectUri, verifier) - const user = await fetchUserInfo(tokenRes.access_token) + const tokenRes = await exchangeCode(code, redirectUri, verifier, options).catch(rethrow(options.signal)) + const user = await fetchUserInfo(tokenRes.access_token, options.fetchImpl, options.signal).catch( + rethrow(options.signal), + ) const authData: AuthData = { access_token: tokenRes.access_token, @@ -128,6 +166,8 @@ export async function login(options: LoginOptions): Promise { }, } + // The last window an abort can land in: nothing may reach disk after it. + if (options.signal?.aborted) throw loginCancelled() saveAuth(authData) clearForceLogin() log(`Logged in as ${authData.user.email}`) @@ -137,13 +177,13 @@ export async function login(options: LoginOptions): Promise { // Non-interactive SSO: reuse a non-expired cached session, else silently // refresh via the stored refresh_token. Returns null when neither works — // callers then run the interactive login(). -export async function silentSso(): Promise { +export async function silentSso(opts?: { fetchImpl?: typeof fetch }): Promise { const cached = loadAuth() if (cached) return cached const stale = readAuthFile() if (!stale?.refresh_token) return null try { - const authData = await refreshToAuthData(stale.refresh_token) + const authData = await refreshToAuthData(stale.refresh_token, opts?.fetchImpl) saveAuth(authData) return authData } catch { @@ -230,9 +270,13 @@ async function revokeToken(fetchImpl: typeof fetch, token: string | undefined, h }).catch(() => {}) } -async function refreshToAuthData(refreshToken: string): Promise { - const refreshed = await refreshTokens(refreshToken) - const user = await fetchUserInfo(refreshed.access_token) +async function refreshToAuthData( + refreshToken: string, + fetchImpl: typeof fetch = fetch, + signal?: AbortSignal, +): Promise { + const refreshed = await refreshTokens(refreshToken, fetchImpl, signal) + const user = await fetchUserInfo(refreshed.access_token, fetchImpl, signal) return { access_token: refreshed.access_token, id_token: refreshed.id_token, @@ -297,14 +341,17 @@ function getAuthCode( ): Promise<{ code: string; redirectUri: string }> { return new Promise((resolve, reject) => { let timeoutHandle: ReturnType | null = null - // Set once the flow resolves or rejects — by the loopback callback or by a - // manual paste — so the two paths can't both fire. + // Set once the flow resolves or rejects — by the loopback callback, a + // manual paste, the timeout, or the caller's abort — so no two paths can + // both fire. let settled = false + const onAbort = () => failWith(loginCancelled()) const finish = () => { if (timeoutHandle) { clearTimeout(timeoutHandle) timeoutHandle = null } + options.signal?.removeEventListener("abort", onAbort) } // Captured once when listen() completes — server.address() returns null @@ -430,7 +477,21 @@ function getAuthCode( succeed(code) }) + if (options.signal?.aborted) { + reject(loginCancelled()) + return + } + // Armed before listen() so an abort that lands while the port is still + // being bound closes the server instead of leaving it listening. + options.signal?.addEventListener("abort", onAbort, { once: true }) + server.listen(0, "127.0.0.1", () => { + // Aborted while the port was being bound: the abort's close() found no + // running server, so close the one that just came up. + if (settled) { + server.close() + return + } boundPort = (server.address() as AddressInfo).port // Local browser: loopback authorize (on force-login via the /logout // bounce that clears the IdP cookie first). Manual channel: the public @@ -462,8 +523,14 @@ function getAuthCode( }) } -async function exchangeCode(code: string, redirectUri: string, codeVerifier: string): Promise { - const res = await fetch(`${SSO_URL}/token`, { +async function exchangeCode( + code: string, + redirectUri: string, + codeVerifier: string, + options: Pick, +): Promise { + const fetchImpl = options.fetchImpl ?? fetch + const res = await fetchImpl(`${SSO_URL}/token`, { method: "POST", headers: { "Content-Type": "application/x-www-form-urlencoded" }, body: new URLSearchParams({ @@ -473,7 +540,7 @@ async function exchangeCode(code: string, redirectUri: string, codeVerifier: str client_id: CLIENT_ID, code_verifier: codeVerifier, }), - signal: AbortSignal.timeout(SSO_FETCH_TIMEOUT_MS), + signal: requestSignal(SSO_FETCH_TIMEOUT_MS, options.signal), }) if (!res.ok) { throw new Error(`Token exchange failed (${res.status}): ${await res.text()}`) @@ -481,8 +548,12 @@ async function exchangeCode(code: string, redirectUri: string, codeVerifier: str return (await res.json()) as TokenResponse } -async function refreshTokens(refreshToken: string): Promise { - const res = await fetch(`${SSO_URL}/token`, { +async function refreshTokens( + refreshToken: string, + fetchImpl: typeof fetch, + signal?: AbortSignal, +): Promise { + const res = await fetchImpl(`${SSO_URL}/token`, { method: "POST", headers: { "Content-Type": "application/x-www-form-urlencoded" }, body: new URLSearchParams({ @@ -490,16 +561,16 @@ async function refreshTokens(refreshToken: string): Promise { refresh_token: refreshToken, client_id: CLIENT_ID, }), - signal: AbortSignal.timeout(SSO_FETCH_TIMEOUT_MS), + signal: requestSignal(SSO_FETCH_TIMEOUT_MS, signal), }) if (!res.ok) throw new Error(`Token refresh failed (${res.status})`) return (await res.json()) as TokenResponse } -async function fetchUserInfo(accessToken: string, fetchImpl: typeof fetch = fetch) { +async function fetchUserInfo(accessToken: string, fetchImpl: typeof fetch = fetch, signal?: AbortSignal) { const res = await fetchImpl(`${SSO_URL}/userinfo`, { headers: { Authorization: `Bearer ${accessToken}` }, - signal: AbortSignal.timeout(SSO_FETCH_TIMEOUT_MS), + signal: requestSignal(SSO_FETCH_TIMEOUT_MS, signal), }) if (!res.ok) { throw new Error(`Failed to fetch user info (${res.status}): ${await res.text()}`) diff --git a/packages/codev-gateway/test/sso.test.ts b/packages/codev-gateway/test/sso.test.ts index ed7fe063c..ca5b77058 100644 --- a/packages/codev-gateway/test/sso.test.ts +++ b/packages/codev-gateway/test/sso.test.ts @@ -2,7 +2,7 @@ import { afterEach, beforeEach, describe, expect, test } from "bun:test" import { existsSync, mkdtempSync, readFileSync, rmSync } from "node:fs" import { tmpdir } from "node:os" import { join } from "node:path" -import { logout, parseCallbackParams, probeSession } from "../src/sso" +import { isLoginCancelled, login, logout, parseCallbackParams, probeSession, silentSso } from "../src/sso" import { authFilePath, forceLoginPath, loadApiKey, saveApiKey, saveAuth } from "../src/auth-file" describe("parseCallbackParams", () => { @@ -164,3 +164,98 @@ describe("probeSession", () => { expect(await probeSession({ fetchImpl: noFetch })).toBe("live") }) }) + +describe("login", () => { + let tempDir: string + let originalTestHome: string | undefined + + beforeEach(() => { + tempDir = mkdtempSync(join(tmpdir(), "codev-gateway-login-")) + originalTestHome = process.env.CODEV_TEST_HOME + process.env.CODEV_TEST_HOME = tempDir + }) + + afterEach(() => { + if (originalTestHome === undefined) delete process.env.CODEV_TEST_HOME + if (originalTestHome !== undefined) process.env.CODEV_TEST_HOME = originalTestHome + rmSync(tempDir, { recursive: true, force: true }) + }) + + const user = { sub: "s", email: "e@x", displayName: "E" } + const noFetch: typeof fetch = (async (_input: string | URL | Request): Promise => { + throw new Error("must not be called") + }) as typeof fetch + + // The interactive flow up to onReady is pure loopback: no IdP round-trip + // happens until a callback or paste lands, so the abort contract can be + // exercised against the real login() with the browser launcher stubbed. + test("abort rejects as a cancellation and closes the loopback server", async () => { + const abort = new AbortController() + const ready = new Promise((resolve) => { + void login({ + onLog: () => {}, + onReady: (open) => open(), + openUrl: async (url) => resolve(url), + fetchImpl: noFetch, + signal: abort.signal, + }).then( + () => { + throw new Error("must not resolve") + }, + (error) => { + expect(isLoginCancelled(error)).toBe(true) + }, + ) + }) + // openUrl receives the loopback URL (a fresh home has no auth.json, so it + // is the force-login bounce whose redirect_uri names the callback port). + const port = new URL(new URL(await ready).searchParams.get("redirect_uri")!).port + expect(await loopbackAlive(port)).toBe(true) + abort.abort() + await new Promise((resolve) => setTimeout(resolve, 20)) + expect(await loopbackAlive(port)).toBe(false) + expect(existsSync(authFilePath())).toBe(false) + }) + + test("an already-aborted signal never opens a server", async () => { + const abort = new AbortController() + abort.abort() + let opened = false + const error = await login({ + onLog: () => {}, + onReady: () => { + opened = true + }, + openUrl: async () => { + opened = true + }, + fetchImpl: noFetch, + signal: abort.signal, + }).catch((error) => error) + expect(isLoginCancelled(error)).toBe(true) + expect(opened).toBe(false) + }) + + test("the silent refresh and its userinfo round-trip use the injected fetch", async () => { + saveAuth({ access_token: "old-at", id_token: "it", refresh_token: "old-rt", expires_at: Date.now() - 1000, user }) + const urls: string[] = [] + const fetchImpl: typeof fetch = (async (input: string | URL | Request) => { + urls.push(String(input)) + if (urls.length === 1) + return Response.json({ access_token: "new-at", id_token: "new-it", refresh_token: "new-rt", expires_in: 3600 }) + return Response.json(user) + }) as typeof fetch + const auth = await silentSso({ fetchImpl }) + expect(auth?.access_token).toBe("new-at") + expect(urls.map((url) => new URL(url).pathname.split("/").at(-1))).toEqual(["token", "userinfo"]) + // login() takes the same path before ever opening a browser. + const again = await login({ onLog: () => {}, onReady: () => {}, openUrl: async () => {}, fetchImpl: noFetch }) + expect(again.access_token).toBe("new-at") + }) +}) + +async function loopbackAlive(port: string): Promise { + return fetch(`http://127.0.0.1:${port}/nope`, { signal: AbortSignal.timeout(500) }) + .then(() => true) + .catch(() => false) +} diff --git a/packages/desktop/src/main/gateway-sso/controller.test.ts b/packages/desktop/src/main/gateway-sso/controller.test.ts index b4b0dc947..4fe2c5812 100644 --- a/packages/desktop/src/main/gateway-sso/controller.test.ts +++ b/packages/desktop/src/main/gateway-sso/controller.test.ts @@ -67,7 +67,10 @@ describe("gateway sso controller", () => { silentSso: async () => null, login: async (options) => { options.onReady( - () => {}, + // The real login() logs as it opens the browser, right after the + // controller has recorded the URL — that status line must not wipe + // the URL the renderer's fallback controls hang off. + () => options.onLog("Opening browser for SSO login..."), "https://sso.example.test/authorize", (pasted) => (pasted === "good" ? null : "No authorization code found."), ) @@ -78,11 +81,62 @@ describe("gateway sso controller", () => { const run = controller.startLogin() await new Promise((resolve) => setTimeout(resolve, 0)) expect(controller.getState().phase).toBe("awaiting-callback") + expect(controller.getState().status).toBe("Opening browser for SSO login...") expect(controller.getState().authUrl).toBe("https://sso.example.test/authorize") expect(controller.submitCode("bad")).toBe("No authorization code found.") expect(controller.submitCode("good")).toBe(null) gate.resolve(auth) await run + expect(controller.getState().phase).toBe("ready") + expect(controller.getState().authUrl).toBeUndefined() + }) + + test("cancelLogin and logout abort the attempt so login() can close its server", async () => { + for (const end of ["cancelLogin", "logout"] as const) { + let signal: AbortSignal | undefined + const controller = controllerWith({ + silentSso: async () => null, + login: async (options) => { + signal = options.signal + return new Promise((_resolve, reject) => { + options.signal?.addEventListener("abort", () => reject(new Error("Login cancelled"))) + }) + }, + logout: async () => true, + clearSkillhubCookie: () => false, + }) + + const run = controller.startLogin() + await new Promise((resolve) => setTimeout(resolve, 0)) + expect(signal?.aborted).toBe(false) + await controller[end]() + expect(signal?.aborted).toBe(true) + await expect(run).rejects.toThrow("login cancelled") + expect(controller.getState().phase).toBe("idle") + } + }) + + test("the host fetch reaches the silent refresh and the login exchange", async () => { + const fetchImpl: typeof fetch = (async () => new Response("")) as typeof fetch + let silentFetch: typeof fetch | undefined + let loginFetch: typeof fetch | undefined + const controller = createGatewaySsoController({ + openUrl: async () => {}, + fetchImpl, + gateway: { + silentSso: async (opts) => { + silentFetch = opts?.fetchImpl + return null + }, + login: async (options) => { + loginFetch = options.fetchImpl + return auth + }, + }, + }) + await controller.startLogin() + expect(silentFetch).toBe(fetchImpl) + expect(loginFetch).toBe(fetchImpl) }) test("fetchSetup retries and reports the manual-key path", async () => { diff --git a/packages/desktop/src/main/gateway-sso/controller.ts b/packages/desktop/src/main/gateway-sso/controller.ts index 5e0ee559a..68e90a7e1 100644 --- a/packages/desktop/src/main/gateway-sso/controller.ts +++ b/packages/desktop/src/main/gateway-sso/controller.ts @@ -101,23 +101,29 @@ export function createGatewaySsoController(deps: Deps) { const listeners = new Set<(state: GatewaySsoState) => void>() let state: GatewaySsoState = { phase: "idle", status: "" } + // authUrl is carried forward unless the update names it: the sign-in URL is + // set once by onReady, and the very next status line (login() logs as it + // opens the browser) must not wipe it, or the renderer loses its open/copy/ + // paste-back fallback for the whole wait. Phase transitions that end the + // wait pass `authUrl: undefined` explicitly. const setState = (next: Partial) => { state = { phase: next.phase ?? state.phase, status: next.status ?? state.status, - authUrl: next.authUrl, + authUrl: "authUrl" in next ? next.authUrl : state.authUrl, email: next.email ?? state.email, error: next.error, } listeners.forEach((listener) => listener(state)) } - // login() has no cancel API: its promise settles only on callback, paste, - // error, or its own five-minute timeout. Cancelling bumps the generation so - // the abandoned attempt's eventual result is dropped; a late saveAuth to - // disk is harmless (the next silentSso just succeeds). + // Cancelling aborts the attempt's signal — login() closes its loopback + // server and rejects, so a browser callback that lands later cannot write a + // session behind the user's back — and bumps the generation so any result + // already in flight is dropped. let generation = 0 let loginRun: Promise | undefined + let loginAbort: AbortController | undefined let submitStash: ((pasted: string) => string | null) | undefined let session: AuthData | undefined @@ -138,9 +144,11 @@ export function createGatewaySsoController(deps: Deps) { return } const active = ++generation - setState({ phase: "signing-in", status: "Starting SSO login...", error: undefined }) + const abort = new AbortController() + loginAbort = abort + setState({ phase: "signing-in", status: "Starting SSO login...", authUrl: undefined, error: undefined }) const run = (async () => { - const cached = await gateway.silentSso() + const cached = await gateway.silentSso(fetchOpts) if (cached) return cached return gateway.login({ onLog: (message) => { @@ -153,6 +161,8 @@ export function createGatewaySsoController(deps: Deps) { openBrowser() }, openUrl: deps.openUrl, + fetchImpl: deps.fetchImpl, + signal: abort.signal, }) })() loginRun = run @@ -160,14 +170,20 @@ export function createGatewaySsoController(deps: Deps) { const auth = await run if (generation !== active) throw cancelled() session = auth - setState({ phase: "ready", status: "Signed in.", email: auth.user.email }) + setState({ phase: "ready", status: "Signed in.", authUrl: undefined, email: auth.user.email }) } catch (error) { if (generation !== active) throw cancelled() - setState({ phase: "error", status: "", error: error instanceof Error ? error.message : String(error) }) + setState({ + phase: "error", + status: "", + authUrl: undefined, + error: error instanceof Error ? error.message : String(error), + }) throw error } finally { if (generation === active) { loginRun = undefined + loginAbort = undefined submitStash = undefined } } @@ -197,9 +213,11 @@ export function createGatewaySsoController(deps: Deps) { }, cancelLogin() { generation++ + loginAbort?.abort() + loginAbort = undefined loginRun = undefined submitStash = undefined - setState({ phase: "idle", status: "", error: undefined }) + setState({ phase: "idle", status: "", authUrl: undefined, error: undefined }) }, // The disk answer, hardened against a refresh token the wrapper has since // rejected: an expired access token forces a bounded probe of the token @@ -228,9 +246,12 @@ export function createGatewaySsoController(deps: Deps) { // it, mirroring the hub command. The state emission below is what tells // every window's account UI to refetch. async logout() { - // End any in-flight interactive attempt first, so its late success - // cannot resurrect the session this sign-out just ended. + // End any in-flight interactive attempt first — abort closes its + // loopback server — so a late browser callback cannot write back the + // session this sign-out just ended. generation++ + loginAbort?.abort() + loginAbort = undefined loginRun = undefined submitStash = undefined session = undefined @@ -283,7 +304,7 @@ export function createGatewaySsoController(deps: Deps) { ]) if (valid !== false) return { status: "ok" } - const auth = await gateway.silentSso().catch(() => null) + const auth = await gateway.silentSso(fetchOpts).catch(() => null) if (!auth) return { status: "needs-login" } const refreshed = await gateway.fetchApiKey(auth.access_token, fetchOpts).catch(() => "") if (!refreshed) return { status: "needs-login" } diff --git a/packages/tui/src/component/dialog-configure.tsx b/packages/tui/src/component/dialog-configure.tsx index 54c50cb4b..084e891d4 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, + isLoginCancelled, login, saveApiKey, saveCodevConfig, @@ -147,25 +148,34 @@ async function runSsoFlow(ctx: FlowContext) { // without this the input stays live and it looks like Enter did nothing. const [busy, setBusy] = createSignal(false) let submitManualCode: ((pasted: string) => string | null) | undefined + // Escape closes the login step (the dialog stays dismissable until a session + // is in hand); its onClose aborts the attempt so login() closes the loopback + // server and rejects instead of finishing later behind the user's back. If + // nothing is configured yet, app.tsx's gate invariant reopens the entry + // chooser — this step's escape is a Back, not a way into an unconfigured + // chat. replace()/clear() also fire onClose, but by then login() has settled + // and the abort is a no-op. + const abort = new AbortController() - ctx.dialog.replace(() => ( - { - if (busy() || !submitManualCode) return - const error = submitManualCode(value) - setPasteError(error ?? undefined) - // A null result means the code was accepted and login() is exchanging - // it now — lock the field so Enter visibly took effect. - if (!error) setBusy(true) - }} - /> - )) - // replace() resets the store's persistent flag; keep the login step locked. - ctx.dialog.setPersistent(true) + ctx.dialog.replace( + () => ( + { + if (busy() || !submitManualCode) return + const error = submitManualCode(value) + setPasteError(error ?? undefined) + // A null result means the code was accepted and login() is exchanging + // it now — lock the field so Enter visibly took effect. + if (!error) setBusy(true) + }} + /> + ), + () => abort.abort(), + ) try { const session = @@ -178,11 +188,14 @@ async function runSsoFlow(ctx: FlowContext) { openBrowserFn() }, openUrl: open, + signal: abort.signal, })) // Authenticated: everything below is non-interactive network work. Lock the - // view (covers the browser-callback path, which never touched the field). + // view (covers the browser-callback path, which never touched the field) + // and the dialog: escaping now would drop the gate mid-setup. setBusy(true) + ctx.dialog.setPersistent(true) setStatus("Fetching gateway configuration...") const config = await withRetries("Fetching gateway configuration", setStatus, () => fetchCodevConfig(session.access_token), @@ -210,6 +223,9 @@ async function runSsoFlow(ctx: FlowContext) { await runModelFlow(ctx, { apiKey, 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. + if (isLoginCancelled(err)) return // login() is single-attempt (its auth code is single-use, so a silent // retry can't reuse it); the retryable backend steps have already spent // their budget by the time they throw here. Either way, quit rather than @@ -248,7 +264,9 @@ function SsoView(props: { return (