From de837f086c49bdd2a0c32063e87ae615e5d8a66d Mon Sep 17 00:00:00 2001 From: minhn4 Date: Thu, 10 Sep 2026 10:25:29 +0700 Subject: [PATCH 1/2] fix(gateway): keep the sign-in URL, abort cancelled logins, proxy the token exchange The desktop controller's state merge never carried authUrl forward, and the real login() logs as it opens the browser, so the URL vanished on the very next event and the renderer hid its open/copy/paste-back fallback for the whole wait. Merge it like email and clear it on the transitions that end the wait. login() now takes an AbortSignal: aborting closes the loopback server and rejects with a cancellation at whichever step the flow is on, so a callback landing after Back, Log out, or the TUI's new escape from the SSO step can no longer write a session to disk. login() and silentSso() also take the caller's fetchImpl so the desktop's token exchange and silent refresh ride Electron's net.fetch like the gateway calls already do. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01UtPnKQ9ATvc9kd1v74zC4Y --- CHANGELOG.md | 21 ++++ packages/codev-gateway/src/index.ts | 1 + packages/codev-gateway/src/sso.ts | 107 +++++++++++++++--- packages/codev-gateway/test/sso.test.ts | 97 +++++++++++++++- .../src/main/gateway-sso/controller.test.ts | 56 ++++++++- .../src/main/gateway-sso/controller.ts | 47 +++++--- .../tui/src/component/dialog-configure.tsx | 58 ++++++---- 7 files changed, 334 insertions(+), 53 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 384002d3d..ff2b92597 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -79,6 +79,27 @@ 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. +- **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. +- **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. - **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 ( Date: Thu, 10 Sep 2026 10:25:49 +0700 Subject: [PATCH 2/2] docs: link the sign-in changelog entries to #351 Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01UtPnKQ9ATvc9kd1v74zC4Y --- CHANGELOG.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index ff2b92597..f8e331ec6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -86,6 +86,7 @@ file is the human-readable summary of what changed and why. 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 @@ -95,11 +96,13 @@ file is the human-readable summary of what changed and why. 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