From b43b4d23db955775861c72d2481b4248a40b4d7c Mon Sep 17 00:00:00 2001 From: minhn4 Date: Thu, 10 Sep 2026 10:31:16 +0700 Subject: [PATCH 1/2] feat(tui): re-check the gateway key at startup like the desktop app Move the desktop controller's ensureKey logic into @codev/gateway as ensureGatewayKey so both shells run the same pre-flight: validate the stored gateway key, re-mint it silently through the SSO session when the gateway rejects it, and only ask for a sign-in when that is impossible. The result now says why a login is needed ("no-key" vs "rejected"). The TUI runs it once after sync completes. A refreshed key is merged into the auth registry for every provider id the check names, then the instance is disposed and re-bootstrapped; a rejected key that cannot be renewed opens the configure dialog in a new dismissable mode. Machines without a gateway key are left alone. TUI tests boot the real app, so a new test preload sets CODEV_TEST_HOME to an empty temp home; without it a developer's run would probe the real gateway with their real key. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01UtPnKQ9ATvc9kd1v74zC4Y --- CHANGELOG.md | 10 ++ DIVERGENCES.md | 3 +- packages/codev-gateway/src/ensure-key.ts | 100 +++++++++++++ packages/codev-gateway/src/index.ts | 1 + .../codev-gateway/test/ensure-key.test.ts | 139 ++++++++++++++++++ .../src/main/gateway-sso/controller.test.ts | 2 +- .../src/main/gateway-sso/controller.ts | 53 ++----- packages/tui/bunfig.toml | 2 +- packages/tui/src/app.tsx | 43 ++++++ .../tui/src/component/dialog-configure.tsx | 9 +- packages/tui/test/preload.ts | 13 ++ 11 files changed, 332 insertions(+), 43 deletions(-) create mode 100644 packages/codev-gateway/src/ensure-key.ts create mode 100644 packages/codev-gateway/test/ensure-key.test.ts create mode 100644 packages/tui/test/preload.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index f8e331ec6e..47bf6cb473 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -79,6 +79,16 @@ file is the human-readable summary of what changed and why. ### Gateway +- **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 + since rejected left everything configured on paper and failing on every + message, with `/configure` as the only way back — if you knew to run it. + `codev` now runs the same pre-flight as the desktop app once sync settles: + a rejected key is re-minted silently while the CoDev sign-in still allows + it, and only a key that cannot be renewed without a browser opens the + configure dialog — dismissable, since a provider is configured and it is just + the key that died. Machines set up with some other provider are left alone. - **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 diff --git a/DIVERGENCES.md b/DIVERGENCES.md index 534aff2c89..d29be08a87 100644 --- a/DIVERGENCES.md +++ b/DIVERGENCES.md @@ -157,7 +157,8 @@ from the squash message first, or use a plain merge. | `packages/tui/src/feature-plugins/sidebar/footer.tsx` | Added the editor-context label above the `cwd:branch` line: `useEditorLabel()` (fork-only `src/context/editor-label.tsx`) supplies the effective selection, an `editorFile` memo formats it via `editorContextFileLabel(...)` at a fixed width of 36 (the sidebar's 42 columns less 2 of padding a side and 2 for the bullet), and `` renders it — both from the fork-only `./editor-context-label.tsx`. | The open file + selected range belong with the sidebar's other ambient session state rather than in the prompt footer's crowded keybind-hint row; they sit next to the cwd for a single "where am I working" group. Moved out of the prompt footer, which no longer shows it at all. | Keep the `useEditorLabel` import, the `editorFile` memo, and the `` element as the box's first child; take upstream's changes to the path display below it. | | `packages/tui/src/feature-plugins/sidebar/lsp.tsx` | Whole LSP section wrapped in `` so it renders nothing when `config.lsp` is falsy; the "LSPs are disabled" placeholder text removed (empty state is only "LSPs will activate as files are read") | A disabled feature shouldn't occupy sidebar space with a permanent placeholder. | Keep our `Show` wrapper + removed placeholder; take upstream's surrounding changes. | | `packages/opencode/src/cli/cmd/*.ts` (tui, run, serve, attach, pr, upgrade, uninstall, web) | Help `describe` strings "opencode" → "codev" | `--help` is the first thing users read. | Keep our strings. | -| `packages/tui/src/app.tsx` | Terminal title "OpenCode"/"OC \|" → "CoDev Code"/"CC \|"; the configure-gate effect is a stateless forced-setup invariant — while `sync.status === "complete" && !useConnected() && dialog.stack.length === 0` it (re)opens the fork's `DialogConfigure` (component/dialog-configure.tsx, new file), so escaping any gate-launched flow or a credential-only save bounces back to the gate instead of an unconfigured chat (gates on `!useConnected()`, not `provider.length === 0` — hosted Zen free models always register); added the `codev.configure` command (`/configure`) and a `CODEV_CONFIGURE=1` mount hook for `codev configure`; home screen skipped — plain startup seeds the `dummy` session route and auto-opens a new empty session (reusing a leftover default-titled empty one via `openNewSession`/`findEmptySession`), `session.new` and the current-session `session.deleted` handler open a new session instead of navigating home, and the `-c` resolver prefers non-default-titled sessions so it lands on the last real conversation; added the `app.exit.confirm` command + `EXIT_CONFIRM_WINDOW` const (double-press Ctrl+C to quit: first press shows a toast, second within 1.5 s exits) and gathered its `app_exit_confirm` binding alongside `app_exit`; an onMount "Session upload is paused" warning toast next to the raster-font one, shown when `@codev/upload/nudge`'s `shouldNudgeSignIn()` says the background upload stalled for lack of a CoDev SSO session (SSO-origin keys only; deliberately every launch until the sign-in happens — see the session-upload wiring row) | Tab title branding; first-run setup is a forced gate the user can't escape into an unconfigured chat (the invariant reopens it); `codev` starts directly in the session layout instead of the home screen; a stray Ctrl+C no longer quits on the first press. | Keep our strings, the invariant gate effect, dialog swap + command block, route seed, `openNewSession`/`findEmptySession` + call sites, the `-c` default-title filter, and the `app.exit.confirm` command + binding; take upstream's surrounding changes. | +| `packages/tui/src/app.tsx` | Terminal title "OpenCode"/"OC \|" → "CoDev Code"/"CC \|"; the configure-gate effect is a stateless forced-setup invariant — while `sync.status === "complete" && !useConnected() && dialog.stack.length === 0` it (re)opens the fork's `DialogConfigure` (component/dialog-configure.tsx, new file), so escaping any gate-launched flow or a credential-only save bounces back to the gate instead of an unconfigured chat (gates on `!useConnected()`, not `provider.length === 0` — hosted Zen free models always register); added the `codev.configure` command (`/configure`) and a `CODEV_CONFIGURE=1` mount hook for `codev configure`; home screen skipped — plain startup seeds the `dummy` session route and auto-opens a new empty session (reusing a leftover default-titled empty one via `openNewSession`/`findEmptySession`), `session.new` and the current-session `session.deleted` handler open a new session instead of navigating home, and the `-c` resolver prefers non-default-titled sessions so it lands on the last real conversation; added the `app.exit.confirm` command + `EXIT_CONFIRM_WINDOW` const (double-press Ctrl+C to quit: first press shows a toast, second within 1.5 s exits) and gathered its `app_exit_confirm` binding alongside `app_exit`; an onMount "Session upload is paused" warning toast next to the raster-font one, shown when `@codev/upload/nudge`'s `shouldNudgeSignIn()` says the background upload stalled for lack of a CoDev SSO session (SSO-origin keys only; deliberately every launch until the sign-in happens — see the session-upload wiring row); a one-shot `createEffect` after `sync.status === "complete"` runs `@codev/gateway`'s `ensureGatewayKey()` (the startup gateway-key pre-flight shared with the desktop controller): a `refreshed` result merges the re-minted key into the auth registry for every id it names via `sdk.client.auth.set`, then `instance.dispose()` + `sync.bootstrap()`; a `needs-login` with reason `rejected` opens `` when no dialog is up (`no-key` is ignored — a non-gateway setup, or the gate's own case) | Tab title branding; first-run setup is a forced gate the user can't escape into an unconfigured chat (the invariant reopens it); `codev` starts directly in the session layout instead of the home screen; a stray Ctrl+C no longer quits on the first press. | Keep our strings, the invariant gate effect, dialog swap + command block, route seed, `openNewSession`/`findEmptySession` + call sites, the `-c` default-title filter, and the `app.exit.confirm` command + binding; take upstream's surrounding changes. | +| `packages/tui/bunfig.toml` | `[test] preload` gains the fork-only `./test/preload.ts`, which sets `CODEV_TEST_HOME` to a fresh temp dir (unless already set) before any test runs | The app mount runs the gateway key pre-flight (`ensureGatewayKey`) and the upload sign-in nudge at startup; both read the shared auth file under `~/.codev`/`~/.codev-hub`, and the pre-flight can write a re-minted key back. View tests boot the real app, so without an isolated home a developer's test run would probe the real gateway with their real key. Same convention as core's `Global` and `packages/opencode/test/preload.ts`. | Keep our preload entry; take upstream's other bunfig changes. | | `packages/tui/src/routes/session/index.tsx` | Flow transcript: the scrollbox opens with a home-like banner (`Logo` + `home_bottom` tips slot, `placement="start"`) as its first entry for root sessions (subagent sessions keep a plain 1-row pad), its `maxHeight` is clamped to the content height (a `transcriptHeight` signal read off the content's yoga node in the renderer root's `layout-changed` handler, which then re-runs `calculateLayout` so the clamp lands in the same frame — `scrollHeight` is a frame stale there, and painting with a stale clamp flickers during streaming) so the `Prompt` rides directly below the last message, and a trailing flexGrow spacer absorbs the leftover space until content fills the screen (then the prompt is effectively bottom-anchored and the banner scrolls away with history); the session `Prompt` gets home's `placeholder` pool when the session has no messages so the input shows an `Ask anything... ""` placeholder like home | Startup lands in an empty session instead of the home screen; the transcript flows top-down like terminal scrollback — logo top-left, input following the conversation down — instead of a bottom-anchored prompt with a void above it. | Keep our banner block inside the scrollbox, the `transcriptHeight` clamp + `layout-changed` listener, the trailing spacer, and the `placeholders` prop; take upstream's surrounding changes. | | `packages/tui/src/routes/session/index.tsx` | Sidebar visibility is session-local: removed upstream's persisted `kv.signal<"auto" \| "hide">("sidebar", "auto")` (and the `sidebar() === "auto" && wide()` branch of `sidebarVisible`); visibility is driven only by the non-persisted `sidebarOpen` signal, which the `session.sidebar.toggle` command flips (supersedes the earlier `"auto"` → `"hide"` default change) | The right sidebar starts hidden in every session regardless of terminal width or what the user did last session; `ctrl+b`/`b` only affects the current session. A stale `sidebar` key in existing kv.json files is simply never read. | Keep the kv signal removed and `sidebarVisible` reading only `parentID` + `sidebarOpen`; take upstream's surrounding changes. | | `packages/tui/src/routes/session/index.tsx` | Hidden-sidebar surfaces: a `sidebarHint` memo (modified-file count from `sync.data.session_diff`, only when the sidebar is hidden and the session isn't a subagent; label from `sidebarHintLabel` in the fork-only `routes/session/sidebar-hint.ts`) plus a `sidebarShortcut` (`useCommandShortcut("session.sidebar.toggle")`), passed as the `Prompt`'s previously unused `hint` prop — e.g. `ctrl+b 5 modified files`; and a compact `` (fork-only `routes/session/todo-panel.tsx` + `todo-panel-model.ts`) mounted inside `` under an extra ``, directly above the `session_prompt` slot — up to 4 checkbox items windowed around the active todo plus a summary line (`2 of 6 finished · ctrl+b to show full TODO`), shown only while an unfinished todo exists | The sidebar starts hidden in this fork (see the row above), so users would otherwise never see the agent's plan or modified files or how to reveal them. The panel shows the active todos inline, so the footer hint only needs to cover modified files. | Keep our `sidebarShortcut`/`sidebarHint` memos, the `sidebar-hint` and `todo-panel` imports, the `TodoPanel` mount above the `session_prompt` slot, and the `hint={…}` prop on `` (all additive); take upstream's surrounding changes. If upstream starts passing its own `hint` to this ``, merge the two under one `Show`; if upstream adds its own children to the bottom box, keep `TodoPanel` directly above the prompt slot. | diff --git a/packages/codev-gateway/src/ensure-key.ts b/packages/codev-gateway/src/ensure-key.ts new file mode 100644 index 0000000000..54d498b13c --- /dev/null +++ b/packages/codev-gateway/src/ensure-key.ts @@ -0,0 +1,100 @@ +import { loadApiKey, loadGatewayUrl, saveApiKey, type AuthData } from "./auth-file" +import { fetchApiKey, validateApiKey } from "./backend" +import { GATEWAY_PROVIDER, LEGACY_GATEWAY_PROVIDER_IDS } from "./provider" +import { silentSso } from "./sso" + +// Cap the key check so a slow or hung gateway never delays startup. A real +// /key/info round-trip is far under this; past it we cannot prove the key is +// dead, so we leave it alone (mirrors codev-hub's launch pre-flight). +const KEY_CHECK_TIMEOUT_MS = 2_500 + +export type GatewayKeyCheck = + // Key validated, or the gateway could not be reached to say otherwise. + | { status: "ok" } + // A rejected key was re-minted silently. Every id in providerIDs needs the + // new credential in the local server's auth registry: the current SSO + // identity plus its pre-rename spellings, so a config block written before + // an identity rename is re-keyed too instead of 401ing on a credential + // nothing updates. baseUrl and auth are what a caller that tracks the + // signed-in session (the desktop controller) adopts from the refresh. + | { status: "refreshed"; providerIDs: string[]; apiKey: string; baseUrl: string; auth: AuthData } + // "no-key": nothing stored — a machine set up with a non-gateway provider, + // or never set up. "rejected": the gateway refused the stored key and the + // SSO session could not re-mint it without a browser sign-in. + | { status: "needs-login"; reason: "no-key" | "rejected" } + +export type EnsureKeyFns = { + loadApiKey: typeof loadApiKey + loadGatewayUrl: typeof loadGatewayUrl + validateApiKey: typeof validateApiKey + silentSso: typeof silentSso + fetchApiKey: typeof fetchApiKey + saveApiKey: typeof saveApiKey +} + +export interface EnsureKeyOptions { + // Electron callers pass the host-aware net.fetch so proxies and PAC files + // apply to the check and the re-mint alike. + fetchImpl?: typeof fetch + timeoutMs?: number + // Injectable for tests; defaults to this package's own functions. + fns?: Partial +} + +// Startup pre-flight for the stored gateway key, the client twin of +// codev-hub's ensureFreshGatewayKey. Gateway keys have a lifetime CoDev does +// not control, and nothing else notices a dead one: the provider stays +// "connected" with a credential that 401s on every message, and the configure +// gate only fires when no provider is connected at all — so the sign-in flow +// was unreachable exactly when it was needed. +// +// A rejected key is refreshed silently when the SSO session still allows it, +// which is the common case and costs the user nothing; the refreshed key is +// written to the shared auth file here, and the caller merges it into the +// local server's auth registry. Only a key that cannot be refreshed without a +// browser round-trip asks for one. Never throws on network trouble: only a +// definite rejection acts, because a gateway that is slow or unreachable must +// never cost someone their session. +export async function ensureGatewayKey(options: EnsureKeyOptions = {}): Promise { + const fns: EnsureKeyFns = { + loadApiKey, + loadGatewayUrl, + validateApiKey, + silentSso, + fetchApiKey, + saveApiKey, + ...options.fns, + } + const fetchOpts = { fetchImpl: options.fetchImpl } + const creds = fns.loadApiKey() + if (!creds?.apiKey) return { status: "needs-login", reason: "no-key" } + // SSO-minted keys record no base_url (the configure flows write it for + // manual keys only), so fall back to the gateway URL saved with the config + // block. + const baseUrl = creds.baseUrl ?? fns.loadGatewayUrl() + if (!baseUrl) return { status: "needs-login", reason: "no-key" } + + // true = valid, false = rejected, null = could not tell. + const valid = await Promise.race([ + fns.validateApiKey(creds.apiKey, baseUrl, fetchOpts).catch(() => null), + new Promise((resolve) => setTimeout(resolve, options.timeoutMs ?? KEY_CHECK_TIMEOUT_MS, null)), + ]) + if (valid !== false) return { status: "ok" } + + const auth = await fns.silentSso(fetchOpts).catch(() => null) + if (!auth) return { status: "needs-login", reason: "rejected" } + const refreshed = await fns.fetchApiKey(auth.access_token, fetchOpts).catch(() => "") + if (!refreshed) return { status: "needs-login", reason: "rejected" } + + // Keep every other field: the provider identity and model are what a + // manually named provider is rewritten from, and dropping them would + // silently re-label it. + fns.saveApiKey({ ...creds, apiKey: refreshed }) + return { + status: "refreshed", + providerIDs: [GATEWAY_PROVIDER.id, ...LEGACY_GATEWAY_PROVIDER_IDS], + apiKey: refreshed, + baseUrl, + auth, + } +} diff --git a/packages/codev-gateway/src/index.ts b/packages/codev-gateway/src/index.ts index 6d2b47ba87..f99a13fb7b 100644 --- a/packages/codev-gateway/src/index.ts +++ b/packages/codev-gateway/src/index.ts @@ -24,6 +24,7 @@ export { type ModelWindow, } from "./backend" export { cacheModelLimits, limitsFor, type ModelLimits } from "./model-limits" +export { ensureGatewayKey, type EnsureKeyFns, type EnsureKeyOptions, type GatewayKeyCheck } from "./ensure-key" export { buildCustomProviderConfig, buildGatewayConfig, normalizeGatewayBaseUrl } from "./config-block" export { CUSTOM_FALLBACK_PROVIDER, diff --git a/packages/codev-gateway/test/ensure-key.test.ts b/packages/codev-gateway/test/ensure-key.test.ts new file mode 100644 index 0000000000..c960455a7b --- /dev/null +++ b/packages/codev-gateway/test/ensure-key.test.ts @@ -0,0 +1,139 @@ +import { describe, expect, test } from "bun:test" +import { ensureGatewayKey } from "../src/ensure-key" +import { GATEWAY_PROVIDER, LEGACY_GATEWAY_PROVIDER_IDS } from "../src/provider" +import type { AuthData } from "../src/auth-file" + +// The startup pre-flight shared by the desktop controller and the TUI. Only a +// definite rejection acts, and it prompts only when a silent re-mint is +// impossible. +describe("ensureGatewayKey", () => { + const creds = { apiKey: "sk-old", model: "m", baseUrl: "https://gw.test" } + const auth: AuthData = { + access_token: "token", + id_token: "id", + expires_at: Date.now() + 60_000, + user: { sub: "sub", email: "user@example.test", displayName: "User" }, + } + + test("leaves a valid key alone", async () => { + const result = await ensureGatewayKey({ + fns: { loadApiKey: () => creds, validateApiKey: async () => true }, + }) + expect(result).toEqual({ status: "ok" }) + }) + + // A gateway that cannot be reached is not evidence the key is dead, and + // must never cost someone their session. + test("leaves the key alone when validation cannot answer", async () => { + let refreshed = false + const result = await ensureGatewayKey({ + fns: { + loadApiKey: () => creds, + validateApiKey: async () => { + throw new Error("network down") + }, + silentSso: async () => { + refreshed = true + return auth + }, + }, + }) + expect(result).toEqual({ status: "ok" }) + expect(refreshed).toBe(false) + }) + + test("a validation slower than the cap counts as no answer", async () => { + const result = await ensureGatewayKey({ + timeoutMs: 10, + fns: { + loadApiKey: () => creds, + validateApiKey: () => new Promise((resolve) => setTimeout(resolve, 200, false)), + }, + }) + expect(result).toEqual({ status: "ok" }) + }) + + test("re-mints a rejected key silently and hands back the credential", async () => { + let saved: { apiKey: string; model?: string } | undefined + const fetchImpl: typeof fetch = (async (_input: string | URL | Request) => new Response("")) as typeof fetch + const seen: Array = [] + const result = await ensureGatewayKey({ + fetchImpl, + fns: { + loadApiKey: () => creds, + validateApiKey: async (_key, _url, opts) => { + seen.push(opts?.fetchImpl) + return false + }, + silentSso: async (opts) => { + seen.push(opts?.fetchImpl) + return auth + }, + fetchApiKey: async (_token, opts) => { + seen.push(opts?.fetchImpl) + return "sk-new" + }, + saveApiKey: (next) => { + saved = next + }, + }, + }) + expect(result).toEqual({ + status: "refreshed", + // Legacy ids ride along so a pre-rename config block is re-keyed too. + providerIDs: [GATEWAY_PROVIDER.id, ...LEGACY_GATEWAY_PROVIDER_IDS], + apiKey: "sk-new", + baseUrl: "https://gw.test", + auth, + }) + expect(saved?.apiKey).toBe("sk-new") + // The rest of the record survives: model and provider identity are what a + // manually named provider is rewritten from. + expect(saved?.model).toBe("m") + // The host fetch reaches the check, the refresh, and the re-mint alike. + expect(seen).toEqual([fetchImpl, fetchImpl, fetchImpl]) + }) + + test("asks for a login when the session cannot re-mint", async () => { + const result = await ensureGatewayKey({ + fns: { loadApiKey: () => creds, validateApiKey: async () => false, silentSso: async () => null }, + }) + expect(result).toEqual({ status: "needs-login", reason: "rejected" }) + const empty = await ensureGatewayKey({ + fns: { + loadApiKey: () => creds, + validateApiKey: async () => false, + silentSso: async () => auth, + fetchApiKey: async () => "", + }, + }) + expect(empty).toEqual({ status: "needs-login", reason: "rejected" }) + }) + + test("reports a missing key as such, not as a rejection", async () => { + expect(await ensureGatewayKey({ fns: { loadApiKey: () => null } })).toEqual({ + status: "needs-login", + reason: "no-key", + }) + }) + + test("falls back to the saved gateway url when the key records none", async () => { + const urls: string[] = [] + const result = await ensureGatewayKey({ + fns: { + loadApiKey: () => ({ apiKey: "sk-old" }), + loadGatewayUrl: () => "https://saved.test", + validateApiKey: async (_key, url) => { + urls.push(url) + return true + }, + }, + }) + expect(result).toEqual({ status: "ok" }) + expect(urls).toEqual(["https://saved.test"]) + const none = await ensureGatewayKey({ + fns: { loadApiKey: () => ({ apiKey: "sk-old" }), loadGatewayUrl: () => null }, + }) + expect(none).toEqual({ status: "needs-login", reason: "no-key" }) + }) +}) diff --git a/packages/desktop/src/main/gateway-sso/controller.test.ts b/packages/desktop/src/main/gateway-sso/controller.test.ts index 4fe2c5812f..74d7e30637 100644 --- a/packages/desktop/src/main/gateway-sso/controller.test.ts +++ b/packages/desktop/src/main/gateway-sso/controller.test.ts @@ -117,7 +117,7 @@ describe("gateway sso controller", () => { }) test("the host fetch reaches the silent refresh and the login exchange", async () => { - const fetchImpl: typeof fetch = (async () => new Response("")) as typeof fetch + const fetchImpl: typeof fetch = (async (_input: string | URL | Request) => new Response("")) as typeof fetch let silentFetch: typeof fetch | undefined let loginFetch: typeof fetch | undefined const controller = createGatewaySsoController({ diff --git a/packages/desktop/src/main/gateway-sso/controller.ts b/packages/desktop/src/main/gateway-sso/controller.ts index 68e90a7e13..c51eb7e007 100644 --- a/packages/desktop/src/main/gateway-sso/controller.ts +++ b/packages/desktop/src/main/gateway-sso/controller.ts @@ -2,12 +2,12 @@ import { buildGatewayConfig, cacheModelLimits, clearSkillhubCookie, + ensureGatewayKey, fetchApiKey, fetchCodevConfig, fetchModels, fetchModelWindows, GATEWAY_PROVIDER, - LEGACY_GATEWAY_PROVIDER_IDS, loadAccount, loadApiKey, loadAuth, @@ -288,46 +288,25 @@ export function createGatewaySsoController(deps: Deps) { // which is the common case and costs the user nothing. Only a key that // cannot be refreshed without a browser round-trip asks for one. async ensureKey(): Promise { - const creds = gateway.loadApiKey() - if (!creds?.apiKey) return { status: "needs-login" } - // SSO-minted keys record no base_url (see complete()), so fall back to the - // gateway URL saved with the config block. - const baseUrl = creds.baseUrl ?? gateway.loadGatewayUrl() - if (!baseUrl) return { status: "needs-login" } - - // true = valid, false = rejected, null = could not tell. Only a definite - // rejection acts: a gateway that is slow or unreachable must never cost - // someone their session. - const valid = await Promise.race([ - gateway.validateApiKey(creds.apiKey, baseUrl, fetchOpts).catch(() => null), - new Promise((resolve) => setTimeout(resolve, KEY_CHECK_TIMEOUT_MS, null)), - ]) - if (valid !== false) return { status: "ok" } - - 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" } - - // Keep every other field: the provider identity and model are what a - // manually named provider is rewritten from, and dropping them would - // silently re-label it. - gateway.saveApiKey({ ...creds, apiKey: refreshed }) - session = auth - apiKey = refreshed - gatewayUrl = baseUrl + // The check itself is shared with the TUI (@codev/gateway ensureGatewayKey); + // this wrapper adopts a refreshed session into the controller and maps + // the result onto the wire type. Both needs-login reasons prompt here, + // as before: a desktop set up without a gateway key is still asked to + // sign in. + const result = await ensureGatewayKey({ + fetchImpl: deps.fetchImpl, + timeoutMs: KEY_CHECK_TIMEOUT_MS, + fns: gateway, + }) + if (result.status !== "refreshed") return { status: result.status } + session = result.auth + apiKey = result.apiKey + gatewayUrl = result.baseUrl manualKey = false deps.onConfigured?.() // The renderer owns writes to the local server, so hand it the credential // to merge into the auth registry the same way the configure flow does. - // Legacy ids ride along: a config block written before an identity - // rename still reads its key from the old id, and this refresh is the - // only thing that updates it. - return { - status: "refreshed", - providerIDs: [GATEWAY_PROVIDER.id, ...LEGACY_GATEWAY_PROVIDER_IDS], - apiKey: refreshed, - } + return { status: "refreshed", providerIDs: result.providerIDs, apiKey: result.apiKey } }, setManualApiKey(key: string) { if (!key.length) throw new Error("empty API key") diff --git a/packages/tui/bunfig.toml b/packages/tui/bunfig.toml index b16283cb5b..c29ce70f0a 100644 --- a/packages/tui/bunfig.toml +++ b/packages/tui/bunfig.toml @@ -1,4 +1,4 @@ preload = ["@opentui/solid/preload"] [test] -preload = ["@opentui/solid/preload"] +preload = ["@opentui/solid/preload", "./test/preload.ts"] diff --git a/packages/tui/src/app.tsx b/packages/tui/src/app.tsx index aedeb6a75a..df548e900e 100644 --- a/packages/tui/src/app.tsx +++ b/packages/tui/src/app.tsx @@ -87,6 +87,7 @@ import { DialogVariant } from "./component/dialog-variant" import { createTuiAttention } from "./attention" import * as TuiAudio from "./audio" import { shouldNudgeSignIn } from "@codev/upload/nudge" +import { ensureGatewayKey } from "@codev/gateway" import { win32DisableProcessedInput, win32FlushInputBuffer, win32RasterFontConsole } from "./terminal-win32" import { destroyRenderer } from "./util/renderer" import { cliErrorMessage, errorFormat } from "./util/error" @@ -716,6 +717,48 @@ function App(props: { onSnapshot?: () => Promise; pluginHost: TuiPlugi dialog.replace(() => ) }) + // Fork: startup gateway-key pre-flight, the TUI twin of the desktop gate's + // ensureKey (packages/app/src/components/gateway/gateway-configure-gate.tsx). + // The gate above only fires when no provider is connected, so a stored key + // the gateway has since rejected left the TUI configured on paper and 401ing + // on every message, with /configure as the only way back. ensureGatewayKey + // re-mints silently where the SSO session allows it; the dialog is for the + // case it cannot. Once, after sync settles: it reads the same auth registry + // the refresh writes to, and re-running it on every provider change would + // re-check a key nothing has touched. + let keyChecked = false + createEffect(() => { + if (sync.status !== "complete" || keyChecked) return + keyChecked = true + void ensureGatewayKey() + .then(async (result) => { + if (result.status === "ok") return + if (result.status === "refreshed") { + // Only the credential changed — the keyless config block still points + // at the same gateway and model — so merge it into the auth registry + // (every id the check names: the current SSO identity plus its + // pre-rename spellings) and reload, skipping the config patch the + // full flow does. Dispose then bootstrap, like the configure flow. + await Promise.all( + result.providerIDs.map((providerID) => + sdk.client.auth.set({ providerID, auth: { type: "api", key: result.apiKey } }), + ), + ) + await sdk.client.instance.dispose() + await sync.bootstrap() + return + } + // "no-key" is a machine set up with some other provider (or not at + // all — the gate handles that); only a rejected gateway key is worth + // interrupting for. Dismissable, unlike the gate: a provider is + // configured, its key is just dead. + if (result.reason !== "rejected") return + if (dialog.stack.length > 0) return + dialog.replace(() => ) + }) + .catch(() => undefined) + }) + const currentWorktreeWorkspace = createMemo(() => { const workspaceID = project.workspace.current() if (!workspaceID) return diff --git a/packages/tui/src/component/dialog-configure.tsx b/packages/tui/src/component/dialog-configure.tsx index 084e891d4b..540584ff89 100644 --- a/packages/tui/src/component/dialog-configure.tsx +++ b/packages/tui/src/component/dialog-configure.tsx @@ -56,7 +56,10 @@ interface FlowContext { local: ReturnType } -export function DialogConfigure() { +// `dismissable` is for the startup key pre-flight in app.tsx: a provider IS +// configured there — its gateway key is just dead — so this is not the setup +// screen the app traps you on, and escape has to be a way out. +export function DialogConfigure(props: { dismissable?: boolean } = {}) { const ctx: FlowContext = { dialog: useDialog(), sdk: useSDK(), @@ -70,12 +73,12 @@ export function DialogConfigure() { // The entry chooser is a non-dismissable setup gate: no escape, no // click-outside. The next replace() (a flow step) resets the flag, so only // this first screen is locked. Re-arm on mount so a remount stays locked. - onMount(() => ctx.dialog.setPersistent(true)) + onMount(() => ctx.dialog.setPersistent(!props.dismissable)) return ( Date: Thu, 10 Sep 2026 10:31:31 +0700 Subject: [PATCH 2/2] docs: link the key pre-flight changelog entry to #352 Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01UtPnKQ9ATvc9kd1v74zC4Y --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 47bf6cb473..b091414e45 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -89,6 +89,7 @@ file is the human-readable summary of what changed and why. it, and only a key that cannot be renewed without a browser opens the configure dialog — dismissable, since a provider is configured and it is just the key that died. Machines set up with some other provider are left alone. + ([#352](https://github.com/quickbeard/codev-code/pull/352)) - **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