fix: recognize Codex credential-pool auth (#617)
This commit is contained in:
@@ -33,6 +33,13 @@ function cleanupExpiredSessions() {
|
||||
|
||||
// --- Auth file helpers ---
|
||||
interface AuthJson { version?: number; active_provider?: string; providers?: Record<string, any>; credential_pool?: Record<string, any[]>; 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<void> {
|
||||
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 } }
|
||||
}
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
|
||||
@@ -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<string, unknown>) {
|
||||
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' })
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user