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:
committed by
GitHub
parent
252808bbd0
commit
fc7dc9ca0d
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user