From 9979871550885aa53972a046ca8cb344d59592e8 Mon Sep 17 00:00:00 2001 From: ekko Date: Fri, 17 Apr 2026 23:11:57 +0800 Subject: [PATCH] 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 --- package.json | 2 +- packages/client/src/api/hermes/codex-auth.ts | 30 ++ .../hermes/models/CodexLoginModal.vue | 239 ++++++++++++ .../hermes/models/ProviderFormModal.vue | 34 +- packages/client/src/i18n/locales/de.ts | 7 + packages/client/src/i18n/locales/en.ts | 7 + packages/client/src/i18n/locales/es.ts | 7 + packages/client/src/i18n/locales/fr.ts | 7 + packages/client/src/i18n/locales/ja.ts | 7 + packages/client/src/i18n/locales/ko.ts | 7 + packages/client/src/i18n/locales/pt.ts | 7 + packages/client/src/i18n/locales/zh.ts | 7 + packages/client/src/shared/providers.ts | 6 + .../client/src/views/hermes/ChannelsView.vue | 2 +- .../client/src/views/hermes/ModelsView.vue | 3 + .../server/src/routes/hermes/codex-auth.ts | 347 ++++++++++++++++++ .../server/src/routes/hermes/filesystem.ts | 1 + packages/server/src/routes/hermes/index.ts | 2 + packages/server/src/shared/providers.ts | 6 + 19 files changed, 722 insertions(+), 6 deletions(-) create mode 100644 packages/client/src/api/hermes/codex-auth.ts create mode 100644 packages/client/src/components/hermes/models/CodexLoginModal.vue create mode 100644 packages/server/src/routes/hermes/codex-auth.ts diff --git a/package.json b/package.json index 4cb5391..be2df30 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "hermes-web-ui", - "version": "0.3.2", + "version": "0.3.4", "description": "Web dashboard for Hermes Agent — multi-platform AI chat, session management, scheduled jobs, usage analytics & channel configuration (Telegram, Discord, Slack, WhatsApp)", "repository": { "type": "git", diff --git a/packages/client/src/api/hermes/codex-auth.ts b/packages/client/src/api/hermes/codex-auth.ts new file mode 100644 index 0000000..2102e8f --- /dev/null +++ b/packages/client/src/api/hermes/codex-auth.ts @@ -0,0 +1,30 @@ +import { request } from '../client' + +export interface CodexStartResult { + session_id: string + user_code: string + verification_url: string + expires_in: number +} + +export interface CodexPollResult { + status: 'pending' | 'approved' | 'expired' | 'error' + error: string | null +} + +export interface CodexStatusResult { + authenticated: boolean + last_refresh?: string +} + +export async function startCodexLogin(): Promise { + return request('/api/hermes/auth/codex/start', { method: 'POST' }) +} + +export async function pollCodexLogin(sessionId: string): Promise { + return request(`/api/hermes/auth/codex/poll/${sessionId}`) +} + +export async function getCodexAuthStatus(): Promise { + return request('/api/hermes/auth/codex/status') +} diff --git a/packages/client/src/components/hermes/models/CodexLoginModal.vue b/packages/client/src/components/hermes/models/CodexLoginModal.vue new file mode 100644 index 0000000..fede904 --- /dev/null +++ b/packages/client/src/components/hermes/models/CodexLoginModal.vue @@ -0,0 +1,239 @@ + + + + + diff --git a/packages/client/src/components/hermes/models/ProviderFormModal.vue b/packages/client/src/components/hermes/models/ProviderFormModal.vue index aeb71b6..e29ab7b 100644 --- a/packages/client/src/components/hermes/models/ProviderFormModal.vue +++ b/packages/client/src/components/hermes/models/ProviderFormModal.vue @@ -1,9 +1,10 @@ diff --git a/packages/server/src/routes/hermes/codex-auth.ts b/packages/server/src/routes/hermes/codex-auth.ts new file mode 100644 index 0000000..7bf00c4 --- /dev/null +++ b/packages/server/src/routes/hermes/codex-auth.ts @@ -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() + +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 + credential_pool?: Record + 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 { + 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 } + } +}) diff --git a/packages/server/src/routes/hermes/filesystem.ts b/packages/server/src/routes/hermes/filesystem.ts index 5e449e6..f0e007b 100644 --- a/packages/server/src/routes/hermes/filesystem.ts +++ b/packages/server/src/routes/hermes/filesystem.ts @@ -27,6 +27,7 @@ const PROVIDER_ENV_MAP: Record { diff --git a/packages/server/src/routes/hermes/index.ts b/packages/server/src/routes/hermes/index.ts index d75564b..1cba783 100644 --- a/packages/server/src/routes/hermes/index.ts +++ b/packages/server/src/routes/hermes/index.ts @@ -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 } diff --git a/packages/server/src/shared/providers.ts b/packages/server/src/shared/providers.ts index 72d3904..3b9ae29 100644 --- a/packages/server/src/shared/providers.ts +++ b/packages/server/src/shared/providers.ts @@ -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',