feat(desktop): make xAI Grok a first-class OAuth provider in the launcher

xAI Grok was only reachable via the "I have an API key" form. xAI's
OAuth (SuperGrok / Premium+) flow already exists in the backend
(`hermes auth add xai-oauth`) but was never surfaced in the desktop
onboarding launcher.

Add a loopback PKCE flow: the local backend binds the 127.0.0.1
callback listener, the client opens the browser, and the redirect lands
back automatically — no code to copy/paste. Reuses the existing xAI
OAuth helpers (discovery, callback server, token exchange, persist)
rather than duplicating them.

- web_server: catalog entry (flow: loopback) + status dispatch +
  _start_xai_loopback_flow + background worker + route branch
- desktop: 'loopback' flow type, awaiting_browser status, xAI Grok card
  (PROVIDER_DISPLAY / FLOW_SUBTITLES / FlowPanel waiting render)
- tests: catalog listing, start authorize-url, worker persist, state
  mismatch rejection
This commit is contained in:
Brooklyn Nicholson
2026-06-02 17:34:00 -05:00
parent c47b9d126f
commit dd5e97bd7f
5 changed files with 434 additions and 6 deletions
+21 -3
View File
@@ -18,6 +18,7 @@ import type { ModelOptionProvider, OAuthProvider, OAuthStartResponse } from '@/t
type PkceStart = Extract<OAuthStartResponse, { flow: 'pkce' }>
type DeviceStart = Extract<OAuthStartResponse, { flow: 'device_code' }>
type LoopbackStart = Extract<OAuthStartResponse, { flow: 'loopback' }>
export type OnboardingMode = 'apikey' | 'oauth'
@@ -26,6 +27,10 @@ export type OnboardingFlow =
| { provider: OAuthProvider; status: 'starting' }
| { code: string; provider: OAuthProvider; start: PkceStart; status: 'awaiting_user' }
| { copied: boolean; provider: OAuthProvider; start: DeviceStart; status: 'polling' }
// Loopback PKCE (xAI Grok): browser opens, the local backend's 127.0.0.1
// listener catches the redirect, and we poll until the worker finishes.
// No code to paste and no user_code to show — just a waiting state.
| { provider: OAuthProvider; start: LoopbackStart; status: 'awaiting_browser' }
| { provider: OAuthProvider; start: OAuthStartResponse; status: 'submitting' }
| { copied: boolean; provider: OAuthProvider; status: 'external_pending' }
| { provider: OAuthProvider; status: 'success' }
@@ -419,7 +424,8 @@ export async function startProviderOAuth(provider: OAuthProvider, ctx: Onboardin
try {
const start = await startOAuthLogin(provider.id)
await window.hermesDesktop?.openExternal(start.flow === 'pkce' ? start.auth_url : start.verification_url)
const browserUrl = start.flow === 'device_code' ? start.verification_url : start.auth_url
await window.hermesDesktop?.openExternal(browserUrl)
if (start.flow === 'pkce') {
setFlow({ status: 'awaiting_user', provider, start, code: '' })
@@ -427,14 +433,26 @@ export async function startProviderOAuth(provider: OAuthProvider, ctx: Onboardin
return
}
if (start.flow === 'loopback') {
// No code to paste: the redirect lands on the backend's loopback
// listener. Just wait and poll the session until the worker finishes.
setFlow({ status: 'awaiting_browser', provider, start })
pollTimer = window.setInterval(() => void pollSession(provider, start, ctx), POLL_MS)
return
}
setFlow({ status: 'polling', provider, start, copied: false })
pollTimer = window.setInterval(() => void pollDevice(provider, start, ctx), POLL_MS)
pollTimer = window.setInterval(() => void pollSession(provider, start, ctx), POLL_MS)
} catch (error) {
setFlow({ status: 'error', provider, message: `Could not start sign-in: ${errMessage(error)}` })
}
}
async function pollDevice(provider: OAuthProvider, start: DeviceStart, ctx: OnboardingContext) {
// Poll a session-backed flow (device_code or loopback) until it resolves.
// Both shapes only need the session_id to poll; the start is threaded
// through to the error flow so the user can retry from the same context.
async function pollSession(provider: OAuthProvider, start: DeviceStart | LoopbackStart, ctx: OnboardingContext) {
try {
const { error_message, status } = await pollOAuthSession(provider.id, start.session_id)