Add Codex OAuth provider flow for ChatGPT account sign-in (#503)

* feat: add Codex OAuth provider flow

* fix: harden Codex OAuth storage, session activation, and UI
This commit is contained in:
Henrique Fernandes
2026-04-13 11:34:16 -03:00
committed by GitHub
parent 252808bbd0
commit fc7dc9ca0d
34 changed files with 5187 additions and 508 deletions

View File

@@ -7,6 +7,7 @@ import {
resolveCodexApiCredentials,
resolveProviderRequest,
} from '../services/api/providerConfig.ts'
import { parseChatgptAccountId } from '../services/api/codexOAuthShared.js'
import {
getGoalDefaultOpenAIModel,
normalizeRecommendationGoal,
@@ -14,6 +15,20 @@ import {
} from './providerRecommendation.ts'
import { readGeminiAccessToken } from './geminiCredentials.ts'
import { getOllamaChatBaseUrl } from './providerDiscovery.ts'
import { getProviderValidationError } from './providerValidation.ts'
import {
maskSecretForDisplay,
redactSecretValueForDisplay,
sanitizeApiKey,
sanitizeProviderConfigValue,
} from './providerSecrets.ts'
export {
maskSecretForDisplay,
redactSecretValueForDisplay,
sanitizeApiKey,
sanitizeProviderConfigValue,
} from './providerSecrets.ts'
export const PROFILE_FILE_NAME = '.openclaude-profile.json'
export const DEFAULT_GEMINI_BASE_URL =
@@ -33,6 +48,7 @@ const PROFILE_ENV_KEYS = [
'OPENAI_MODEL',
'OPENAI_API_KEY',
'CODEX_API_KEY',
'CODEX_CREDENTIAL_SOURCE',
'CHATGPT_ACCOUNT_ID',
'CODEX_ACCOUNT_ID',
'GEMINI_API_KEY',
@@ -46,21 +62,20 @@ const PROFILE_ENV_KEYS = [
'MISTRAL_MODEL',
] as const
const SECRET_ENV_KEYS = [
'OPENAI_API_KEY',
'CODEX_API_KEY',
'GEMINI_API_KEY',
'GOOGLE_API_KEY',
'MISTRAL_API_KEY',
] as const
export type ProviderProfile = 'openai' | 'ollama' | 'codex' | 'gemini' | 'atomic-chat' | 'mistral'
export type ProviderProfile =
| 'openai'
| 'ollama'
| 'codex'
| 'gemini'
| 'atomic-chat'
| 'mistral'
export type ProfileEnv = {
OPENAI_BASE_URL?: string
OPENAI_MODEL?: string
OPENAI_API_KEY?: string
CODEX_API_KEY?: string
CODEX_CREDENTIAL_SOURCE?: 'oauth' | 'existing'
CHATGPT_ACCOUNT_ID?: string
CODEX_ACCOUNT_ID?: string
GEMINI_API_KEY?: string
@@ -78,13 +93,6 @@ export type ProfileFile = {
createdAt: string
}
type SecretValueSource = Partial<
Pick<
NodeJS.ProcessEnv & ProfileEnv,
(typeof SECRET_ENV_KEYS)[number]
>
>
type ProfileFileLocation = {
cwd?: string
filePath?: string
@@ -109,102 +117,6 @@ export function isProviderProfile(value: unknown): value is ProviderProfile {
)
}
export function sanitizeApiKey(
key: string | null | undefined,
): string | undefined {
if (!key || key === 'SUA_CHAVE') return undefined
return key
}
function looksLikeSecretValue(value: string): boolean {
const trimmed = value.trim()
if (!trimmed) return false
if (trimmed.startsWith('sk-') || trimmed.startsWith('sk-ant-')) {
return true
}
if (trimmed.startsWith('AIza')) {
return true
}
return false
}
function collectSecretValues(
sources: Array<SecretValueSource | null | undefined>,
): string[] {
const values = new Set<string>()
for (const source of sources) {
if (!source) continue
for (const key of SECRET_ENV_KEYS) {
const value = sanitizeApiKey(source[key])
if (value) {
values.add(value)
}
}
}
return [...values]
}
export function maskSecretForDisplay(
value: string | null | undefined,
): string | undefined {
const sanitized = sanitizeApiKey(value)
if (!sanitized) return undefined
if (sanitized.length <= 8) {
return 'configured'
}
if (sanitized.startsWith('sk-')) {
return `${sanitized.slice(0, 3)}...${sanitized.slice(-4)}`
}
if (sanitized.startsWith('AIza')) {
return `${sanitized.slice(0, 4)}...${sanitized.slice(-4)}`
}
return `${sanitized.slice(0, 2)}...${sanitized.slice(-4)}`
}
export function redactSecretValueForDisplay(
value: string | null | undefined,
...sources: Array<SecretValueSource | null | undefined>
): string | undefined {
if (!value) return undefined
const trimmed = value.trim()
if (!trimmed) return trimmed
const secretValues = collectSecretValues(sources)
if (secretValues.includes(trimmed) || looksLikeSecretValue(trimmed)) {
return maskSecretForDisplay(trimmed) ?? 'configured'
}
return trimmed
}
export function sanitizeProviderConfigValue(
value: string | null | undefined,
...sources: Array<SecretValueSource | null | undefined>
): string | undefined {
if (!value) return undefined
const trimmed = value.trim()
if (!trimmed) return undefined
const secretValues = collectSecretValues(sources)
if (secretValues.includes(trimmed) || looksLikeSecretValue(trimmed)) {
return undefined
}
return trimmed
}
export function buildOllamaProfileEnv(
model: string,
options: {
@@ -335,6 +247,7 @@ export function buildCodexProfileEnv(options: {
model?: string | null
baseUrl?: string | null
apiKey?: string | null
credentialSource?: 'oauth' | 'existing'
processEnv?: NodeJS.ProcessEnv
}): ProfileEnv | null {
const processEnv = options.processEnv ?? process.env
@@ -346,10 +259,14 @@ export function buildCodexProfileEnv(options: {
if (!credentials.apiKey || !credentials.accountId) {
return null
}
const credentialSource =
options.credentialSource ??
(credentials.source === 'secure-storage' ? 'oauth' : 'existing')
const env: ProfileEnv = {
OPENAI_BASE_URL: options.baseUrl || DEFAULT_CODEX_BASE_URL,
OPENAI_MODEL: options.model || 'codexplan',
CODEX_CREDENTIAL_SOURCE: credentialSource,
}
if (key) {
@@ -399,6 +316,30 @@ export function buildMistralProfileEnv(options: {
return env
}
export function buildCodexOAuthProfileEnv(
tokens: {
accessToken: string
idToken?: string
accountId?: string
},
): ProfileEnv | null {
const accountId =
tokens.accountId ??
parseChatgptAccountId(tokens.idToken) ??
parseChatgptAccountId(tokens.accessToken)
if (!accountId) {
return null
}
return {
OPENAI_BASE_URL: DEFAULT_CODEX_BASE_URL,
OPENAI_MODEL: 'codexplan',
CHATGPT_ACCOUNT_ID: accountId,
CODEX_CREDENTIAL_SOURCE: 'oauth',
}
}
export function createProfileFile(
profile: ProviderProfile,
env: ProfileEnv,
@@ -410,6 +351,26 @@ export function createProfileFile(
}
}
export function isPersistedCodexOAuthProfile(
persisted: ProfileFile | null,
): boolean {
return (
persisted?.profile === 'codex' &&
persisted.env.CODEX_CREDENTIAL_SOURCE === 'oauth'
)
}
export function clearPersistedCodexOAuthProfile(
options?: ProfileFileLocation,
): string | null {
const persisted = loadProfileFile(options)
if (!isPersistedCodexOAuthProfile(persisted)) {
return null
}
return deleteProfileFile(options)
}
export function loadProfileFile(options?: ProfileFileLocation): ProfileFile | null {
const filePath = resolveProfileFilePath(options)
if (!existsSync(filePath)) {
@@ -545,6 +506,7 @@ export async function buildLaunchEnv(options: {
delete env.CLAUDE_CODE_USE_OPENAI
delete env.CLAUDE_CODE_USE_GITHUB
delete env.CODEX_CREDENTIAL_SOURCE
env.GEMINI_MODEL =
shellGeminiModel ||
@@ -668,6 +630,7 @@ export async function buildLaunchEnv(options: {
delete env.CLAUDE_CODE_USE_FOUNDRY
delete env.CLAUDE_CODE_USE_GEMINI
delete env.CLAUDE_CODE_USE_GITHUB
delete env.CODEX_CREDENTIAL_SOURCE
delete env.GEMINI_API_KEY
delete env.GEMINI_AUTH_MODE
delete env.GEMINI_ACCESS_TOKEN
@@ -838,3 +801,40 @@ export function applyProfileEnvToProcessEnv(
Object.assign(targetEnv, nextEnv)
}
export async function applySavedProfileToCurrentSession(options: {
profileFile: ProfileFile
processEnv?: NodeJS.ProcessEnv
}): Promise<string | null> {
const processEnv = options.processEnv ?? process.env
const baseEnv = { ...processEnv }
const isCodexOAuthProfile =
options.profileFile.profile === 'codex' &&
options.profileFile.env.CODEX_CREDENTIAL_SOURCE === 'oauth'
delete baseEnv.CLAUDE_CODE_PROVIDER_PROFILE_ENV_APPLIED
delete baseEnv.CLAUDE_CODE_PROVIDER_PROFILE_ENV_APPLIED_ID
if (isCodexOAuthProfile) {
delete baseEnv.CODEX_API_KEY
delete baseEnv.CODEX_ACCOUNT_ID
delete baseEnv.CHATGPT_ACCOUNT_ID
}
const nextEnv = await buildLaunchEnv({
profile: options.profileFile.profile,
persisted: options.profileFile,
goal: normalizeRecommendationGoal(processEnv.OPENCLAUDE_PROFILE_GOAL),
processEnv: baseEnv,
getOllamaChatBaseUrl,
readGeminiAccessToken,
})
const validationError = await getProviderValidationError(nextEnv)
if (validationError) {
return validationError
}
delete processEnv.CLAUDE_CODE_PROVIDER_PROFILE_ENV_APPLIED
delete processEnv.CLAUDE_CODE_PROVIDER_PROFILE_ENV_APPLIED_ID
applyProfileEnvToProcessEnv(processEnv, nextEnv)
return null
}