From 5e608ea3383845b172128d82229a1e52da471693 Mon Sep 17 00:00:00 2001 From: Zhicheng Han <43314240+hanzckernel@users.noreply.github.com> Date: Mon, 11 May 2026 15:36:43 +0200 Subject: [PATCH] fix: recognize Codex credential-pool auth (#617) --- .../src/controllers/hermes/codex-auth.ts | 72 +++++++++-- .../server/src/controllers/hermes/models.ts | 12 +- .../server/codex-credential-pool-auth.test.ts | 115 ++++++++++++++++++ 3 files changed, 181 insertions(+), 18 deletions(-) create mode 100644 tests/server/codex-credential-pool-auth.test.ts diff --git a/packages/server/src/controllers/hermes/codex-auth.ts b/packages/server/src/controllers/hermes/codex-auth.ts index ac48242..5a84081 100644 --- a/packages/server/src/controllers/hermes/codex-auth.ts +++ b/packages/server/src/controllers/hermes/codex-auth.ts @@ -33,6 +33,13 @@ function cleanupExpiredSessions() { // --- Auth file helpers --- interface AuthJson { version?: number; active_provider?: string; providers?: Record; credential_pool?: Record; updated_at?: string } +interface CodexCredentialRef { + accessToken: string + refreshToken?: string + lastRefresh?: string + provider?: any + poolEntry?: any +} function loadAuthJson(authPath: string): AuthJson { try { return JSON.parse(readFileSync(authPath, 'utf-8')) as AuthJson } catch { return { version: 1 } } @@ -63,6 +70,35 @@ function decodeJwtExp(token: string): number | null { } catch { return null } } +function getCodexCredential(auth: AuthJson): CodexCredentialRef | null { + const provider = auth.providers?.['openai-codex'] + const providerTokens = provider?.tokens + const providerAccessToken = providerTokens?.access_token || provider?.access_token + const pool = auth.credential_pool?.['openai-codex'] + const poolEntry = Array.isArray(pool) ? pool.find(entry => entry?.access_token) : undefined + + if (providerAccessToken) { + return { + accessToken: providerAccessToken, + refreshToken: providerTokens?.refresh_token || provider?.refresh_token, + lastRefresh: provider.last_refresh, + provider, + poolEntry, + } + } + + if (poolEntry?.access_token) { + return { + accessToken: poolEntry.access_token, + refreshToken: poolEntry.refresh_token, + lastRefresh: poolEntry.last_refresh, + poolEntry, + } + } + + return null +} + // --- Background login worker --- async function codexLoginWorker(session: CodexSession, authPath: string): Promise { const startTime = Date.now() @@ -145,32 +181,42 @@ export async function status(ctx: any) { 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']! - const exp = decodeJwtExp(tokens.access_token) + const credential = getCodexCredential(auth) + if (!credential) { ctx.body = { authenticated: false }; return } + const exp = decodeJwtExp(credential.accessToken) if (exp && exp <= Date.now() / 1000 + 120) { - if (tokens.refresh_token) { + if (credential.refreshToken) { 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(), + body: new URLSearchParams({ grant_type: 'refresh_token', refresh_token: credential.refreshToken, 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() + const lastRefresh = new Date().toISOString() + if (credential.provider?.tokens) { + credential.provider.tokens.access_token = newTokens.access_token + if (newTokens.refresh_token) { credential.provider.tokens.refresh_token = newTokens.refresh_token } + credential.provider.last_refresh = lastRefresh + } else if (credential.provider) { + credential.provider.access_token = newTokens.access_token + if (newTokens.refresh_token) { credential.provider.refresh_token = newTokens.refresh_token } + credential.provider.last_refresh = lastRefresh + } + if (credential.poolEntry) { + credential.poolEntry.access_token = newTokens.access_token + if (newTokens.refresh_token) { credential.poolEntry.refresh_token = newTokens.refresh_token } + credential.poolEntry.last_refresh = lastRefresh + } saveAuthJson(authPath, auth) - saveCodexCliTokens(newTokens.access_token, newTokens.refresh_token || tokens.refresh_token) - 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 + saveCodexCliTokens(newTokens.access_token, newTokens.refresh_token || credential.refreshToken) + ctx.body = { authenticated: true, last_refresh: lastRefresh }; return } } catch { } } ctx.body = { authenticated: false }; return } - ctx.body = { authenticated: true, last_refresh: codexProvider.last_refresh } + ctx.body = { authenticated: true, last_refresh: credential.lastRefresh } } catch { ctx.body = { authenticated: false } } } diff --git a/packages/server/src/controllers/hermes/models.ts b/packages/server/src/controllers/hermes/models.ts index 5b688bc..4b2231e 100644 --- a/packages/server/src/controllers/hermes/models.ts +++ b/packages/server/src/controllers/hermes/models.ts @@ -132,12 +132,14 @@ export async function getAvailable(ctx: any) { if (!existsSync(authPath)) return false const auth = JSON.parse(readFileSync(authPath, 'utf-8')) const provider = auth.providers?.[providerKey] - if (!provider) return false - // Codex: providers.openai-codex.tokens.access_token - // Nous: providers.nous.access_token + const pool = auth.credential_pool?.[providerKey] + // Legacy OAuth providers are stored under providers.*; newer Hermes + // credential pools store Codex-style OAuth entries under + // credential_pool.*. Treat either shape as an authorized provider. return !!( - provider.tokens?.access_token || - provider.access_token + provider?.tokens?.access_token || + provider?.access_token || + (Array.isArray(pool) && pool.some((entry: any) => entry?.access_token)) ) } catch { return false } } diff --git a/tests/server/codex-credential-pool-auth.test.ts b/tests/server/codex-credential-pool-auth.test.ts new file mode 100644 index 0000000..0bdc73f --- /dev/null +++ b/tests/server/codex-credential-pool-auth.test.ts @@ -0,0 +1,115 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'fs' +import { join } from 'path' +import { tmpdir } from 'os' + +let hermesHome = '' + +function writeHermesFile(path: string, content: string) { + mkdirSync(hermesHome, { recursive: true }) + writeFileSync(join(hermesHome, path), content) +} + +function writeConfigYaml(content: string) { + writeHermesFile('config.yaml', content) +} + +function writeEnv(content = '') { + writeHermesFile('.env', content) +} + +function writeAuthJson(auth: Record) { + writeHermesFile('auth.json', JSON.stringify(auth, null, 2)) +} + +function makeCtx(): any { + return { params: {}, request: { body: {} }, body: undefined, status: 200 } +} + +async function loadModelsController() { + vi.resetModules() + vi.doMock('../../packages/server/src/services/app-config', () => ({ + readAppConfig: vi.fn().mockResolvedValue({}), + })) + vi.doMock('../../packages/server/src/services/hermes/copilot-models', () => ({ + getCopilotModelsDetailed: vi.fn().mockResolvedValue([]), + resolveCopilotOAuthToken: vi.fn().mockResolvedValue(''), + })) + return import('../../packages/server/src/controllers/hermes/models') +} + +async function loadCodexAuthController() { + vi.resetModules() + vi.doMock('../../packages/server/src/services/logger', () => ({ + logger: { info: vi.fn(), error: vi.fn(), warn: vi.fn() }, + })) + return import('../../packages/server/src/controllers/hermes/codex-auth') +} + +describe('OpenAI Codex credential pool auth compatibility', () => { + beforeEach(() => { + hermesHome = mkdtempSync(join(tmpdir(), 'hwui-codex-pool-')) + process.env.HERMES_HOME = hermesHome + writeConfigYaml('model:\n default: gpt-5.5\n provider: openai-codex\n') + writeEnv('') + }) + + afterEach(() => { + vi.doUnmock('../../packages/server/src/services/app-config') + vi.doUnmock('../../packages/server/src/services/hermes/copilot-models') + vi.doUnmock('../../packages/server/src/services/logger') + delete process.env.HERMES_HOME + if (hermesHome) rmSync(hermesHome, { recursive: true, force: true }) + hermesHome = '' + }) + + it('lists OpenAI Codex models when auth.json only has credential_pool entries', async () => { + writeAuthJson({ + version: 1, + providers: {}, + active_provider: 'openai-codex', + credential_pool: { + 'openai-codex': [ + { id: 'main', auth_type: 'oauth', access_token: 'access-token-from-pool', refresh_token: 'refresh-token-from-pool' }, + ], + }, + }) + + const { getAvailable } = await loadModelsController() + const ctx = makeCtx() + + await getAvailable(ctx) + + expect(ctx.body.default).toBe('gpt-5.5') + expect(ctx.body.default_provider).toBe('openai-codex') + expect(ctx.body.groups).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + provider: 'openai-codex', + label: 'OpenAI Codex', + models: expect.arrayContaining(['gpt-5.5', 'gpt-5.4-mini']), + }), + ]), + ) + }) + + it('reports Codex authenticated from credential_pool without requiring legacy providers tokens', async () => { + writeAuthJson({ + version: 1, + providers: {}, + active_provider: 'openai-codex', + credential_pool: { + 'openai-codex': [ + { id: 'main', auth_type: 'oauth', access_token: 'non-jwt-access-token', refresh_token: 'refresh-token-from-pool', last_refresh: '2026-05-10T00:00:00.000Z' }, + ], + }, + }) + + const { status } = await loadCodexAuthController() + const ctx = makeCtx() + + await status(ctx) + + expect(ctx.body).toEqual({ authenticated: true, last_refresh: '2026-05-10T00:00:00.000Z' }) + }) +})