mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-06-30 11:52:04 +00:00
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:
parent
c47b9d126f
commit
dd5e97bd7f
5 changed files with 434 additions and 6 deletions
|
|
@ -107,8 +107,9 @@ const PROVIDER_DISPLAY: Record<string, { order: number; title: string }> = {
|
|||
anthropic: { order: 1, title: 'Anthropic Claude' },
|
||||
'openai-codex': { order: 2, title: 'OpenAI Codex / ChatGPT' },
|
||||
'minimax-oauth': { order: 3, title: 'MiniMax' },
|
||||
'claude-code': { order: 4, title: 'Claude Code' },
|
||||
'qwen-oauth': { order: 5, title: 'Qwen Code' }
|
||||
'xai-oauth': { order: 4, title: 'xAI Grok' },
|
||||
'claude-code': { order: 5, title: 'Claude Code' },
|
||||
'qwen-oauth': { order: 6, title: 'Qwen Code' }
|
||||
}
|
||||
|
||||
const assetPath = (path: string) => `${import.meta.env.BASE_URL}${path.replace(/^\/+/, '')}`
|
||||
|
|
@ -116,6 +117,7 @@ const assetPath = (path: string) => `${import.meta.env.BASE_URL}${path.replace(/
|
|||
const FLOW_SUBTITLES: Record<OAuthProvider['flow'], string> = {
|
||||
pkce: 'Opens your browser to sign in, then continues here',
|
||||
device_code: 'Opens a verification page in your browser — Hermes connects automatically',
|
||||
loopback: 'Opens your browser to sign in — Hermes connects automatically',
|
||||
external: 'Sign in once in your terminal, then come back to chat'
|
||||
}
|
||||
|
||||
|
|
@ -565,6 +567,24 @@ function FlowPanel({ ctx, flow }: { ctx: OnboardingContext; flow: OnboardingFlow
|
|||
)
|
||||
}
|
||||
|
||||
if (flow.status === 'awaiting_browser') {
|
||||
return (
|
||||
<Step title={`Sign in with ${title}`}>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
We opened {title} in your browser. Authorize Hermes there and you'll be connected
|
||||
automatically — nothing to copy or paste.
|
||||
</p>
|
||||
<FlowFooter left={<DocsLink href={flow.start.auth_url}>Re-open sign-in page</DocsLink>}>
|
||||
<span className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||
<Loader2 className="size-3 animate-spin" />
|
||||
Waiting for you to authorize...
|
||||
</span>
|
||||
<CancelBtn size="sm" />
|
||||
</FlowFooter>
|
||||
</Step>
|
||||
)
|
||||
}
|
||||
|
||||
if (flow.status === 'external_pending') {
|
||||
return (
|
||||
<Step title={`Sign in with ${title}`}>
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -48,7 +48,7 @@ export interface OAuthProviderStatus {
|
|||
export interface OAuthProvider {
|
||||
cli_command: string
|
||||
docs_url: string
|
||||
flow: 'device_code' | 'external' | 'pkce'
|
||||
flow: 'device_code' | 'external' | 'loopback' | 'pkce'
|
||||
id: string
|
||||
name: string
|
||||
status: OAuthProviderStatus
|
||||
|
|
@ -73,6 +73,12 @@ export type OAuthStartResponse =
|
|||
user_code: string
|
||||
verification_url: string
|
||||
}
|
||||
| {
|
||||
auth_url: string
|
||||
expires_in: number
|
||||
flow: 'loopback'
|
||||
session_id: string
|
||||
}
|
||||
|
||||
export interface OAuthSubmitResponse {
|
||||
message?: string
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue