feat: add Codex OAuth login and fix channel config display

- Add OpenAI Codex Device Code Flow login (backend polling + frontend modal)
- Codex provider integrated into preset dropdown (hides URL/API key fields)
- Sync provider model catalogs with Hermes system
- Fix channel config not displaying on first visit (wait for data load)
- Fix sidebar model list not refreshing after adding provider
- Add autocomplete="off" to API key input to prevent browser autofill

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
ekko
2026-04-17 23:11:57 +08:00
parent 26bb821e29
commit 9979871550
19 changed files with 722 additions and 6 deletions
@@ -0,0 +1,347 @@
import Router from '@koa/router'
import { randomUUID } from 'crypto'
import { join } from 'path'
import { homedir } from 'os'
import { readFileSync, writeFileSync, existsSync, mkdirSync } from 'fs'
import { getActiveAuthPath } from '../../services/hermes/hermes-profile'
// --- OAuth Constants ---
const CODEX_CLIENT_ID = 'app_EMoamEEZ73f0CkXaXp7hrann'
const CODEX_DEVICE_AUTH_URL = 'https://auth.openai.com/api/accounts/deviceauth/usercode'
const CODEX_DEVICE_TOKEN_URL = 'https://auth.openai.com/api/accounts/deviceauth/token'
const CODEX_OAUTH_TOKEN_URL = 'https://auth.openai.com/oauth/token'
const CODEX_DEFAULT_BASE_URL = 'https://chatgpt.com/backend-api/codex'
const CODEX_REDIRECT_URI = 'https://auth.openai.com/deviceauth/callback'
const CODEX_VERIFICATION_URL = 'https://auth.openai.com/codex/device'
const CODEX_HOME = join(homedir(), '.codex')
const POLL_MAX_DURATION = 15 * 60 * 1000 // 15 minutes
const POLL_DEFAULT_INTERVAL = 5000 // 5 seconds
// --- Session Store ---
interface CodexSession {
id: string
userCode: string
deviceAuthId: string
status: 'pending' | 'approved' | 'expired' | 'error'
error?: string
accessToken?: string
refreshToken?: string
createdAt: number
}
const sessions = new Map<string, CodexSession>()
function cleanupExpiredSessions() {
const now = Date.now()
sessions.forEach((session, id) => {
if (now - session.createdAt > POLL_MAX_DURATION + 60000) {
sessions.delete(id)
}
})
}
// --- Auth file helpers ---
interface AuthJson {
version?: number
active_provider?: string
providers?: Record<string, any>
credential_pool?: Record<string, any[]>
updated_at?: string
}
function loadAuthJson(authPath: string): AuthJson {
try {
const raw = readFileSync(authPath, 'utf-8')
return JSON.parse(raw) as AuthJson
} catch {
return { version: 1 }
}
}
function saveAuthJson(authPath: string, data: AuthJson): void {
data.updated_at = new Date().toISOString()
const dir = authPath.substring(0, authPath.lastIndexOf('/'))
if (!existsSync(dir)) mkdirSync(dir, { recursive: true })
writeFileSync(authPath, JSON.stringify(data, null, 2) + '\n', { mode: 0o600 })
}
function saveCodexCliTokens(accessToken: string, refreshToken: string): void {
const codexHome = process.env.CODEX_HOME || CODEX_HOME
const codexAuthPath = join(codexHome, 'auth.json')
const dir = codexAuthPath.substring(0, codexAuthPath.lastIndexOf('/'))
if (!existsSync(dir)) mkdirSync(dir, { recursive: true })
const data = {
tokens: { access_token: accessToken, refresh_token: refreshToken },
last_refresh: new Date().toISOString(),
}
writeFileSync(codexAuthPath, JSON.stringify(data, null, 2) + '\n', { mode: 0o600 })
}
function decodeJwtExp(token: string): number | null {
try {
const parts = token.split('.')
if (parts.length !== 3) return null
const payload = Buffer.from(parts[1], 'base64url').toString('utf-8')
const claims = JSON.parse(payload)
return typeof claims.exp === 'number' ? claims.exp : null
} catch {
return null
}
}
// --- Background login worker ---
async function codexLoginWorker(session: CodexSession, authPath: string): Promise<void> {
const startTime = Date.now()
const interval = POLL_DEFAULT_INTERVAL
while (Date.now() - startTime < POLL_MAX_DURATION) {
await new Promise(resolve => setTimeout(resolve, interval))
if (session.status !== 'pending') return
try {
// Step 3: Poll for authorization
const pollRes = await fetch(CODEX_DEVICE_TOKEN_URL, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
device_auth_id: session.deviceAuthId,
user_code: session.userCode,
}),
signal: AbortSignal.timeout(10000),
})
if (pollRes.status === 200) {
const pollData = await pollRes.json() as { authorization_code: string; code_verifier: string }
// Step 4: Exchange authorization code for tokens
const tokenRes = await fetch(CODEX_OAUTH_TOKEN_URL, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({
grant_type: 'authorization_code',
code: pollData.authorization_code,
redirect_uri: CODEX_REDIRECT_URI,
client_id: CODEX_CLIENT_ID,
code_verifier: pollData.code_verifier,
}).toString(),
signal: AbortSignal.timeout(15000),
})
if (!tokenRes.ok) {
const errText = await tokenRes.text()
console.error('[Codex Auth] Token exchange failed:', tokenRes.status, errText)
session.status = 'error'
session.error = `Token exchange failed: ${tokenRes.status}`
return
}
const tokenData = await tokenRes.json() as { access_token: string; refresh_token?: string }
const refreshToken = tokenData.refresh_token || ''
session.accessToken = tokenData.access_token
session.refreshToken = refreshToken
session.status = 'approved'
// Save to auth.json
const auth = loadAuthJson(authPath)
if (!auth.providers) auth.providers = {}
auth.providers['openai-codex'] = {
tokens: {
access_token: tokenData.access_token,
refresh_token: refreshToken,
},
last_refresh: new Date().toISOString(),
auth_mode: 'chatgpt',
}
// Add to credential pool
if (!auth.credential_pool) auth.credential_pool = {}
auth.credential_pool['openai-codex'] = [{
id: `openai-codex-${Date.now()}`,
label: 'OpenAI Codex',
base_url: CODEX_DEFAULT_BASE_URL,
access_token: tokenData.access_token,
last_status: null,
}]
saveAuthJson(authPath, auth)
// Save to ~/.codex/auth.json for CLI sync
saveCodexCliTokens(tokenData.access_token, refreshToken)
console.log('[Codex Auth] Login successful')
return
}
if (pollRes.status === 403 || pollRes.status === 404) {
// Not yet authorized, keep polling
continue
}
// Other error status
console.error('[Codex Auth] Poll failed:', pollRes.status)
session.status = 'error'
session.error = `Poll failed: ${pollRes.status}`
return
} catch (err: any) {
if (err.name === 'TimeoutError' || err.name === 'AbortError') {
continue
}
console.error('[Codex Auth] Poll error:', err.message)
session.status = 'error'
session.error = err.message
return
}
}
// Timeout
session.status = 'expired'
}
// --- Routes ---
export const codexAuthRoutes = new Router()
codexAuthRoutes.post('/api/hermes/auth/codex/start', async (ctx) => {
try {
cleanupExpiredSessions()
// Step 1: Request device code
const res = await fetch(CODEX_DEVICE_AUTH_URL, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Accept': 'application/json',
'User-Agent': 'node-fetch',
},
body: JSON.stringify({ client_id: CODEX_CLIENT_ID }),
signal: AbortSignal.timeout(10000),
})
if (!res.ok) {
let errorBody: any = null
try { errorBody = await res.json() } catch { /* ignore */ }
console.error(`[codex-auth] Device code request failed: ${res.status}`, errorBody)
let errorMessage = `Device code request failed: ${res.status}`
if (errorBody?.error?.code === 'unsupported_country_region_territory') {
errorMessage = 'OpenAI does not support your region. You may need to use a proxy or VPN to access Codex.'
}
ctx.status = 502
ctx.body = { error: errorMessage, code: errorBody?.error?.code }
return
}
const data = await res.json() as { user_code: string; device_auth_id: string; interval?: string }
const sessionId = randomUUID()
const session: CodexSession = {
id: sessionId,
userCode: data.user_code,
deviceAuthId: data.device_auth_id,
status: 'pending',
createdAt: Date.now(),
}
sessions.set(sessionId, session)
// Start background worker
const authPath = getActiveAuthPath()
codexLoginWorker(session, authPath).catch(err => {
console.error('[Codex Auth] Worker error:', err)
session.status = 'error'
session.error = err.message
})
ctx.body = {
session_id: sessionId,
user_code: data.user_code,
verification_url: CODEX_VERIFICATION_URL,
expires_in: 900, // 15 minutes
}
} catch (err: any) {
ctx.status = 500
ctx.body = { error: err.message }
}
})
codexAuthRoutes.get('/api/hermes/auth/codex/poll/:sessionId', async (ctx) => {
const session = sessions.get(ctx.params.sessionId)
if (!session) {
ctx.status = 404
ctx.body = { error: 'Session not found' }
return
}
ctx.body = {
status: session.status,
error: session.error || null,
}
})
codexAuthRoutes.get('/api/hermes/auth/codex/status', async (ctx) => {
try {
const authPath = getActiveAuthPath()
const auth = loadAuthJson(authPath)
const tokens = auth.providers?.['openai-codex']?.tokens
if (!tokens?.access_token || !auth.providers) {
ctx.body = { authenticated: false }
return
}
const codexProvider = auth.providers['openai-codex']!
// Check if token is expired
const exp = decodeJwtExp(tokens.access_token)
if (exp && exp <= Date.now() / 1000 + 120) {
// Try refresh
if (tokens.refresh_token) {
try {
const refreshRes = await fetch(CODEX_OAUTH_TOKEN_URL, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({
grant_type: 'refresh_token',
refresh_token: tokens.refresh_token,
client_id: CODEX_CLIENT_ID,
}).toString(),
signal: AbortSignal.timeout(15000),
})
if (refreshRes.ok) {
const newTokens = await refreshRes.json() as { access_token: string; refresh_token?: string }
codexProvider.tokens.access_token = newTokens.access_token
if (newTokens.refresh_token) {
codexProvider.tokens.refresh_token = newTokens.refresh_token
}
codexProvider.last_refresh = new Date().toISOString()
saveAuthJson(authPath, auth)
saveCodexCliTokens(newTokens.access_token, newTokens.refresh_token || tokens.refresh_token)
// Update credential pool too
if (auth.credential_pool?.['openai-codex']?.[0]) {
auth.credential_pool['openai-codex'][0].access_token = newTokens.access_token
saveAuthJson(authPath, auth)
}
ctx.body = { authenticated: true, last_refresh: codexProvider.last_refresh }
return
}
} catch {
// Refresh failed
}
}
ctx.body = { authenticated: false }
return
}
ctx.body = {
authenticated: true,
last_refresh: codexProvider.last_refresh,
}
} catch {
ctx.body = { authenticated: false }
}
})
@@ -27,6 +27,7 @@ const PROVIDER_ENV_MAP: Record<string, { api_key_env: string; base_url_env: stri
'opencode-go': { api_key_env: 'OPENCODE_API_KEY', base_url_env: 'OPENCODE_GO_BASE_URL' },
huggingface: { api_key_env: 'HF_TOKEN', base_url_env: 'HF_BASE_URL' },
arcee: { api_key_env: 'ARCEE_API_KEY', base_url_env: '' },
'openai-codex': { api_key_env: '', base_url_env: 'HERMES_CODEX_BASE_URL' },
}
async function saveEnvValue(key: string, value: string): Promise<void> {
@@ -5,6 +5,7 @@ import { configRoutes } from './config'
import { fsRoutes } from './filesystem'
import { logRoutes } from './logs'
import { weixinRoutes } from './weixin'
import { codexAuthRoutes } from './codex-auth'
import { proxyRoutes, proxyMiddleware } from './proxy'
import { setupTerminalWebSocket } from './terminal'
@@ -16,6 +17,7 @@ hermesRoutes.use(configRoutes.routes())
hermesRoutes.use(fsRoutes.routes())
hermesRoutes.use(logRoutes.routes())
hermesRoutes.use(weixinRoutes.routes())
hermesRoutes.use(codexAuthRoutes.routes())
hermesRoutes.use(proxyRoutes.routes())
export { setupTerminalWebSocket, proxyMiddleware }
+6
View File
@@ -221,6 +221,12 @@ export const PROVIDER_PRESETS: ProviderPreset[] = [
base_url: 'https://opencode.ai/zen/go/v1',
models: ['glm-5.1', 'glm-5', 'kimi-k2.5', 'mimo-v2-pro', 'mimo-v2-omni', 'minimax-m2.7', 'minimax-m2.5'],
},
{
label: 'OpenAI Codex',
value: 'openai-codex',
base_url: 'https://chatgpt.com/backend-api/codex',
models: ['gpt-5.4-mini', 'gpt-5.4', 'gpt-5.3-codex', 'gpt-5.2-codex', 'gpt-5.1-codex-max', 'gpt-5.1-codex-mini'],
},
{
label: 'Arcee AI',
value: 'arcee',