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
@@ -1,20 +1,28 @@
|
||||
import { PassThrough } from 'node:stream'
|
||||
|
||||
import { expect, test } from 'bun:test'
|
||||
import { afterEach, expect, mock, test } from 'bun:test'
|
||||
import React from 'react'
|
||||
import stripAnsi from 'strip-ansi'
|
||||
|
||||
import { createRoot, render, useApp } from '../../ink.js'
|
||||
import { AppStateProvider } from '../../state/AppState.js'
|
||||
import {
|
||||
applySavedProfileToCurrentSession,
|
||||
buildCodexOAuthProfileEnv,
|
||||
buildCurrentProviderSummary,
|
||||
buildProfileSaveMessage,
|
||||
getProviderWizardDefaults,
|
||||
ProviderWizard,
|
||||
TextEntryDialog,
|
||||
} from './provider.js'
|
||||
import { createProfileFile } from '../../utils/providerProfile.js'
|
||||
|
||||
const SYNC_START = '\x1B[?2026h'
|
||||
const SYNC_END = '\x1B[?2026l'
|
||||
const ORIGINAL_SIMPLE_ENV = process.env.CLAUDE_CODE_SIMPLE
|
||||
const ORIGINAL_CODEX_API_KEY = process.env.CODEX_API_KEY
|
||||
const ORIGINAL_CHATGPT_ACCOUNT_ID = process.env.CHATGPT_ACCOUNT_ID
|
||||
const ORIGINAL_CODEX_ACCOUNT_ID = process.env.CODEX_ACCOUNT_ID
|
||||
|
||||
function extractLastFrame(output: string): string {
|
||||
let lastFrame: string | null = null
|
||||
@@ -60,6 +68,51 @@ async function renderFinalFrame(node: React.ReactNode): Promise<string> {
|
||||
return stripAnsi(extractLastFrame(getOutput()))
|
||||
}
|
||||
|
||||
async function waitForOutput(
|
||||
getOutput: () => string,
|
||||
predicate: (output: string) => boolean,
|
||||
timeoutMs = 2500,
|
||||
): Promise<string> {
|
||||
const startedAt = Date.now()
|
||||
|
||||
while (Date.now() - startedAt < timeoutMs) {
|
||||
const output = stripAnsi(extractLastFrame(getOutput()))
|
||||
if (predicate(output)) {
|
||||
return output
|
||||
}
|
||||
await Bun.sleep(10)
|
||||
}
|
||||
|
||||
throw new Error('Timed out waiting for ProviderWizard test output')
|
||||
}
|
||||
|
||||
async function renderProviderWizardFrame(): Promise<string> {
|
||||
const { stdout, stdin, getOutput } = createTestStreams()
|
||||
const root = await createRoot({
|
||||
stdout: stdout as unknown as NodeJS.WriteStream,
|
||||
stdin: stdin as unknown as NodeJS.ReadStream,
|
||||
patchConsole: false,
|
||||
})
|
||||
|
||||
root.render(
|
||||
<AppStateProvider>
|
||||
<ProviderWizard onDone={() => {}} />
|
||||
</AppStateProvider>,
|
||||
)
|
||||
|
||||
try {
|
||||
return await waitForOutput(
|
||||
getOutput,
|
||||
output => output.includes('Set up a provider profile'),
|
||||
)
|
||||
} finally {
|
||||
root.unmount()
|
||||
stdin.end()
|
||||
stdout.end()
|
||||
await Bun.sleep(0)
|
||||
}
|
||||
}
|
||||
|
||||
function createTestStreams(): {
|
||||
stdout: PassThrough
|
||||
stdin: PassThrough & {
|
||||
@@ -94,6 +147,34 @@ function createTestStreams(): {
|
||||
}
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
mock.restore()
|
||||
|
||||
if (ORIGINAL_SIMPLE_ENV === undefined) {
|
||||
delete process.env.CLAUDE_CODE_SIMPLE
|
||||
} else {
|
||||
process.env.CLAUDE_CODE_SIMPLE = ORIGINAL_SIMPLE_ENV
|
||||
}
|
||||
|
||||
if (ORIGINAL_CODEX_API_KEY === undefined) {
|
||||
delete process.env.CODEX_API_KEY
|
||||
} else {
|
||||
process.env.CODEX_API_KEY = ORIGINAL_CODEX_API_KEY
|
||||
}
|
||||
|
||||
if (ORIGINAL_CHATGPT_ACCOUNT_ID === undefined) {
|
||||
delete process.env.CHATGPT_ACCOUNT_ID
|
||||
} else {
|
||||
process.env.CHATGPT_ACCOUNT_ID = ORIGINAL_CHATGPT_ACCOUNT_ID
|
||||
}
|
||||
|
||||
if (ORIGINAL_CODEX_ACCOUNT_ID === undefined) {
|
||||
delete process.env.CODEX_ACCOUNT_ID
|
||||
} else {
|
||||
process.env.CODEX_ACCOUNT_ID = ORIGINAL_CODEX_ACCOUNT_ID
|
||||
}
|
||||
})
|
||||
|
||||
function StepChangeHarness(): React.ReactNode {
|
||||
const { exit } = useApp()
|
||||
const [step, setStep] = React.useState<'api' | 'model'>('api')
|
||||
@@ -233,6 +314,167 @@ test('buildProfileSaveMessage describes Gemini access token / ADC mode clearly',
|
||||
expect(message).not.toContain('AIza')
|
||||
})
|
||||
|
||||
test('buildProfileSaveMessage reflects immediate Codex activation for existing credentials', () => {
|
||||
const message = buildProfileSaveMessage(
|
||||
'codex',
|
||||
{
|
||||
OPENAI_MODEL: 'codexplan',
|
||||
OPENAI_BASE_URL: 'https://chatgpt.com/backend-api/codex',
|
||||
CHATGPT_ACCOUNT_ID: 'acct_codex',
|
||||
},
|
||||
'D:/codings/Opensource/openclaude/.openclaude-profile.json',
|
||||
{
|
||||
activatedInSession: true,
|
||||
},
|
||||
)
|
||||
|
||||
expect(message).toContain('Saved Codex profile.')
|
||||
expect(message).toContain('OpenClaude switched to it for this session.')
|
||||
expect(message).not.toContain('Restart OpenClaude to use it.')
|
||||
})
|
||||
|
||||
test('buildProfileSaveMessage reflects immediate Codex OAuth activation when the session switched successfully', () => {
|
||||
const message = buildProfileSaveMessage(
|
||||
'codex',
|
||||
{
|
||||
OPENAI_MODEL: 'codexplan',
|
||||
OPENAI_BASE_URL: 'https://chatgpt.com/backend-api/codex',
|
||||
CHATGPT_ACCOUNT_ID: 'acct_codex',
|
||||
CODEX_CREDENTIAL_SOURCE: 'oauth',
|
||||
},
|
||||
'D:/codings/Opensource/openclaude/.openclaude-profile.json',
|
||||
{
|
||||
activatedInSession: true,
|
||||
},
|
||||
)
|
||||
|
||||
expect(message).toContain('Saved Codex profile.')
|
||||
expect(message).toContain('OpenClaude switched to it for this session.')
|
||||
expect(message).not.toContain('Restart OpenClaude to use it.')
|
||||
})
|
||||
|
||||
test('buildCodexOAuthProfileEnv uses the fresh OAuth account id without persisting an API key', () => {
|
||||
process.env.CODEX_API_KEY = 'stale-codex-key'
|
||||
process.env.CHATGPT_ACCOUNT_ID = 'acct_stale'
|
||||
|
||||
const env = buildCodexOAuthProfileEnv({
|
||||
accessToken: 'oauth-access-token',
|
||||
accountId: 'acct_oauth',
|
||||
})
|
||||
|
||||
expect(env).toEqual({
|
||||
OPENAI_BASE_URL: 'https://chatgpt.com/backend-api/codex',
|
||||
OPENAI_MODEL: 'codexplan',
|
||||
CHATGPT_ACCOUNT_ID: 'acct_oauth',
|
||||
CODEX_CREDENTIAL_SOURCE: 'oauth',
|
||||
})
|
||||
expect(env).not.toHaveProperty('CODEX_API_KEY')
|
||||
})
|
||||
|
||||
test('buildCodexProfileEnv derives oauth source from secure storage when no explicit source is provided', async () => {
|
||||
const actualProviderConfig = await import('../../services/api/providerConfig.js')
|
||||
|
||||
mock.module('../../services/api/providerConfig.js', () => ({
|
||||
...actualProviderConfig,
|
||||
resolveCodexApiCredentials: () => ({
|
||||
apiKey: 'stored-access-token',
|
||||
accountId: 'acct_secure_storage',
|
||||
source: 'secure-storage' as const,
|
||||
}),
|
||||
}))
|
||||
|
||||
// @ts-expect-error cache-busting query string for Bun module mocks
|
||||
const { buildCodexProfileEnv } = await import(
|
||||
'../../utils/providerProfile.js?secure-storage-codex-source'
|
||||
)
|
||||
|
||||
const env = buildCodexProfileEnv({
|
||||
model: 'codexplan',
|
||||
processEnv: {},
|
||||
})
|
||||
|
||||
expect(env).toEqual({
|
||||
OPENAI_BASE_URL: 'https://chatgpt.com/backend-api/codex',
|
||||
OPENAI_MODEL: 'codexplan',
|
||||
CHATGPT_ACCOUNT_ID: 'acct_secure_storage',
|
||||
CODEX_CREDENTIAL_SOURCE: 'oauth',
|
||||
})
|
||||
})
|
||||
|
||||
test('applySavedProfileToCurrentSession switches the current env to the saved Codex profile', async () => {
|
||||
// @ts-expect-error cache-busting query string for Bun module mocks
|
||||
const { applySavedProfileToCurrentSession } = await import(
|
||||
'../../utils/providerProfile.js?apply-saved-profile-codex'
|
||||
)
|
||||
const processEnv: NodeJS.ProcessEnv = {
|
||||
CLAUDE_CODE_USE_OPENAI: '1',
|
||||
OPENAI_MODEL: 'gpt-4o',
|
||||
OPENAI_BASE_URL: 'https://api.openai.com/v1',
|
||||
OPENAI_API_KEY: 'sk-openai',
|
||||
CODEX_API_KEY: 'codex-live',
|
||||
CHATGPT_ACCOUNT_ID: 'acct_codex',
|
||||
CLAUDE_CODE_PROVIDER_PROFILE_ENV_APPLIED: '1',
|
||||
CLAUDE_CODE_PROVIDER_PROFILE_ENV_APPLIED_ID: 'provider_old',
|
||||
}
|
||||
const profileFile = createProfileFile('codex', {
|
||||
OPENAI_MODEL: 'codexplan',
|
||||
OPENAI_BASE_URL: 'https://chatgpt.com/backend-api/codex',
|
||||
CODEX_API_KEY: 'codex-live',
|
||||
CHATGPT_ACCOUNT_ID: 'acct_codex',
|
||||
})
|
||||
|
||||
const warning = await applySavedProfileToCurrentSession({
|
||||
profileFile,
|
||||
processEnv,
|
||||
})
|
||||
|
||||
expect(warning).toBeNull()
|
||||
expect(processEnv.CLAUDE_CODE_USE_OPENAI).toBe('1')
|
||||
expect(processEnv.OPENAI_MODEL).toBe('codexplan')
|
||||
expect(processEnv.OPENAI_BASE_URL).toBe(
|
||||
'https://chatgpt.com/backend-api/codex',
|
||||
)
|
||||
expect(processEnv.CODEX_API_KEY).toBe('codex-live')
|
||||
expect(processEnv.CHATGPT_ACCOUNT_ID).toBe('acct_codex')
|
||||
expect(processEnv.OPENAI_API_KEY).toBeUndefined()
|
||||
expect(processEnv.CLAUDE_CODE_PROVIDER_PROFILE_ENV_APPLIED).toBeUndefined()
|
||||
expect(processEnv.CLAUDE_CODE_PROVIDER_PROFILE_ENV_APPLIED_ID).toBeUndefined()
|
||||
})
|
||||
|
||||
test('applySavedProfileToCurrentSession ignores stale Codex env overrides for OAuth-backed profiles', async () => {
|
||||
// @ts-expect-error cache-busting query string for Bun module mocks
|
||||
const { applySavedProfileToCurrentSession } = await import(
|
||||
'../../utils/providerProfile.js?apply-saved-profile-codex-oauth'
|
||||
)
|
||||
const processEnv: NodeJS.ProcessEnv = {
|
||||
CLAUDE_CODE_USE_OPENAI: '1',
|
||||
OPENAI_MODEL: 'gpt-4o',
|
||||
OPENAI_BASE_URL: 'https://api.openai.com/v1',
|
||||
CODEX_API_KEY: 'stale-codex-key',
|
||||
CHATGPT_ACCOUNT_ID: 'acct_stale',
|
||||
}
|
||||
const profileFile = createProfileFile('codex', {
|
||||
OPENAI_MODEL: 'codexplan',
|
||||
OPENAI_BASE_URL: 'https://chatgpt.com/backend-api/codex',
|
||||
CHATGPT_ACCOUNT_ID: 'acct_oauth',
|
||||
CODEX_CREDENTIAL_SOURCE: 'oauth',
|
||||
})
|
||||
|
||||
const warning = await applySavedProfileToCurrentSession({
|
||||
profileFile,
|
||||
processEnv,
|
||||
})
|
||||
|
||||
expect(warning).toBeNull()
|
||||
expect(processEnv.OPENAI_MODEL).toBe('codexplan')
|
||||
expect(processEnv.OPENAI_BASE_URL).toBe(
|
||||
'https://chatgpt.com/backend-api/codex',
|
||||
)
|
||||
expect(processEnv.CODEX_API_KEY).toBeUndefined()
|
||||
expect(processEnv.CHATGPT_ACCOUNT_ID).not.toBe('acct_stale')
|
||||
expect(processEnv.CHATGPT_ACCOUNT_ID).toBeTruthy()
|
||||
})
|
||||
|
||||
test('buildCurrentProviderSummary redacts poisoned model and endpoint values', () => {
|
||||
const summary = buildCurrentProviderSummary({
|
||||
processEnv: {
|
||||
@@ -307,3 +549,12 @@ test('getProviderWizardDefaults ignores poisoned current provider values', () =>
|
||||
expect(defaults.openAIBaseUrl).toBe('https://api.openai.com/v1')
|
||||
expect(defaults.geminiModel).toBe('gemini-2.0-flash')
|
||||
})
|
||||
|
||||
test('ProviderWizard hides Codex OAuth while running in bare mode', async () => {
|
||||
process.env.CLAUDE_CODE_SIMPLE = '1'
|
||||
|
||||
const output = await renderProviderWizardFrame()
|
||||
|
||||
expect(output).toContain('Set up a provider profile')
|
||||
expect(output).not.toContain('Codex OAuth')
|
||||
})
|
||||
|
||||
@@ -10,8 +10,12 @@ import {
|
||||
} from '../../components/CustomSelect/index.js'
|
||||
import { Dialog } from '../../components/design-system/Dialog.js'
|
||||
import { LoadingState } from '../../components/design-system/LoadingState.js'
|
||||
import { useCodexOAuthFlow } from '../../components/useCodexOAuthFlow.js'
|
||||
import { useTerminalSize } from '../../hooks/useTerminalSize.js'
|
||||
import { Box, Text } from '../../ink.js'
|
||||
import {
|
||||
type CodexOAuthTokens,
|
||||
} from '../../services/api/codexOAuth.js'
|
||||
import {
|
||||
DEFAULT_CODEX_BASE_URL,
|
||||
DEFAULT_OPENAI_BASE_URL,
|
||||
@@ -20,6 +24,8 @@ import {
|
||||
resolveProviderRequest,
|
||||
} from '../../services/api/providerConfig.js'
|
||||
import {
|
||||
applySavedProfileToCurrentSession as applySharedProfileToCurrentSession,
|
||||
buildCodexOAuthProfileEnv as buildSharedCodexOAuthProfileEnv,
|
||||
buildCodexProfileEnv,
|
||||
buildGeminiProfileEnv,
|
||||
buildMistralProfileEnv,
|
||||
@@ -49,6 +55,7 @@ import {
|
||||
readGeminiAccessToken,
|
||||
saveGeminiAccessToken,
|
||||
} from '../../utils/geminiCredentials.js'
|
||||
import { isBareMode } from '../../utils/envUtils.js'
|
||||
import {
|
||||
getGoalDefaultOpenAIModel,
|
||||
normalizeRecommendationGoal,
|
||||
@@ -57,12 +64,13 @@ import {
|
||||
type RecommendationGoal,
|
||||
} from '../../utils/providerRecommendation.js'
|
||||
import {
|
||||
getOllamaChatBaseUrl,
|
||||
getLocalOpenAICompatibleProviderLabel,
|
||||
hasLocalOllama,
|
||||
listOllamaModels,
|
||||
} from '../../utils/providerDiscovery.js'
|
||||
|
||||
type ProviderChoice = 'auto' | ProviderProfile | 'clear'
|
||||
type ProviderChoice = 'auto' | ProviderProfile | 'codex-oauth' | 'clear'
|
||||
|
||||
type Step =
|
||||
| { name: 'choose' }
|
||||
@@ -93,6 +101,7 @@ type Step =
|
||||
apiKey?: string
|
||||
authMode: 'api-key' | 'access-token' | 'adc'
|
||||
}
|
||||
| { name: 'codex-oauth' }
|
||||
| { name: 'codex-check' }
|
||||
|
||||
type CurrentProviderSummary = {
|
||||
@@ -131,6 +140,8 @@ type ProviderWizardDefaults = {
|
||||
mistralBaseUrl: string
|
||||
}
|
||||
|
||||
type SecretSourceEnv = NodeJS.ProcessEnv & Partial<ProfileEnv>
|
||||
|
||||
function isEnvTruthy(value: string | undefined): boolean {
|
||||
if (!value) return false
|
||||
const normalized = value.trim().toLowerCase()
|
||||
@@ -139,7 +150,7 @@ function isEnvTruthy(value: string | undefined): boolean {
|
||||
|
||||
function getSafeDisplayValue(
|
||||
value: string | undefined,
|
||||
processEnv: NodeJS.ProcessEnv,
|
||||
processEnv: SecretSourceEnv,
|
||||
profileEnv?: ProfileEnv,
|
||||
fallback = '(not set)',
|
||||
): string {
|
||||
@@ -151,14 +162,15 @@ function getSafeDisplayValue(
|
||||
export function getProviderWizardDefaults(
|
||||
processEnv: NodeJS.ProcessEnv = process.env,
|
||||
): ProviderWizardDefaults {
|
||||
const secretSource = processEnv as SecretSourceEnv
|
||||
const safeOpenAIModel =
|
||||
sanitizeProviderConfigValue(processEnv.OPENAI_MODEL, processEnv) ||
|
||||
sanitizeProviderConfigValue(processEnv.OPENAI_MODEL, secretSource) ||
|
||||
'gpt-4o'
|
||||
const safeOpenAIBaseUrl =
|
||||
sanitizeProviderConfigValue(processEnv.OPENAI_BASE_URL, processEnv) ||
|
||||
sanitizeProviderConfigValue(processEnv.OPENAI_BASE_URL, secretSource) ||
|
||||
DEFAULT_OPENAI_BASE_URL
|
||||
const safeGeminiModel =
|
||||
sanitizeProviderConfigValue(processEnv.GEMINI_MODEL, processEnv) ||
|
||||
sanitizeProviderConfigValue(processEnv.GEMINI_MODEL, secretSource) ||
|
||||
DEFAULT_GEMINI_MODEL
|
||||
const safeMistralModel =
|
||||
sanitizeProviderConfigValue(processEnv.MISTRAL_MODEL, processEnv) ||
|
||||
@@ -181,6 +193,7 @@ export function buildCurrentProviderSummary(options?: {
|
||||
persisted?: ProfileFile | null
|
||||
}): CurrentProviderSummary {
|
||||
const processEnv = options?.processEnv ?? process.env
|
||||
const secretSource = processEnv as SecretSourceEnv
|
||||
const persisted = options?.persisted ?? loadProfileFile()
|
||||
const savedProfileLabel = persisted?.profile ?? 'none'
|
||||
|
||||
@@ -189,11 +202,11 @@ export function buildCurrentProviderSummary(options?: {
|
||||
providerLabel: 'Google Gemini',
|
||||
modelLabel: getSafeDisplayValue(
|
||||
processEnv.GEMINI_MODEL ?? DEFAULT_GEMINI_MODEL,
|
||||
processEnv,
|
||||
secretSource,
|
||||
),
|
||||
endpointLabel: getSafeDisplayValue(
|
||||
processEnv.GEMINI_BASE_URL ?? DEFAULT_GEMINI_BASE_URL,
|
||||
processEnv,
|
||||
secretSource,
|
||||
),
|
||||
savedProfileLabel,
|
||||
}
|
||||
@@ -219,13 +232,13 @@ export function buildCurrentProviderSummary(options?: {
|
||||
providerLabel: 'GitHub Models',
|
||||
modelLabel: getSafeDisplayValue(
|
||||
processEnv.OPENAI_MODEL ?? 'github:copilot',
|
||||
processEnv,
|
||||
secretSource,
|
||||
),
|
||||
endpointLabel: getSafeDisplayValue(
|
||||
processEnv.OPENAI_BASE_URL ??
|
||||
processEnv.OPENAI_API_BASE ??
|
||||
'https://models.github.ai/inference',
|
||||
processEnv,
|
||||
secretSource,
|
||||
),
|
||||
savedProfileLabel,
|
||||
}
|
||||
@@ -246,8 +259,8 @@ export function buildCurrentProviderSummary(options?: {
|
||||
|
||||
return {
|
||||
providerLabel,
|
||||
modelLabel: getSafeDisplayValue(request.requestedModel, processEnv),
|
||||
endpointLabel: getSafeDisplayValue(request.baseUrl, processEnv),
|
||||
modelLabel: getSafeDisplayValue(request.requestedModel, secretSource),
|
||||
endpointLabel: getSafeDisplayValue(request.baseUrl, secretSource),
|
||||
savedProfileLabel,
|
||||
}
|
||||
}
|
||||
@@ -258,11 +271,11 @@ export function buildCurrentProviderSummary(options?: {
|
||||
processEnv.ANTHROPIC_MODEL ??
|
||||
processEnv.CLAUDE_MODEL ??
|
||||
'claude-sonnet-4-6',
|
||||
processEnv,
|
||||
secretSource,
|
||||
),
|
||||
endpointLabel: getSafeDisplayValue(
|
||||
processEnv.ANTHROPIC_BASE_URL ?? 'https://api.anthropic.com',
|
||||
processEnv,
|
||||
secretSource,
|
||||
),
|
||||
savedProfileLabel,
|
||||
}
|
||||
@@ -376,6 +389,10 @@ export function buildProfileSaveMessage(
|
||||
profile: ProviderProfile,
|
||||
env: ProfileEnv,
|
||||
filePath: string,
|
||||
options?: {
|
||||
activatedInSession?: boolean
|
||||
activationWarning?: string | null
|
||||
},
|
||||
): string {
|
||||
const summary = buildSavedProfileSummary(profile, env)
|
||||
const lines = [
|
||||
@@ -389,13 +406,24 @@ export function buildProfileSaveMessage(
|
||||
}
|
||||
|
||||
lines.push(`Profile: ${filePath}`)
|
||||
lines.push('Restart OpenClaude to use it.')
|
||||
if (options?.activatedInSession) {
|
||||
lines.push('OpenClaude switched to it for this session.')
|
||||
} else if (options?.activationWarning) {
|
||||
lines.push(
|
||||
`Saved for next startup. Warning: could not activate it in this session (${options.activationWarning}).`,
|
||||
)
|
||||
} else {
|
||||
lines.push('Restart OpenClaude to use it.')
|
||||
}
|
||||
|
||||
return lines.join('\n')
|
||||
}
|
||||
|
||||
function buildUsageText(): string {
|
||||
const summary = buildCurrentProviderSummary()
|
||||
const availableProviders = isBareMode()
|
||||
? 'Choose Auto, Ollama, OpenAI-compatible, Gemini, or Codex, then save a provider profile.'
|
||||
: 'Choose Auto, Ollama, OpenAI-compatible, Gemini, Codex, or Codex OAuth, then save a provider profile.'
|
||||
return [
|
||||
'Usage: /provider',
|
||||
'',
|
||||
@@ -406,7 +434,7 @@ function buildUsageText(): string {
|
||||
`Current endpoint: ${summary.endpointLabel}`,
|
||||
`Saved profile: ${summary.savedProfileLabel}`,
|
||||
'',
|
||||
'Choose Auto, Ollama, OpenAI-compatible, Gemini, or Codex, then save a profile for the next OpenClaude restart.',
|
||||
availableProviders,
|
||||
].join('\n')
|
||||
}
|
||||
|
||||
@@ -415,12 +443,45 @@ function finishProfileSave(
|
||||
profile: ProviderProfile,
|
||||
env: ProfileEnv,
|
||||
): void {
|
||||
void saveProfileAndNotify(onDone, profile, env)
|
||||
}
|
||||
|
||||
export function buildCodexOAuthProfileEnv(
|
||||
tokens: Pick<CodexOAuthTokens, 'accessToken' | 'idToken' | 'accountId'>,
|
||||
): ProfileEnv | null {
|
||||
return buildSharedCodexOAuthProfileEnv(tokens)
|
||||
}
|
||||
|
||||
export async function applySavedProfileToCurrentSession(options: {
|
||||
profileFile: ProfileFile
|
||||
processEnv?: NodeJS.ProcessEnv
|
||||
}): Promise<string | null> {
|
||||
return applySharedProfileToCurrentSession(options)
|
||||
}
|
||||
|
||||
async function saveProfileAndNotify(
|
||||
onDone: LocalJSXCommandOnDone,
|
||||
profile: ProviderProfile,
|
||||
env: ProfileEnv,
|
||||
): Promise<void> {
|
||||
try {
|
||||
const profileFile = createProfileFile(profile, env)
|
||||
const filePath = saveProfileFile(profileFile)
|
||||
onDone(buildProfileSaveMessage(profile, env, filePath), {
|
||||
display: 'system',
|
||||
})
|
||||
const shouldActivateInSession = profile === 'codex'
|
||||
const activationWarning = shouldActivateInSession
|
||||
? await applySharedProfileToCurrentSession({ profileFile })
|
||||
: null
|
||||
|
||||
onDone(
|
||||
buildProfileSaveMessage(profile, env, filePath, {
|
||||
activatedInSession:
|
||||
shouldActivateInSession && activationWarning === null,
|
||||
activationWarning,
|
||||
}),
|
||||
{
|
||||
display: 'system',
|
||||
},
|
||||
)
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error)
|
||||
onDone(`Failed to save provider profile: ${message}`, {
|
||||
@@ -504,6 +565,10 @@ function ProviderChooser({
|
||||
onCancel: () => void
|
||||
}): React.ReactNode {
|
||||
const summary = buildCurrentProviderSummary()
|
||||
const canUseCodexOAuth = !isBareMode()
|
||||
const helperText = canUseCodexOAuth
|
||||
? 'Save a provider profile without editing environment variables first. Codex profiles backed by env, auth.json, or OpenClaude secure storage can switch this session immediately when validation succeeds.'
|
||||
: 'Save a provider profile without editing environment variables first. Codex profiles backed by env or auth.json can switch this session immediately.'
|
||||
const options: OptionWithDescription<ProviderChoice>[] = [
|
||||
{
|
||||
label: 'Auto',
|
||||
@@ -537,6 +602,16 @@ function ProviderChooser({
|
||||
value: 'codex',
|
||||
description: 'Use existing ChatGPT Codex CLI auth or env credentials',
|
||||
},
|
||||
...(canUseCodexOAuth
|
||||
? [
|
||||
{
|
||||
label: 'Codex OAuth',
|
||||
value: 'codex-oauth' as const,
|
||||
description:
|
||||
'Sign in with ChatGPT in your browser and store Codex tokens securely',
|
||||
},
|
||||
]
|
||||
: []),
|
||||
]
|
||||
|
||||
if (summary.savedProfileLabel !== 'none') {
|
||||
@@ -554,10 +629,7 @@ function ProviderChooser({
|
||||
onCancel={onCancel}
|
||||
>
|
||||
<Box flexDirection="column" gap={1}>
|
||||
<Text>
|
||||
Save a provider profile for the next OpenClaude restart without
|
||||
editing environment variables first.
|
||||
</Text>
|
||||
<Text>{helperText}</Text>
|
||||
<Box flexDirection="column">
|
||||
<Text dimColor>Current model: {summary.modelLabel}</Text>
|
||||
<Text dimColor>Current endpoint: {summary.endpointLabel}</Text>
|
||||
@@ -709,7 +781,9 @@ function AutoRecommendationStep({
|
||||
{ label: 'Back', value: 'back' },
|
||||
{ label: 'Cancel', value: 'cancel' },
|
||||
]}
|
||||
onChange={value => (value === 'back' ? onBack() : onCancel())}
|
||||
onChange={(value: string) =>
|
||||
value === 'back' ? onBack() : onCancel()
|
||||
}
|
||||
onCancel={onCancel}
|
||||
/>
|
||||
</Box>
|
||||
@@ -732,7 +806,7 @@ function AutoRecommendationStep({
|
||||
{ label: 'Back', value: 'back' },
|
||||
{ label: 'Cancel', value: 'cancel' },
|
||||
]}
|
||||
onChange={value => {
|
||||
onChange={(value: string) => {
|
||||
if (value === 'continue') {
|
||||
onNeedOpenAI(status.defaultModel)
|
||||
} else if (value === 'back') {
|
||||
@@ -765,7 +839,7 @@ function AutoRecommendationStep({
|
||||
{ label: 'Back', value: 'back' },
|
||||
{ label: 'Cancel', value: 'cancel' },
|
||||
]}
|
||||
onChange={value => {
|
||||
onChange={(value: string) => {
|
||||
if (value === 'save') {
|
||||
onSave(
|
||||
'ollama',
|
||||
@@ -867,7 +941,9 @@ function OllamaModelStep({
|
||||
{ label: 'Back', value: 'back' },
|
||||
{ label: 'Cancel', value: 'cancel' },
|
||||
]}
|
||||
onChange={value => (value === 'back' ? onBack() : onCancel())}
|
||||
onChange={(value: string) =>
|
||||
value === 'back' ? onBack() : onCancel()
|
||||
}
|
||||
onCancel={onCancel}
|
||||
/>
|
||||
</Box>
|
||||
@@ -888,7 +964,7 @@ function OllamaModelStep({
|
||||
defaultFocusValue={status.defaultValue}
|
||||
inlineDescriptions
|
||||
visibleOptionCount={Math.min(8, status.options.length)}
|
||||
onChange={value => {
|
||||
onChange={(value: string) => {
|
||||
onSave(
|
||||
'ollama',
|
||||
buildOllamaProfileEnv(value, {
|
||||
@@ -903,6 +979,84 @@ function OllamaModelStep({
|
||||
)
|
||||
}
|
||||
|
||||
function CodexOAuthStep({
|
||||
onSave,
|
||||
onBack,
|
||||
onCancel,
|
||||
}: {
|
||||
onSave: (profile: ProviderProfile, env: ProfileEnv) => void
|
||||
onBack: () => void
|
||||
onCancel: () => void
|
||||
}): React.ReactNode {
|
||||
const handleAuthenticated = React.useCallback(async (
|
||||
tokens: CodexOAuthTokens,
|
||||
persistCredentials: (options?: { profileId?: string }) => void,
|
||||
) => {
|
||||
const env = buildCodexOAuthProfileEnv(tokens)
|
||||
if (!env) {
|
||||
throw new Error(
|
||||
'Codex OAuth succeeded, but OpenClaude could not build a Codex profile from the stored credentials.',
|
||||
)
|
||||
}
|
||||
|
||||
persistCredentials()
|
||||
onSave('codex', env)
|
||||
}, [onSave])
|
||||
|
||||
const status = useCodexOAuthFlow({
|
||||
onAuthenticated: handleAuthenticated,
|
||||
})
|
||||
|
||||
if (status.state === 'error') {
|
||||
return (
|
||||
<Dialog title="Codex OAuth failed" onCancel={onCancel} color="warning">
|
||||
<Box flexDirection="column" gap={1}>
|
||||
<Text>{status.message}</Text>
|
||||
<Select
|
||||
options={[
|
||||
{ label: 'Back', value: 'back' },
|
||||
{ label: 'Cancel', value: 'cancel' },
|
||||
]}
|
||||
onChange={(value: string) =>
|
||||
value === 'back' ? onBack() : onCancel()
|
||||
}
|
||||
onCancel={onCancel}
|
||||
/>
|
||||
</Box>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
if (status.state === 'starting') {
|
||||
return <LoadingState message="Starting Codex OAuth..." />
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog title="Codex OAuth" onCancel={onBack}>
|
||||
<Box flexDirection="column" gap={1}>
|
||||
<Text>
|
||||
Finish signing in with ChatGPT in your browser. OpenClaude will store
|
||||
the resulting Codex credentials securely for future sessions.
|
||||
</Text>
|
||||
{status.browserOpened === false ? (
|
||||
<Text color="warning">
|
||||
Browser did not open automatically. Visit this URL to continue:
|
||||
</Text>
|
||||
) : status.browserOpened === true ? (
|
||||
<Text dimColor>
|
||||
Browser opened. Complete the sign-in there, then OpenClaude will
|
||||
finish setup automatically.
|
||||
</Text>
|
||||
) : (
|
||||
<Text dimColor>Opening your browser...</Text>
|
||||
)}
|
||||
<Text>{status.authUrl}</Text>
|
||||
<Text dimColor>Press Esc to cancel and go back.</Text>
|
||||
</Box>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
function CodexCredentialStep({
|
||||
onSave,
|
||||
onBack,
|
||||
@@ -924,7 +1078,9 @@ function CodexCredentialStep({
|
||||
{ label: 'Back', value: 'back' },
|
||||
{ label: 'Cancel', value: 'cancel' },
|
||||
]}
|
||||
onChange={value => (value === 'back' ? onBack() : onCancel())}
|
||||
onChange={(value: string) =>
|
||||
value === 'back' ? onBack() : onCancel()
|
||||
}
|
||||
onCancel={onCancel}
|
||||
/>
|
||||
</Box>
|
||||
@@ -958,9 +1114,10 @@ function CodexCredentialStep({
|
||||
defaultFocusValue="codexplan"
|
||||
inlineDescriptions
|
||||
visibleOptionCount={options.length}
|
||||
onChange={value => {
|
||||
onChange={(value: string) => {
|
||||
const env = buildCodexProfileEnv({
|
||||
model: value,
|
||||
credentialSource: credentials.credentialSource,
|
||||
processEnv: process.env,
|
||||
})
|
||||
if (env) {
|
||||
@@ -975,9 +1132,16 @@ function CodexCredentialStep({
|
||||
}
|
||||
|
||||
function resolveCodexCredentials(processEnv: NodeJS.ProcessEnv):
|
||||
| { ok: true; sourceDescription: string }
|
||||
| {
|
||||
ok: true
|
||||
sourceDescription: string
|
||||
credentialSource: 'oauth' | 'existing'
|
||||
}
|
||||
| { ok: false; message: string } {
|
||||
const credentials = resolveCodexApiCredentials(processEnv)
|
||||
const oauthHint = isBareMode()
|
||||
? 'Re-login with the Codex CLI'
|
||||
: 'Choose Codex OAuth in /provider, or re-login with the Codex CLI'
|
||||
|
||||
if (!credentials.apiKey) {
|
||||
const authHint = credentials.authPath
|
||||
@@ -985,7 +1149,7 @@ function resolveCodexCredentials(processEnv: NodeJS.ProcessEnv):
|
||||
: 'Set CODEX_API_KEY or re-login with the Codex CLI.'
|
||||
return {
|
||||
ok: false,
|
||||
message: `Codex setup needs existing credentials. Re-login with the Codex CLI or set CODEX_API_KEY. ${authHint}`,
|
||||
message: `Codex setup needs existing credentials. ${oauthHint}, or set CODEX_API_KEY. ${authHint}`,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -993,15 +1157,19 @@ function resolveCodexCredentials(processEnv: NodeJS.ProcessEnv):
|
||||
return {
|
||||
ok: false,
|
||||
message:
|
||||
'Codex auth is missing chatgpt_account_id. Re-login with the Codex CLI or set CHATGPT_ACCOUNT_ID/CODEX_ACCOUNT_ID first.',
|
||||
`Codex auth is missing chatgpt_account_id. ${oauthHint}, or set CHATGPT_ACCOUNT_ID/CODEX_ACCOUNT_ID first.`,
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
credentialSource:
|
||||
credentials.source === 'secure-storage' ? 'oauth' : 'existing',
|
||||
sourceDescription:
|
||||
credentials.source === 'env'
|
||||
? 'the current shell environment'
|
||||
: credentials.source === 'secure-storage'
|
||||
? 'OpenClaude secure storage'
|
||||
: credentials.authPath ?? DEFAULT_CODEX_BASE_URL,
|
||||
}
|
||||
}
|
||||
@@ -1035,6 +1203,8 @@ export function ProviderWizard({
|
||||
name: 'mistral-key',
|
||||
defaultModel: defaults.mistralModel,
|
||||
})
|
||||
} else if (value === 'codex-oauth') {
|
||||
setStep({ name: 'codex-oauth' })
|
||||
} else if (value === 'clear') {
|
||||
const filePath = deleteProfileFile()
|
||||
onDone(`Removed saved provider profile at ${filePath}. Restart OpenClaude to go back to normal startup.`, {
|
||||
@@ -1314,7 +1484,7 @@ export function ProviderWizard({
|
||||
options={options}
|
||||
inlineDescriptions
|
||||
visibleOptionCount={options.length}
|
||||
onChange={value => {
|
||||
onChange={(value: string) => {
|
||||
if (value === 'api-key') {
|
||||
setStep({ name: 'gemini-key' })
|
||||
} else if (value === 'access-token') {
|
||||
@@ -1470,6 +1640,15 @@ export function ProviderWizard({
|
||||
onCancel={() => onDone()}
|
||||
/>
|
||||
)
|
||||
|
||||
case 'codex-oauth':
|
||||
return (
|
||||
<CodexOAuthStep
|
||||
onSave={(profile, env) => finishProfileSave(onDone, profile, env)}
|
||||
onBack={() => setStep({ name: 'choose' })}
|
||||
onCancel={() => onDone()}
|
||||
/>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -101,9 +101,9 @@ export function EffortPicker({ onSelect, onCancel }: Props) {
|
||||
<Box marginBottom={1} flexDirection="column">
|
||||
<Text color="remember" bold={true}>Set effort level</Text>
|
||||
<Text dimColor={true}>
|
||||
{usesOpenAIEffort
|
||||
? `OpenAI/Codex provider (${provider})`
|
||||
: supportsEffort
|
||||
{supportsEffort && usesOpenAIEffort
|
||||
? `OpenAI/Codex provider (${provider})`
|
||||
: supportsEffort
|
||||
? `Claude model · ${provider} provider`
|
||||
: `Effort not supported for this model`
|
||||
}
|
||||
|
||||
@@ -5,13 +5,14 @@ import React from 'react'
|
||||
import stripAnsi from 'strip-ansi'
|
||||
|
||||
import { createRoot } from '../ink.js'
|
||||
import { AppStateProvider } from '../state/AppState.js'
|
||||
import { KeybindingSetup } from '../keybindings/KeybindingProviderSetup.js'
|
||||
import { AppStateProvider } from '../state/AppState.js'
|
||||
|
||||
const SYNC_START = '\x1B[?2026h'
|
||||
const SYNC_END = '\x1B[?2026l'
|
||||
|
||||
const ORIGINAL_ENV = {
|
||||
CLAUDE_CODE_SIMPLE: process.env.CLAUDE_CODE_SIMPLE,
|
||||
CLAUDE_CODE_USE_GITHUB: process.env.CLAUDE_CODE_USE_GITHUB,
|
||||
GITHUB_TOKEN: process.env.GITHUB_TOKEN,
|
||||
GH_TOKEN: process.env.GH_TOKEN,
|
||||
@@ -109,6 +110,9 @@ function createDeferred<T>(): {
|
||||
|
||||
function mockProviderProfilesModule(options?: {
|
||||
addProviderProfile?: (...args: unknown[]) => unknown
|
||||
getProviderProfiles?: () => unknown[]
|
||||
updateProviderProfile?: (...args: unknown[]) => unknown
|
||||
setActiveProviderProfile?: (...args: unknown[]) => unknown
|
||||
}): void {
|
||||
mock.module('../utils/providerProfiles.js', () => ({
|
||||
addProviderProfile: options?.addProviderProfile ?? (() => null),
|
||||
@@ -131,17 +135,20 @@ function mockProviderProfilesModule(options?: {
|
||||
model: 'mock-model',
|
||||
apiKey: '',
|
||||
},
|
||||
getProviderProfiles: () => [],
|
||||
setActiveProviderProfile: () => null,
|
||||
updateProviderProfile: () => null,
|
||||
getProviderProfiles: options?.getProviderProfiles ?? (() => []),
|
||||
setActiveProviderProfile: options?.setActiveProviderProfile ?? (() => null),
|
||||
updateProviderProfile: options?.updateProviderProfile ?? (() => null),
|
||||
}))
|
||||
}
|
||||
|
||||
function mockProviderManagerDependencies(
|
||||
syncRead: () => string | undefined,
|
||||
asyncRead: () => Promise<string | undefined>,
|
||||
githubSyncRead: () => string | undefined,
|
||||
githubAsyncRead: () => Promise<string | undefined>,
|
||||
options?: {
|
||||
addProviderProfile?: (...args: unknown[]) => unknown
|
||||
applySavedProfileToCurrentSession?: (...args: unknown[]) => Promise<string | null>
|
||||
clearCodexCredentials?: () => { success: boolean; warning?: string }
|
||||
getProviderProfiles?: () => unknown[]
|
||||
hasLocalOllama?: () => Promise<boolean>
|
||||
listOllamaModels?: () => Promise<
|
||||
Array<{
|
||||
@@ -153,9 +160,33 @@ function mockProviderManagerDependencies(
|
||||
quantizationLevel?: string | null
|
||||
}>
|
||||
>
|
||||
codexSyncRead?: () => unknown
|
||||
codexAsyncRead?: () => Promise<unknown>
|
||||
updateProviderProfile?: (...args: unknown[]) => unknown
|
||||
setActiveProviderProfile?: (...args: unknown[]) => unknown
|
||||
useCodexOAuthFlow?: (options: {
|
||||
onAuthenticated: (tokens: {
|
||||
accessToken: string
|
||||
refreshToken: string
|
||||
accountId?: string
|
||||
idToken?: string
|
||||
apiKey?: string
|
||||
}, persistCredentials: (options?: { profileId?: string }) => void) =>
|
||||
void | Promise<void>
|
||||
}) => {
|
||||
state: 'starting' | 'waiting' | 'error'
|
||||
authUrl?: string
|
||||
browserOpened?: boolean | null
|
||||
message?: string
|
||||
}
|
||||
},
|
||||
): void {
|
||||
mockProviderProfilesModule({ addProviderProfile: options?.addProviderProfile })
|
||||
mockProviderProfilesModule({
|
||||
addProviderProfile: options?.addProviderProfile,
|
||||
getProviderProfiles: options?.getProviderProfiles,
|
||||
updateProviderProfile: options?.updateProviderProfile,
|
||||
setActiveProviderProfile: options?.setActiveProviderProfile,
|
||||
})
|
||||
|
||||
mock.module('../utils/providerDiscovery.js', () => ({
|
||||
hasLocalOllama: options?.hasLocalOllama ?? (async () => false),
|
||||
@@ -166,13 +197,65 @@ function mockProviderManagerDependencies(
|
||||
clearGithubModelsToken: () => ({ success: true }),
|
||||
GITHUB_MODELS_HYDRATED_ENV_MARKER: 'CLAUDE_CODE_GITHUB_TOKEN_HYDRATED',
|
||||
hydrateGithubModelsTokenFromSecureStorage: () => {},
|
||||
readGithubModelsToken: syncRead,
|
||||
readGithubModelsTokenAsync: asyncRead,
|
||||
readGithubModelsToken: githubSyncRead,
|
||||
readGithubModelsTokenAsync: githubAsyncRead,
|
||||
}))
|
||||
|
||||
mock.module('../utils/codexCredentials.js', () => ({
|
||||
attachCodexProfileIdToStoredCredentials: () => ({ success: true }),
|
||||
clearCodexCredentials:
|
||||
options?.clearCodexCredentials ?? (() => ({ success: true })),
|
||||
readCodexCredentials:
|
||||
options?.codexSyncRead ?? (() => undefined),
|
||||
readCodexCredentialsAsync:
|
||||
options?.codexAsyncRead ?? (async () => undefined),
|
||||
}))
|
||||
|
||||
mock.module('../utils/providerProfile.js', () => ({
|
||||
applySavedProfileToCurrentSession:
|
||||
options?.applySavedProfileToCurrentSession ?? (async () => null),
|
||||
buildCodexOAuthProfileEnv: (tokens: {
|
||||
accessToken: string
|
||||
accountId?: string
|
||||
idToken?: string
|
||||
}) => {
|
||||
const accountId =
|
||||
tokens.accountId ??
|
||||
(tokens.idToken ? 'acct_from_id_token' : undefined) ??
|
||||
(tokens.accessToken ? 'acct_from_access_token' : undefined)
|
||||
|
||||
if (!accountId) {
|
||||
return null
|
||||
}
|
||||
|
||||
return {
|
||||
OPENAI_BASE_URL: 'https://chatgpt.com/backend-api/codex',
|
||||
OPENAI_MODEL: 'codexplan',
|
||||
CHATGPT_ACCOUNT_ID: accountId,
|
||||
CODEX_CREDENTIAL_SOURCE: 'oauth' as const,
|
||||
}
|
||||
},
|
||||
clearPersistedCodexOAuthProfile: () => null,
|
||||
createProfileFile: (profile: string, env: Record<string, unknown>) => ({
|
||||
profile,
|
||||
env,
|
||||
createdAt: '2026-04-10T00:00:00.000Z',
|
||||
}),
|
||||
}))
|
||||
|
||||
mock.module('../utils/settings/settings.js', () => ({
|
||||
updateSettingsForSource: () => ({ error: null }),
|
||||
}))
|
||||
|
||||
mock.module('./useCodexOAuthFlow.js', () => ({
|
||||
useCodexOAuthFlow:
|
||||
options?.useCodexOAuthFlow ??
|
||||
(() => ({
|
||||
state: 'waiting' as const,
|
||||
authUrl: 'https://chatgpt.com/codex',
|
||||
browserOpened: true,
|
||||
})),
|
||||
}))
|
||||
}
|
||||
|
||||
async function waitForFrameOutput(
|
||||
@@ -240,9 +323,9 @@ async function renderProviderManagerFrame(
|
||||
onDone: (result?: unknown) => void
|
||||
}>,
|
||||
options?: {
|
||||
mode?: 'first-run' | 'manage'
|
||||
waitForOutput?: (output: string) => boolean
|
||||
timeoutMs?: number
|
||||
mode?: 'first-run' | 'manage'
|
||||
},
|
||||
): Promise<string> {
|
||||
const mounted = await mountProviderManager(ProviderManager, {
|
||||
@@ -305,6 +388,47 @@ test('ProviderManager resolves GitHub virtual provider from async storage withou
|
||||
expect(asyncRead).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
test('ProviderManager avoids first-frame false negative while stored-token lookup is pending', async () => {
|
||||
delete process.env.CLAUDE_CODE_USE_GITHUB
|
||||
delete process.env.GITHUB_TOKEN
|
||||
delete process.env.GH_TOKEN
|
||||
|
||||
const syncRead = mock(() => {
|
||||
throw new Error('sync credential read should not run in ProviderManager render flow')
|
||||
})
|
||||
const deferredStoredToken = createDeferred<string | undefined>()
|
||||
const asyncRead = mock(async () => deferredStoredToken.promise)
|
||||
|
||||
mockProviderManagerDependencies(syncRead, asyncRead)
|
||||
|
||||
const nonce = `${Date.now()}-${Math.random()}`
|
||||
const { ProviderManager } = await import(`./ProviderManager.js?ts=${nonce}`)
|
||||
const mounted = await mountProviderManager(ProviderManager)
|
||||
|
||||
const firstFrame = await waitForFrameOutput(
|
||||
mounted.getOutput,
|
||||
frame => frame.includes('Provider manager'),
|
||||
)
|
||||
|
||||
expect(firstFrame).toContain('Checking GitHub Models credentials...')
|
||||
expect(firstFrame).not.toContain('No provider profiles configured yet.')
|
||||
|
||||
deferredStoredToken.resolve('stored-token')
|
||||
|
||||
const resolvedFrame = await waitForFrameOutput(
|
||||
mounted.getOutput,
|
||||
frame => frame.includes('GitHub Models') && frame.includes('token stored'),
|
||||
)
|
||||
|
||||
expect(resolvedFrame).toContain('GitHub Models')
|
||||
expect(resolvedFrame).toContain('token stored')
|
||||
|
||||
await mounted.dispose()
|
||||
|
||||
expect(syncRead).not.toHaveBeenCalled()
|
||||
expect(asyncRead).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
test('ProviderManager first-run Ollama preset auto-detects installed models', async () => {
|
||||
delete process.env.CLAUDE_CODE_USE_GITHUB
|
||||
delete process.env.GITHUB_TOKEN
|
||||
@@ -395,43 +519,411 @@ test('ProviderManager first-run Ollama preset auto-detects installed models', as
|
||||
await mounted.dispose()
|
||||
})
|
||||
|
||||
test('ProviderManager avoids first-frame false negative while stored-token lookup is pending', async () => {
|
||||
test('ProviderManager first-run Codex OAuth switches the current session after login completes', async () => {
|
||||
delete process.env.CLAUDE_CODE_SIMPLE
|
||||
delete process.env.CLAUDE_CODE_USE_GITHUB
|
||||
delete process.env.GITHUB_TOKEN
|
||||
delete process.env.GH_TOKEN
|
||||
|
||||
const syncRead = mock(() => {
|
||||
throw new Error('sync credential read should not run in ProviderManager render flow')
|
||||
})
|
||||
const deferredStoredToken = createDeferred<string | undefined>()
|
||||
const asyncRead = mock(async () => deferredStoredToken.promise)
|
||||
const onDone = mock(() => {})
|
||||
const applySavedProfileToCurrentSession = mock(async () => null)
|
||||
const persistCredentials = mock(() => {})
|
||||
const addProviderProfile = mock((payload: {
|
||||
provider: string
|
||||
name: string
|
||||
baseUrl: string
|
||||
model: string
|
||||
apiKey?: string
|
||||
}) => ({
|
||||
id: 'provider_codex_oauth',
|
||||
provider: payload.provider,
|
||||
name: payload.name,
|
||||
baseUrl: payload.baseUrl,
|
||||
model: payload.model,
|
||||
apiKey: payload.apiKey,
|
||||
}))
|
||||
|
||||
mockProviderManagerDependencies(syncRead, asyncRead)
|
||||
mockProviderManagerDependencies(
|
||||
() => undefined,
|
||||
async () => undefined,
|
||||
{
|
||||
addProviderProfile,
|
||||
applySavedProfileToCurrentSession,
|
||||
useCodexOAuthFlow: ({ onAuthenticated }) => {
|
||||
React.useEffect(() => {
|
||||
void onAuthenticated({
|
||||
accessToken: 'oauth-access-token',
|
||||
refreshToken: 'oauth-refresh-token',
|
||||
accountId: 'acct_oauth',
|
||||
}, persistCredentials)
|
||||
}, [onAuthenticated])
|
||||
|
||||
return {
|
||||
state: 'waiting',
|
||||
authUrl: 'https://chatgpt.com/codex',
|
||||
browserOpened: true,
|
||||
}
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
const nonce = `${Date.now()}-${Math.random()}`
|
||||
const { ProviderManager } = await import(`./ProviderManager.js?ts=${nonce}`)
|
||||
const mounted = await mountProviderManager(ProviderManager, {
|
||||
mode: 'first-run',
|
||||
onDone,
|
||||
})
|
||||
|
||||
await waitForFrameOutput(
|
||||
mounted.getOutput,
|
||||
frame => frame.includes('Set up provider') && frame.includes('Codex OAuth'),
|
||||
)
|
||||
|
||||
mounted.stdin.write('j')
|
||||
await Bun.sleep(25)
|
||||
mounted.stdin.write('j')
|
||||
await Bun.sleep(25)
|
||||
mounted.stdin.write('j')
|
||||
await Bun.sleep(25)
|
||||
mounted.stdin.write('\r')
|
||||
|
||||
await waitForCondition(() => onDone.mock.calls.length > 0)
|
||||
|
||||
expect(addProviderProfile).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
provider: 'openai',
|
||||
name: 'Codex OAuth',
|
||||
baseUrl: 'https://chatgpt.com/backend-api/codex',
|
||||
model: 'codexplan',
|
||||
apiKey: '',
|
||||
}),
|
||||
expect.objectContaining({ makeActive: true }),
|
||||
)
|
||||
expect(applySavedProfileToCurrentSession).toHaveBeenCalled()
|
||||
expect(persistCredentials).toHaveBeenCalledWith({
|
||||
profileId: 'provider_codex_oauth',
|
||||
})
|
||||
expect(onDone).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
action: 'saved',
|
||||
message:
|
||||
'Codex OAuth configured. OpenClaude switched to it for this session.',
|
||||
}),
|
||||
)
|
||||
|
||||
await mounted.dispose()
|
||||
})
|
||||
|
||||
test('ProviderManager first-run Codex OAuth reports next-startup fallback when session activation fails', async () => {
|
||||
delete process.env.CLAUDE_CODE_SIMPLE
|
||||
delete process.env.CLAUDE_CODE_USE_GITHUB
|
||||
delete process.env.GITHUB_TOKEN
|
||||
delete process.env.GH_TOKEN
|
||||
|
||||
const onDone = mock(() => {})
|
||||
const applySavedProfileToCurrentSession = mock(
|
||||
async () => 'validation failed',
|
||||
)
|
||||
const persistCredentials = mock(() => {})
|
||||
const addProviderProfile = mock((payload: {
|
||||
provider: string
|
||||
name: string
|
||||
baseUrl: string
|
||||
model: string
|
||||
apiKey?: string
|
||||
}) => ({
|
||||
id: 'provider_codex_oauth',
|
||||
provider: payload.provider,
|
||||
name: payload.name,
|
||||
baseUrl: payload.baseUrl,
|
||||
model: payload.model,
|
||||
apiKey: payload.apiKey,
|
||||
}))
|
||||
|
||||
mockProviderManagerDependencies(
|
||||
() => undefined,
|
||||
async () => undefined,
|
||||
{
|
||||
addProviderProfile,
|
||||
applySavedProfileToCurrentSession,
|
||||
useCodexOAuthFlow: ({ onAuthenticated }) => {
|
||||
React.useEffect(() => {
|
||||
void onAuthenticated({
|
||||
accessToken: 'oauth-access-token',
|
||||
refreshToken: 'oauth-refresh-token',
|
||||
accountId: 'acct_oauth',
|
||||
}, persistCredentials)
|
||||
}, [onAuthenticated])
|
||||
|
||||
return {
|
||||
state: 'waiting',
|
||||
authUrl: 'https://chatgpt.com/codex',
|
||||
browserOpened: true,
|
||||
}
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
const nonce = `${Date.now()}-${Math.random()}`
|
||||
const { ProviderManager } = await import(`./ProviderManager.js?ts=${nonce}`)
|
||||
const mounted = await mountProviderManager(ProviderManager, {
|
||||
mode: 'first-run',
|
||||
onDone,
|
||||
})
|
||||
|
||||
await waitForFrameOutput(
|
||||
mounted.getOutput,
|
||||
frame => frame.includes('Set up provider') && frame.includes('Codex OAuth'),
|
||||
)
|
||||
|
||||
mounted.stdin.write('j')
|
||||
await Bun.sleep(25)
|
||||
mounted.stdin.write('j')
|
||||
await Bun.sleep(25)
|
||||
mounted.stdin.write('j')
|
||||
await Bun.sleep(25)
|
||||
mounted.stdin.write('\r')
|
||||
|
||||
await waitForCondition(() => onDone.mock.calls.length > 0)
|
||||
|
||||
expect(persistCredentials).toHaveBeenCalledWith({
|
||||
profileId: 'provider_codex_oauth',
|
||||
})
|
||||
expect(onDone).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
action: 'saved',
|
||||
message:
|
||||
'Codex OAuth configured. Saved for next startup. Warning: validation failed.',
|
||||
}),
|
||||
)
|
||||
|
||||
await mounted.dispose()
|
||||
})
|
||||
|
||||
test('ProviderManager does not hijack a manual Codex profile when OAuth credentials are not yet linked', async () => {
|
||||
delete process.env.CLAUDE_CODE_SIMPLE
|
||||
delete process.env.CLAUDE_CODE_USE_GITHUB
|
||||
delete process.env.GITHUB_TOKEN
|
||||
delete process.env.GH_TOKEN
|
||||
|
||||
const onDone = mock(() => {})
|
||||
const manualProfile = {
|
||||
id: 'provider_manual_codex',
|
||||
provider: 'openai',
|
||||
name: 'Codex OAuth',
|
||||
baseUrl: 'https://chatgpt.com/backend-api/codex',
|
||||
model: 'gpt-5.4',
|
||||
apiKey: 'manual-key',
|
||||
}
|
||||
const addProviderProfile = mock((payload: {
|
||||
provider: string
|
||||
name: string
|
||||
baseUrl: string
|
||||
model: string
|
||||
apiKey?: string
|
||||
}) => ({
|
||||
id: 'provider_codex_oauth',
|
||||
provider: payload.provider,
|
||||
name: payload.name,
|
||||
baseUrl: payload.baseUrl,
|
||||
model: payload.model,
|
||||
apiKey: payload.apiKey,
|
||||
}))
|
||||
const updateProviderProfile = mock(() => manualProfile)
|
||||
const persistCredentials = mock(() => {})
|
||||
|
||||
mockProviderManagerDependencies(
|
||||
() => undefined,
|
||||
async () => undefined,
|
||||
{
|
||||
addProviderProfile,
|
||||
getProviderProfiles: () => [manualProfile],
|
||||
updateProviderProfile,
|
||||
useCodexOAuthFlow: ({ onAuthenticated }) => {
|
||||
const hasAuthenticated = React.useRef(false)
|
||||
|
||||
React.useEffect(() => {
|
||||
if (hasAuthenticated.current) {
|
||||
return
|
||||
}
|
||||
hasAuthenticated.current = true
|
||||
void onAuthenticated({
|
||||
accessToken: 'oauth-access-token',
|
||||
refreshToken: 'oauth-refresh-token',
|
||||
accountId: 'acct_oauth',
|
||||
}, persistCredentials)
|
||||
}, [onAuthenticated])
|
||||
|
||||
return {
|
||||
state: 'waiting',
|
||||
authUrl: 'https://chatgpt.com/codex',
|
||||
browserOpened: true,
|
||||
}
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
const nonce = `${Date.now()}-${Math.random()}`
|
||||
const { ProviderManager } = await import(`./ProviderManager.js?ts=${nonce}`)
|
||||
const mounted = await mountProviderManager(ProviderManager, {
|
||||
mode: 'first-run',
|
||||
onDone,
|
||||
})
|
||||
|
||||
await waitForFrameOutput(
|
||||
mounted.getOutput,
|
||||
frame => frame.includes('Set up provider') && frame.includes('Codex OAuth'),
|
||||
)
|
||||
|
||||
mounted.stdin.write('j')
|
||||
await Bun.sleep(25)
|
||||
mounted.stdin.write('j')
|
||||
await Bun.sleep(25)
|
||||
mounted.stdin.write('j')
|
||||
await Bun.sleep(25)
|
||||
mounted.stdin.write('\r')
|
||||
|
||||
await waitForCondition(() => onDone.mock.calls.length > 0)
|
||||
|
||||
expect(addProviderProfile).toHaveBeenCalledTimes(1)
|
||||
expect(updateProviderProfile).not.toHaveBeenCalled()
|
||||
expect(persistCredentials).toHaveBeenCalledWith({
|
||||
profileId: 'provider_codex_oauth',
|
||||
})
|
||||
|
||||
await mounted.dispose()
|
||||
})
|
||||
|
||||
test('ProviderManager keeps Codex OAuth as next-startup only when activating the session fails from the menu', async () => {
|
||||
delete process.env.CLAUDE_CODE_SIMPLE
|
||||
delete process.env.CLAUDE_CODE_USE_GITHUB
|
||||
delete process.env.GITHUB_TOKEN
|
||||
delete process.env.GH_TOKEN
|
||||
|
||||
const codexProfile = {
|
||||
id: 'provider_codex_oauth',
|
||||
provider: 'openai',
|
||||
name: 'Codex OAuth',
|
||||
baseUrl: 'https://chatgpt.com/backend-api/codex',
|
||||
model: 'codexplan',
|
||||
apiKey: '',
|
||||
}
|
||||
|
||||
const applySavedProfileToCurrentSession = mock(
|
||||
async () => 'validation failed',
|
||||
)
|
||||
const setActiveProviderProfile = mock(() => codexProfile)
|
||||
|
||||
mockProviderManagerDependencies(
|
||||
() => undefined,
|
||||
async () => undefined,
|
||||
{
|
||||
applySavedProfileToCurrentSession,
|
||||
getProviderProfiles: () => [codexProfile],
|
||||
setActiveProviderProfile,
|
||||
codexAsyncRead: async () => ({
|
||||
accessToken: 'oauth-access-token',
|
||||
refreshToken: 'oauth-refresh-token',
|
||||
accountId: 'acct_oauth',
|
||||
profileId: 'provider_codex_oauth',
|
||||
}),
|
||||
},
|
||||
)
|
||||
|
||||
const nonce = `${Date.now()}-${Math.random()}`
|
||||
const { ProviderManager } = await import(`./ProviderManager.js?ts=${nonce}`)
|
||||
const mounted = await mountProviderManager(ProviderManager)
|
||||
|
||||
const firstFrame = await waitForFrameOutput(
|
||||
await waitForFrameOutput(
|
||||
mounted.getOutput,
|
||||
frame => frame.includes('Provider manager'),
|
||||
frame =>
|
||||
frame.includes('Provider manager') &&
|
||||
frame.includes('Set active provider') &&
|
||||
frame.includes('Log out Codex OAuth'),
|
||||
)
|
||||
|
||||
expect(firstFrame).toContain('Checking GitHub Models credentials...')
|
||||
expect(firstFrame).not.toContain('No provider profiles configured yet.')
|
||||
mounted.stdin.write('j')
|
||||
await Bun.sleep(25)
|
||||
mounted.stdin.write('\r')
|
||||
|
||||
deferredStoredToken.resolve('stored-token')
|
||||
|
||||
const resolvedFrame = await waitForFrameOutput(
|
||||
await waitForFrameOutput(
|
||||
mounted.getOutput,
|
||||
frame => frame.includes('GitHub Models') && frame.includes('token stored'),
|
||||
frame => frame.includes('Set active provider') && frame.includes('Codex OAuth'),
|
||||
)
|
||||
|
||||
expect(resolvedFrame).toContain('GitHub Models')
|
||||
expect(resolvedFrame).toContain('token stored')
|
||||
await Bun.sleep(25)
|
||||
mounted.stdin.write('\r')
|
||||
|
||||
await waitForCondition(() => setActiveProviderProfile.mock.calls.length > 0)
|
||||
await waitForCondition(
|
||||
() => applySavedProfileToCurrentSession.mock.calls.length > 0,
|
||||
)
|
||||
await Bun.sleep(50)
|
||||
const output = stripAnsi(extractLastFrame(mounted.getOutput()))
|
||||
|
||||
expect(output).toContain(
|
||||
'Active provider: Codex OAuth. Saved for next startup. Warning: validation failed.',
|
||||
)
|
||||
expect(applySavedProfileToCurrentSession).toHaveBeenCalled()
|
||||
expect(setActiveProviderProfile).toHaveBeenCalledWith('provider_codex_oauth')
|
||||
|
||||
await mounted.dispose()
|
||||
|
||||
expect(syncRead).not.toHaveBeenCalled()
|
||||
expect(asyncRead).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
test('ProviderManager resolves Codex OAuth state from async storage without sync reads in render flow', async () => {
|
||||
delete process.env.CLAUDE_CODE_SIMPLE
|
||||
delete process.env.CLAUDE_CODE_USE_GITHUB
|
||||
delete process.env.GITHUB_TOKEN
|
||||
delete process.env.GH_TOKEN
|
||||
|
||||
const githubSyncRead = mock(() => undefined)
|
||||
const githubAsyncRead = mock(async () => undefined)
|
||||
const codexSyncRead = mock(() => {
|
||||
throw new Error('sync codex credential read should not run in ProviderManager render flow')
|
||||
})
|
||||
const codexAsyncRead = mock(async () => ({
|
||||
accessToken: 'codex-access-token',
|
||||
refreshToken: 'codex-refresh-token',
|
||||
}))
|
||||
|
||||
mockProviderManagerDependencies(githubSyncRead, githubAsyncRead, {
|
||||
codexSyncRead,
|
||||
codexAsyncRead,
|
||||
})
|
||||
|
||||
const nonce = `${Date.now()}-${Math.random()}`
|
||||
const { ProviderManager } = await import(`./ProviderManager.js?ts=${nonce}`)
|
||||
const output = await renderProviderManagerFrame(ProviderManager, {
|
||||
waitForOutput: frame =>
|
||||
frame.includes('Provider manager') &&
|
||||
frame.includes('Log out Codex OAuth'),
|
||||
})
|
||||
|
||||
expect(output).toContain('Provider manager')
|
||||
expect(output).toContain('Log out Codex OAuth')
|
||||
expect(codexSyncRead).not.toHaveBeenCalled()
|
||||
expect(codexAsyncRead).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
test('ProviderManager hides Codex OAuth setup in bare mode', async () => {
|
||||
process.env.CLAUDE_CODE_SIMPLE = '1'
|
||||
delete process.env.CLAUDE_CODE_USE_GITHUB
|
||||
delete process.env.GITHUB_TOKEN
|
||||
delete process.env.GH_TOKEN
|
||||
|
||||
const githubSyncRead = mock(() => undefined)
|
||||
const githubAsyncRead = mock(async () => undefined)
|
||||
|
||||
mockProviderManagerDependencies(githubSyncRead, githubAsyncRead)
|
||||
|
||||
const nonce = `${Date.now()}-${Math.random()}`
|
||||
const { ProviderManager } = await import(`./ProviderManager.js?ts=${nonce}`)
|
||||
const output = await renderProviderManagerFrame(ProviderManager, {
|
||||
mode: 'first-run',
|
||||
waitForOutput: frame =>
|
||||
frame.includes('Set up provider') && frame.includes('OpenAI'),
|
||||
})
|
||||
|
||||
expect(output).toContain('Set up provider')
|
||||
expect(output).not.toContain('Codex OAuth')
|
||||
})
|
||||
|
||||
@@ -1,9 +1,20 @@
|
||||
import figures from 'figures'
|
||||
import * as React from 'react'
|
||||
import { DEFAULT_CODEX_BASE_URL } from '../services/api/providerConfig.js'
|
||||
import { Box, Text } from '../ink.js'
|
||||
import { useKeybinding } from '../keybindings/useKeybinding.js'
|
||||
import type { ProviderProfile } from '../utils/config.js'
|
||||
import { hasLocalOllama, listOllamaModels } from '../utils/providerDiscovery.js'
|
||||
import {
|
||||
clearCodexCredentials,
|
||||
readCodexCredentialsAsync,
|
||||
} from '../utils/codexCredentials.js'
|
||||
import { isBareMode, isEnvTruthy } from '../utils/envUtils.js'
|
||||
import {
|
||||
applySavedProfileToCurrentSession,
|
||||
buildCodexOAuthProfileEnv,
|
||||
clearPersistedCodexOAuthProfile,
|
||||
createProfileFile,
|
||||
} from '../utils/providerProfile.js'
|
||||
import {
|
||||
addProviderProfile,
|
||||
applyActiveProviderProfileFromConfig,
|
||||
@@ -16,10 +27,6 @@ import {
|
||||
type ProviderProfileInput,
|
||||
updateProviderProfile,
|
||||
} from '../utils/providerProfiles.js'
|
||||
import {
|
||||
rankOllamaModels,
|
||||
recommendOllamaModel,
|
||||
} from '../utils/providerRecommendation.js'
|
||||
import {
|
||||
clearGithubModelsToken,
|
||||
GITHUB_MODELS_HYDRATED_ENV_MARKER,
|
||||
@@ -27,11 +34,22 @@ import {
|
||||
readGithubModelsToken,
|
||||
readGithubModelsTokenAsync,
|
||||
} from '../utils/githubModelsCredentials.js'
|
||||
import { isEnvTruthy } from '../utils/envUtils.js'
|
||||
import {
|
||||
hasLocalOllama,
|
||||
listOllamaModels,
|
||||
} from '../utils/providerDiscovery.js'
|
||||
import {
|
||||
rankOllamaModels,
|
||||
recommendOllamaModel,
|
||||
} from '../utils/providerRecommendation.js'
|
||||
import { updateSettingsForSource } from '../utils/settings/settings.js'
|
||||
import { type OptionWithDescription, Select } from './CustomSelect/index.js'
|
||||
import {
|
||||
type OptionWithDescription,
|
||||
Select,
|
||||
} from './CustomSelect/index.js'
|
||||
import { Pane } from './design-system/Pane.js'
|
||||
import TextInput from './TextInput.js'
|
||||
import { useCodexOAuthFlow } from './useCodexOAuthFlow.js'
|
||||
|
||||
export type ProviderManagerResult = {
|
||||
action: 'saved' | 'cancelled'
|
||||
@@ -48,6 +66,7 @@ type Screen =
|
||||
| 'menu'
|
||||
| 'select-preset'
|
||||
| 'select-ollama-model'
|
||||
| 'codex-oauth'
|
||||
| 'form'
|
||||
| 'select-active'
|
||||
| 'select-edit'
|
||||
@@ -105,6 +124,8 @@ const GITHUB_PROVIDER_ID = '__github_models__'
|
||||
const GITHUB_PROVIDER_LABEL = 'GitHub Models'
|
||||
const GITHUB_PROVIDER_DEFAULT_MODEL = 'github:copilot'
|
||||
const GITHUB_PROVIDER_DEFAULT_BASE_URL = 'https://models.github.ai/inference'
|
||||
const CODEX_OAUTH_PROVIDER_NAME = 'Codex OAuth'
|
||||
const CODEX_OAUTH_PROVIDER_MODEL = 'codexplan'
|
||||
|
||||
type GithubCredentialSource = 'stored' | 'env' | 'none'
|
||||
|
||||
@@ -193,6 +214,111 @@ function getGithubProviderSummary(
|
||||
return `github-models · ${GITHUB_PROVIDER_DEFAULT_BASE_URL} · ${getGithubProviderModel(processEnv)} · ${credentialSummary}${activeSuffix}`
|
||||
}
|
||||
|
||||
function findCodexOAuthProfile(
|
||||
profiles: ProviderProfile[],
|
||||
profileId?: string,
|
||||
): ProviderProfile | undefined {
|
||||
if (!profileId) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
return profiles.find(profile => profile.id === profileId)
|
||||
}
|
||||
|
||||
function isCodexOAuthProfile(
|
||||
profile: ProviderProfile | null | undefined,
|
||||
profileId?: string,
|
||||
): boolean {
|
||||
return Boolean(profile && profileId && profile.id === profileId)
|
||||
}
|
||||
|
||||
function CodexOAuthSetup({
|
||||
onBack,
|
||||
onConfigured,
|
||||
}: {
|
||||
onBack: () => void
|
||||
onConfigured: (tokens: {
|
||||
accessToken: string
|
||||
refreshToken: string
|
||||
accountId?: string
|
||||
idToken?: string
|
||||
apiKey?: string
|
||||
}, persistCredentials: (options?: { profileId?: string }) => void) => void | Promise<void>
|
||||
}): React.ReactNode {
|
||||
const handleAuthenticated = React.useCallback(async (tokens: {
|
||||
accessToken: string
|
||||
refreshToken: string
|
||||
accountId?: string
|
||||
idToken?: string
|
||||
apiKey?: string
|
||||
}, persistCredentials: (options?: { profileId?: string }) => void) => {
|
||||
await onConfigured(tokens, persistCredentials)
|
||||
}, [onConfigured])
|
||||
useKeybinding('confirm:no', onBack, [onBack])
|
||||
|
||||
const status = useCodexOAuthFlow({
|
||||
onAuthenticated: handleAuthenticated,
|
||||
})
|
||||
|
||||
if (status.state === 'error') {
|
||||
return (
|
||||
<Box flexDirection="column" gap={1}>
|
||||
<Text color="error" bold>
|
||||
Codex OAuth failed
|
||||
</Text>
|
||||
<Text>{status.message}</Text>
|
||||
<Text dimColor>Press Enter or Esc to go back.</Text>
|
||||
<Select
|
||||
options={[
|
||||
{
|
||||
value: 'back',
|
||||
label: 'Back',
|
||||
description: 'Return to provider presets',
|
||||
},
|
||||
]}
|
||||
onChange={onBack}
|
||||
onCancel={onBack}
|
||||
visibleOptionCount={1}
|
||||
/>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Box flexDirection="column" gap={1}>
|
||||
<Text color="remember" bold>
|
||||
Codex OAuth
|
||||
</Text>
|
||||
<Text>
|
||||
Sign in with your ChatGPT account in the browser. OpenClaude will store
|
||||
the resulting Codex credentials securely and switch this session to the
|
||||
new Codex login when setup completes.
|
||||
</Text>
|
||||
{status.state === 'starting' ? (
|
||||
<Text dimColor>Starting local callback and preparing your browser...</Text>
|
||||
) : status.browserOpened === false ? (
|
||||
<>
|
||||
<Text color="warning">
|
||||
Browser did not open automatically. Visit this URL to continue:
|
||||
</Text>
|
||||
<Text>{status.authUrl}</Text>
|
||||
</>
|
||||
) : status.browserOpened === true ? (
|
||||
<>
|
||||
<Text dimColor>
|
||||
Browser opened. Finish the ChatGPT sign-in there and this setup will
|
||||
complete automatically.
|
||||
</Text>
|
||||
<Text>{status.authUrl}</Text>
|
||||
</>
|
||||
) : (
|
||||
<Text dimColor>Opening your browser...</Text>
|
||||
)}
|
||||
<Text dimColor>Press Esc to cancel and go back.</Text>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
export function ProviderManager({ mode, onDone }: Props): React.ReactNode {
|
||||
const initialGithubCredentialSource = getGithubCredentialSourceFromEnv()
|
||||
const initialIsGithubActive = isEnvTruthy(process.env.CLAUDE_CODE_USE_GITHUB)
|
||||
@@ -212,6 +338,7 @@ export function ProviderManager({ mode, onDone }: Props): React.ReactNode {
|
||||
const [isGithubCredentialSourceResolved, setIsGithubCredentialSourceResolved] =
|
||||
React.useState(() => initialHasGithubCredential || initialIsGithubActive)
|
||||
const githubRefreshEpochRef = React.useRef(0)
|
||||
const codexRefreshEpochRef = React.useRef(0)
|
||||
const [screen, setScreen] = React.useState<Screen>(
|
||||
mode === 'first-run' ? 'select-preset' : 'menu',
|
||||
)
|
||||
@@ -226,6 +353,10 @@ export function ProviderManager({ mode, onDone }: Props): React.ReactNode {
|
||||
const [cursorOffset, setCursorOffset] = React.useState(0)
|
||||
const [statusMessage, setStatusMessage] = React.useState<string | undefined>()
|
||||
const [errorMessage, setErrorMessage] = React.useState<string | undefined>()
|
||||
const [hasStoredCodexOAuthCredentials, setHasStoredCodexOAuthCredentials] =
|
||||
React.useState(false)
|
||||
const [storedCodexOAuthProfileId, setStoredCodexOAuthProfileId] =
|
||||
React.useState<string | undefined>()
|
||||
const [ollamaSelection, setOllamaSelection] = React.useState<OllamaSelectionState>({
|
||||
state: 'idle',
|
||||
})
|
||||
@@ -263,19 +394,102 @@ export function ProviderManager({ mode, onDone }: Props): React.ReactNode {
|
||||
})()
|
||||
}, [])
|
||||
|
||||
const refreshCodexOAuthCredentialState = React.useCallback((): void => {
|
||||
if (isBareMode()) {
|
||||
codexRefreshEpochRef.current += 1
|
||||
setHasStoredCodexOAuthCredentials(false)
|
||||
setStoredCodexOAuthProfileId(undefined)
|
||||
return
|
||||
}
|
||||
|
||||
const refreshEpoch = ++codexRefreshEpochRef.current
|
||||
void (async () => {
|
||||
const credentials = await readCodexCredentialsAsync()
|
||||
if (refreshEpoch !== codexRefreshEpochRef.current) {
|
||||
return
|
||||
}
|
||||
|
||||
setHasStoredCodexOAuthCredentials(
|
||||
Boolean(
|
||||
credentials?.apiKey ||
|
||||
credentials?.accessToken ||
|
||||
credentials?.refreshToken ||
|
||||
credentials?.idToken,
|
||||
),
|
||||
)
|
||||
setStoredCodexOAuthProfileId(credentials?.profileId)
|
||||
})()
|
||||
}, [])
|
||||
|
||||
React.useEffect(() => {
|
||||
refreshGithubProviderState()
|
||||
refreshCodexOAuthCredentialState()
|
||||
|
||||
return () => {
|
||||
githubRefreshEpochRef.current += 1
|
||||
codexRefreshEpochRef.current += 1
|
||||
}
|
||||
}, [refreshGithubProviderState])
|
||||
}, [refreshCodexOAuthCredentialState, refreshGithubProviderState])
|
||||
|
||||
React.useEffect(() => {
|
||||
if (screen !== 'select-ollama-model') {
|
||||
return
|
||||
}
|
||||
|
||||
let cancelled = false
|
||||
setOllamaSelection({ state: 'loading' })
|
||||
|
||||
void (async () => {
|
||||
const available = await hasLocalOllama(draft.baseUrl)
|
||||
if (!available) {
|
||||
if (!cancelled) {
|
||||
setOllamaSelection({
|
||||
state: 'unavailable',
|
||||
message:
|
||||
'Could not reach Ollama. Start Ollama first, or enter the endpoint manually.',
|
||||
})
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
const models = await listOllamaModels(draft.baseUrl)
|
||||
if (models.length === 0) {
|
||||
if (!cancelled) {
|
||||
setOllamaSelection({
|
||||
state: 'unavailable',
|
||||
message:
|
||||
'Ollama is running, but no installed models were found. Pull a chat model such as qwen2.5-coder:7b or llama3.1:8b first, or enter details manually.',
|
||||
})
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
const ranked = rankOllamaModels(models, 'balanced')
|
||||
const recommended = recommendOllamaModel(models, 'balanced')
|
||||
if (!cancelled) {
|
||||
setOllamaSelection({
|
||||
state: 'ready',
|
||||
defaultValue: recommended?.name ?? ranked[0]?.name,
|
||||
options: ranked.map(model => ({
|
||||
label: model.name,
|
||||
value: model.name,
|
||||
description: model.summary,
|
||||
})),
|
||||
})
|
||||
}
|
||||
})()
|
||||
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [draft.baseUrl, screen])
|
||||
|
||||
function refreshProfiles(): void {
|
||||
const nextProfiles = getProviderProfiles()
|
||||
setProfiles(nextProfiles)
|
||||
setActiveProfileId(getActiveProviderProfile()?.id)
|
||||
refreshGithubProviderState()
|
||||
refreshCodexOAuthCredentialState()
|
||||
}
|
||||
|
||||
function clearStartupProviderOverrideFromUserSettings(): string | null {
|
||||
@@ -292,6 +506,123 @@ export function ProviderManager({ mode, onDone }: Props): React.ReactNode {
|
||||
return error ? error.message : null
|
||||
}
|
||||
|
||||
function buildCodexOAuthActivationMessage(options: {
|
||||
prefix: string
|
||||
activationWarning: string | null
|
||||
warnings: string[]
|
||||
}): string {
|
||||
if (options.activationWarning) {
|
||||
return `${options.prefix}. Saved for next startup. Warning: ${options.warnings.join('; ')}.`
|
||||
}
|
||||
|
||||
if (options.warnings.length > 0) {
|
||||
return `${options.prefix}. OpenClaude switched to it for this session with warnings: ${options.warnings.join('; ')}.`
|
||||
}
|
||||
|
||||
return `${options.prefix}. OpenClaude switched to it for this session.`
|
||||
}
|
||||
|
||||
async function activateCodexOAuthSession(tokens?: {
|
||||
accessToken: string
|
||||
refreshToken?: string
|
||||
accountId?: string
|
||||
idToken?: string
|
||||
}): Promise<string | null> {
|
||||
const oauthEnv = buildCodexOAuthProfileEnv({
|
||||
accessToken: tokens?.accessToken ?? '',
|
||||
accountId: tokens?.accountId,
|
||||
idToken: tokens?.idToken,
|
||||
})
|
||||
|
||||
if (oauthEnv) {
|
||||
return applySavedProfileToCurrentSession({
|
||||
profileFile: createProfileFile('codex', oauthEnv),
|
||||
})
|
||||
}
|
||||
|
||||
const storedCredentials = await readCodexCredentialsAsync()
|
||||
if (!storedCredentials) {
|
||||
return 'stored Codex OAuth credentials could not be loaded'
|
||||
}
|
||||
|
||||
const storedEnv = buildCodexOAuthProfileEnv({
|
||||
accessToken: storedCredentials.accessToken,
|
||||
accountId: storedCredentials.accountId,
|
||||
idToken: storedCredentials.idToken,
|
||||
})
|
||||
if (!storedEnv) {
|
||||
return 'stored Codex OAuth credentials are missing a ChatGPT account id'
|
||||
}
|
||||
|
||||
return applySavedProfileToCurrentSession({
|
||||
profileFile: createProfileFile('codex', storedEnv),
|
||||
})
|
||||
}
|
||||
|
||||
async function activateSelectedProvider(profileId: string): Promise<void> {
|
||||
let providerLabel = 'provider'
|
||||
|
||||
try {
|
||||
if (profileId === GITHUB_PROVIDER_ID) {
|
||||
providerLabel = GITHUB_PROVIDER_LABEL
|
||||
const githubError = activateGithubProvider()
|
||||
if (githubError) {
|
||||
setErrorMessage(`Could not activate GitHub provider: ${githubError}`)
|
||||
setScreen('menu')
|
||||
return
|
||||
}
|
||||
|
||||
refreshProfiles()
|
||||
setStatusMessage(`Active provider: ${GITHUB_PROVIDER_LABEL}`)
|
||||
setScreen('menu')
|
||||
return
|
||||
}
|
||||
|
||||
const active = setActiveProviderProfile(profileId)
|
||||
if (!active) {
|
||||
setErrorMessage('Could not change active provider.')
|
||||
setScreen('menu')
|
||||
return
|
||||
}
|
||||
|
||||
providerLabel = active.name
|
||||
const settingsOverrideError =
|
||||
clearStartupProviderOverrideFromUserSettings()
|
||||
const isActiveCodexOAuth = isCodexOAuthProfile(
|
||||
active,
|
||||
storedCodexOAuthProfileId,
|
||||
)
|
||||
const activationWarning = isActiveCodexOAuth
|
||||
? await activateCodexOAuthSession()
|
||||
: null
|
||||
|
||||
refreshProfiles()
|
||||
setStatusMessage(
|
||||
isActiveCodexOAuth
|
||||
? buildCodexOAuthActivationMessage({
|
||||
prefix: `Active provider: ${active.name}`,
|
||||
activationWarning,
|
||||
warnings: [
|
||||
activationWarning,
|
||||
settingsOverrideError
|
||||
? `could not clear startup provider override (${settingsOverrideError})`
|
||||
: null,
|
||||
].filter((warning): warning is string => Boolean(warning)),
|
||||
})
|
||||
: settingsOverrideError
|
||||
? `Active provider: ${active.name}. Warning: could not clear startup provider override (${settingsOverrideError}).`
|
||||
: `Active provider: ${active.name}`,
|
||||
)
|
||||
setScreen('menu')
|
||||
} catch (error) {
|
||||
refreshProfiles()
|
||||
setStatusMessage(undefined)
|
||||
const detail = error instanceof Error ? error.message : String(error)
|
||||
setErrorMessage(`Could not finish activating ${providerLabel}: ${detail}`)
|
||||
setScreen('menu')
|
||||
}
|
||||
}
|
||||
|
||||
function closeWithCancelled(message: string): void {
|
||||
onDone({ action: 'cancelled', message })
|
||||
}
|
||||
@@ -383,59 +714,6 @@ export function ProviderManager({ mode, onDone }: Props): React.ReactNode {
|
||||
return null
|
||||
}
|
||||
|
||||
React.useEffect(() => {
|
||||
if (screen !== 'select-ollama-model') {
|
||||
return
|
||||
}
|
||||
|
||||
let cancelled = false
|
||||
setOllamaSelection({ state: 'loading' })
|
||||
|
||||
void (async () => {
|
||||
const available = await hasLocalOllama(draft.baseUrl)
|
||||
if (!available) {
|
||||
if (!cancelled) {
|
||||
setOllamaSelection({
|
||||
state: 'unavailable',
|
||||
message:
|
||||
'Could not reach Ollama. Start Ollama first, or enter the endpoint manually.',
|
||||
})
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
const models = await listOllamaModels(draft.baseUrl)
|
||||
if (models.length === 0) {
|
||||
if (!cancelled) {
|
||||
setOllamaSelection({
|
||||
state: 'unavailable',
|
||||
message:
|
||||
'Ollama is running, but no installed models were found. Pull a chat model such as qwen2.5-coder:7b or llama3.1:8b first, or enter details manually.',
|
||||
})
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
const ranked = rankOllamaModels(models, 'balanced')
|
||||
const recommended = recommendOllamaModel(models, 'balanced')
|
||||
if (!cancelled) {
|
||||
setOllamaSelection({
|
||||
state: 'ready',
|
||||
defaultValue: recommended?.name ?? ranked[0]?.name,
|
||||
options: ranked.map(model => ({
|
||||
label: model.name,
|
||||
value: model.name,
|
||||
description: model.summary,
|
||||
})),
|
||||
})
|
||||
}
|
||||
})()
|
||||
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [draft.baseUrl, screen])
|
||||
|
||||
function startCreateFromPreset(preset: ProviderPreset): void {
|
||||
const defaults = getProviderPresetDefaults(preset)
|
||||
const nextDraft = {
|
||||
@@ -557,7 +835,7 @@ export function ProviderManager({ mode, onDone }: Props): React.ReactNode {
|
||||
description: 'Choose another provider preset',
|
||||
},
|
||||
]}
|
||||
onChange={value => {
|
||||
onChange={(value: string) => {
|
||||
if (value === 'manual') {
|
||||
setFormStepIndex(0)
|
||||
setCursorOffset(draft.name.length)
|
||||
@@ -588,7 +866,7 @@ export function ProviderManager({ mode, onDone }: Props): React.ReactNode {
|
||||
defaultFocusValue={ollamaSelection.defaultValue}
|
||||
inlineDescriptions
|
||||
visibleOptionCount={Math.min(8, ollamaSelection.options.length)}
|
||||
onChange={value => {
|
||||
onChange={(value: string) => {
|
||||
const nextDraft = {
|
||||
...draft,
|
||||
model: value,
|
||||
@@ -654,6 +932,7 @@ export function ProviderManager({ mode, onDone }: Props): React.ReactNode {
|
||||
})
|
||||
|
||||
function renderPresetSelection(): React.ReactNode {
|
||||
const canUseCodexOAuth = !isBareMode()
|
||||
const options = [
|
||||
{
|
||||
value: 'anthropic',
|
||||
@@ -670,6 +949,16 @@ export function ProviderManager({ mode, onDone }: Props): React.ReactNode {
|
||||
label: 'OpenAI',
|
||||
description: 'OpenAI API with API key',
|
||||
},
|
||||
...(canUseCodexOAuth
|
||||
? [
|
||||
{
|
||||
value: 'codex-oauth',
|
||||
label: 'Codex OAuth',
|
||||
description:
|
||||
'Sign in with ChatGPT in your browser and store Codex credentials securely',
|
||||
},
|
||||
]
|
||||
: []),
|
||||
{
|
||||
value: 'moonshotai',
|
||||
label: 'Moonshot AI',
|
||||
@@ -741,11 +1030,15 @@ export function ProviderManager({ mode, onDone }: Props): React.ReactNode {
|
||||
</Text>
|
||||
<Select
|
||||
options={options}
|
||||
onChange={value => {
|
||||
onChange={(value: string) => {
|
||||
if (value === 'skip') {
|
||||
closeWithCancelled('Provider setup skipped')
|
||||
return
|
||||
}
|
||||
if (value === 'codex-oauth') {
|
||||
setScreen('codex-oauth')
|
||||
return
|
||||
}
|
||||
startCreateFromPreset(value as ProviderPreset)
|
||||
}}
|
||||
onCancel={() => {
|
||||
@@ -755,7 +1048,7 @@ export function ProviderManager({ mode, onDone }: Props): React.ReactNode {
|
||||
}
|
||||
setScreen('menu')
|
||||
}}
|
||||
visibleOptionCount={Math.min(12, options.length)}
|
||||
visibleOptionCount={Math.min(13, options.length)}
|
||||
/>
|
||||
</Box>
|
||||
)
|
||||
@@ -832,6 +1125,15 @@ export function ProviderManager({ mode, onDone }: Props): React.ReactNode {
|
||||
description: 'Remove a provider profile',
|
||||
disabled: !hasSelectableProviders,
|
||||
},
|
||||
...(hasStoredCodexOAuthCredentials
|
||||
? [
|
||||
{
|
||||
value: 'logout-codex-oauth',
|
||||
label: 'Log out Codex OAuth',
|
||||
description: 'Clear securely stored Codex OAuth credentials',
|
||||
},
|
||||
]
|
||||
: []),
|
||||
{
|
||||
value: 'done',
|
||||
label: 'Done',
|
||||
@@ -876,7 +1178,7 @@ export function ProviderManager({ mode, onDone }: Props): React.ReactNode {
|
||||
</Box>
|
||||
<Select
|
||||
options={options}
|
||||
onChange={value => {
|
||||
onChange={(value: string) => {
|
||||
setErrorMessage(undefined)
|
||||
switch (value) {
|
||||
case 'add':
|
||||
@@ -897,6 +1199,47 @@ export function ProviderManager({ mode, onDone }: Props): React.ReactNode {
|
||||
setScreen('select-delete')
|
||||
}
|
||||
break
|
||||
case 'logout-codex-oauth': {
|
||||
const cleared = clearCodexCredentials()
|
||||
if (!cleared.success) {
|
||||
setErrorMessage(
|
||||
cleared.warning ??
|
||||
'Could not clear Codex OAuth credentials.',
|
||||
)
|
||||
break
|
||||
}
|
||||
|
||||
setHasStoredCodexOAuthCredentials(false)
|
||||
setStoredCodexOAuthProfileId(undefined)
|
||||
const codexProfile = findCodexOAuthProfile(
|
||||
getProviderProfiles(),
|
||||
storedCodexOAuthProfileId,
|
||||
)
|
||||
let settingsOverrideError: string | null = null
|
||||
if (codexProfile) {
|
||||
const result = deleteProviderProfile(codexProfile.id)
|
||||
if (!result.removed) {
|
||||
setErrorMessage(
|
||||
'Codex OAuth credentials were cleared, but the Codex profile could not be removed.',
|
||||
)
|
||||
refreshProfiles()
|
||||
break
|
||||
}
|
||||
|
||||
clearPersistedCodexOAuthProfile()
|
||||
settingsOverrideError = result.activeProfileId
|
||||
? clearStartupProviderOverrideFromUserSettings()
|
||||
: null
|
||||
}
|
||||
|
||||
refreshProfiles()
|
||||
setStatusMessage(
|
||||
settingsOverrideError
|
||||
? `Codex OAuth logged out. Warning: could not clear startup provider override (${settingsOverrideError}).`
|
||||
: 'Codex OAuth logged out.',
|
||||
)
|
||||
break
|
||||
}
|
||||
default:
|
||||
closeWithCancelled('Provider manager closed')
|
||||
break
|
||||
@@ -975,51 +1318,100 @@ export function ProviderManager({ mode, onDone }: Props): React.ReactNode {
|
||||
|
||||
let content: React.ReactNode
|
||||
|
||||
switch (screen) {
|
||||
case 'select-preset':
|
||||
content = renderPresetSelection()
|
||||
break
|
||||
case 'select-ollama-model':
|
||||
content = renderOllamaSelection()
|
||||
break
|
||||
case 'form':
|
||||
content = renderForm()
|
||||
break
|
||||
switch (screen) {
|
||||
case 'select-preset':
|
||||
content = renderPresetSelection()
|
||||
break
|
||||
case 'select-ollama-model':
|
||||
content = renderOllamaSelection()
|
||||
break
|
||||
case 'codex-oauth':
|
||||
content = (
|
||||
<CodexOAuthSetup
|
||||
onBack={() => setScreen('select-preset')}
|
||||
onConfigured={async (tokens, persistCredentials) => {
|
||||
const payload: ProviderProfileInput = {
|
||||
provider: 'openai',
|
||||
name: CODEX_OAUTH_PROVIDER_NAME,
|
||||
baseUrl: DEFAULT_CODEX_BASE_URL,
|
||||
model: CODEX_OAUTH_PROVIDER_MODEL,
|
||||
apiKey: '',
|
||||
}
|
||||
|
||||
const existing = findCodexOAuthProfile(
|
||||
getProviderProfiles(),
|
||||
storedCodexOAuthProfileId,
|
||||
)
|
||||
const saved = existing
|
||||
? updateProviderProfile(existing.id, payload)
|
||||
: addProviderProfile(payload, { makeActive: true })
|
||||
|
||||
if (!saved) {
|
||||
setErrorMessage(
|
||||
'Codex OAuth login finished, but the provider profile could not be saved.',
|
||||
)
|
||||
setScreen('menu')
|
||||
return
|
||||
}
|
||||
|
||||
const active =
|
||||
existing && activeProfileId !== saved.id
|
||||
? setActiveProviderProfile(saved.id)
|
||||
: saved
|
||||
if (!active) {
|
||||
setErrorMessage(
|
||||
'Codex OAuth login finished, but the provider could not be set as the startup provider.',
|
||||
)
|
||||
setScreen('menu')
|
||||
return
|
||||
}
|
||||
|
||||
persistCredentials({ profileId: saved.id })
|
||||
const settingsOverrideError =
|
||||
clearStartupProviderOverrideFromUserSettings()
|
||||
const activationWarning = await activateCodexOAuthSession(tokens)
|
||||
setHasStoredCodexOAuthCredentials(true)
|
||||
setStoredCodexOAuthProfileId(saved.id)
|
||||
refreshProfiles()
|
||||
const warnings = [
|
||||
activationWarning,
|
||||
settingsOverrideError
|
||||
? `could not clear startup provider override (${settingsOverrideError})`
|
||||
: null,
|
||||
].filter((warning): warning is string => Boolean(warning))
|
||||
const message = buildCodexOAuthActivationMessage({
|
||||
prefix: 'Codex OAuth configured',
|
||||
activationWarning,
|
||||
warnings,
|
||||
})
|
||||
|
||||
if (mode === 'first-run') {
|
||||
onDone({
|
||||
action: 'saved',
|
||||
activeProfileId: active.id,
|
||||
message,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
setStatusMessage(message)
|
||||
setErrorMessage(undefined)
|
||||
setScreen('menu')
|
||||
}}
|
||||
/>
|
||||
)
|
||||
break
|
||||
case 'form':
|
||||
content = renderForm()
|
||||
break
|
||||
case 'select-active':
|
||||
content = renderProfileSelection(
|
||||
'Set active provider',
|
||||
'No providers available. Add one first.',
|
||||
profileId => {
|
||||
if (profileId === GITHUB_PROVIDER_ID) {
|
||||
const githubError = activateGithubProvider()
|
||||
if (githubError) {
|
||||
setErrorMessage(`Could not activate GitHub provider: ${githubError}`)
|
||||
setScreen('menu')
|
||||
return
|
||||
}
|
||||
refreshProfiles()
|
||||
setStatusMessage(`Active provider: ${GITHUB_PROVIDER_LABEL}`)
|
||||
setScreen('menu')
|
||||
return
|
||||
}
|
||||
|
||||
const active = setActiveProviderProfile(profileId)
|
||||
if (!active) {
|
||||
setErrorMessage('Could not change active provider.')
|
||||
setScreen('menu')
|
||||
return
|
||||
}
|
||||
const settingsOverrideError =
|
||||
clearStartupProviderOverrideFromUserSettings()
|
||||
refreshProfiles()
|
||||
setStatusMessage(
|
||||
settingsOverrideError
|
||||
? `Active provider: ${active.name}. Warning: could not clear startup provider override (${settingsOverrideError}).`
|
||||
: `Active provider: ${active.name}`,
|
||||
)
|
||||
setScreen('menu')
|
||||
void activateSelectedProvider(profileId)
|
||||
},
|
||||
{ includeGithub: true },
|
||||
{ includeGithub: true },
|
||||
)
|
||||
break
|
||||
case 'select-edit':
|
||||
@@ -1048,10 +1440,27 @@ export function ProviderManager({ mode, onDone }: Props): React.ReactNode {
|
||||
return
|
||||
}
|
||||
|
||||
const deletedCodexOAuthProfile =
|
||||
findCodexOAuthProfile(
|
||||
profiles,
|
||||
storedCodexOAuthProfileId,
|
||||
)?.id === profileId
|
||||
const result = deleteProviderProfile(profileId)
|
||||
if (!result.removed) {
|
||||
setErrorMessage('Could not delete provider.')
|
||||
} else {
|
||||
if (deletedCodexOAuthProfile) {
|
||||
const cleared = clearCodexCredentials()
|
||||
if (!cleared.success) {
|
||||
setErrorMessage(
|
||||
cleared.warning ??
|
||||
'Provider deleted, but Codex OAuth credentials could not be cleared.',
|
||||
)
|
||||
} else {
|
||||
setStoredCodexOAuthProfileId(undefined)
|
||||
}
|
||||
clearPersistedCodexOAuthProfile()
|
||||
}
|
||||
const settingsOverrideError = result.activeProfileId
|
||||
? clearStartupProviderOverrideFromUserSettings()
|
||||
: null
|
||||
|
||||
220
src/components/useCodexOAuthFlow.test.tsx
Normal file
220
src/components/useCodexOAuthFlow.test.tsx
Normal file
@@ -0,0 +1,220 @@
|
||||
import { PassThrough } from 'node:stream'
|
||||
|
||||
import { afterEach, expect, mock, test } from 'bun:test'
|
||||
import React from 'react'
|
||||
|
||||
import { createRoot, Text } from '../ink.js'
|
||||
|
||||
const SYNC_START = '\x1B[?2026h'
|
||||
const SYNC_END = '\x1B[?2026l'
|
||||
|
||||
function createTestStreams(): {
|
||||
stdout: PassThrough
|
||||
stdin: PassThrough & {
|
||||
isTTY: boolean
|
||||
setRawMode: (mode: boolean) => void
|
||||
ref: () => void
|
||||
unref: () => void
|
||||
}
|
||||
getOutput: () => string
|
||||
} {
|
||||
let output = ''
|
||||
const stdout = new PassThrough()
|
||||
const stdin = new PassThrough() as PassThrough & {
|
||||
isTTY: boolean
|
||||
setRawMode: (mode: boolean) => void
|
||||
ref: () => void
|
||||
unref: () => void
|
||||
}
|
||||
|
||||
stdin.isTTY = true
|
||||
stdin.setRawMode = () => {}
|
||||
stdin.ref = () => {}
|
||||
stdin.unref = () => {}
|
||||
;(stdout as unknown as { columns: number }).columns = 120
|
||||
stdout.on('data', chunk => {
|
||||
output += chunk.toString()
|
||||
})
|
||||
|
||||
return {
|
||||
stdout,
|
||||
stdin,
|
||||
getOutput: () => output,
|
||||
}
|
||||
}
|
||||
|
||||
async function waitForCondition(
|
||||
predicate: () => boolean,
|
||||
options?: { timeoutMs?: number; intervalMs?: number },
|
||||
): Promise<void> {
|
||||
const timeoutMs = options?.timeoutMs ?? 5000
|
||||
const intervalMs = options?.intervalMs ?? 10
|
||||
const startedAt = Date.now()
|
||||
|
||||
while (Date.now() - startedAt < timeoutMs) {
|
||||
if (predicate()) {
|
||||
return
|
||||
}
|
||||
await Bun.sleep(intervalMs)
|
||||
}
|
||||
|
||||
throw new Error('Timed out waiting for useCodexOAuthFlow test condition')
|
||||
}
|
||||
|
||||
function extractLastFrame(output: string): string {
|
||||
let lastFrame: string | null = null
|
||||
let cursor = 0
|
||||
|
||||
while (cursor < output.length) {
|
||||
const start = output.indexOf(SYNC_START, cursor)
|
||||
if (start === -1) break
|
||||
|
||||
const contentStart = start + SYNC_START.length
|
||||
const end = output.indexOf(SYNC_END, contentStart)
|
||||
if (end === -1) break
|
||||
|
||||
const frame = output.slice(contentStart, end)
|
||||
if (frame.trim().length > 0) {
|
||||
lastFrame = frame
|
||||
}
|
||||
cursor = end + SYNC_END.length
|
||||
}
|
||||
|
||||
return lastFrame ?? output
|
||||
}
|
||||
|
||||
const TOKENS = {
|
||||
accessToken: 'oauth-access-token',
|
||||
refreshToken: 'oauth-refresh-token',
|
||||
accountId: 'acct_oauth',
|
||||
idToken: 'oauth-id-token',
|
||||
apiKey: 'oauth-api-key',
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
mock.restore()
|
||||
})
|
||||
|
||||
test('does not persist credentials when downstream setup rejects', async () => {
|
||||
const saveCodexCredentials = mock(() => ({ success: true }))
|
||||
const cleanup = mock(() => {})
|
||||
const onAuthenticated = mock(async () => {
|
||||
throw new Error('profile save failed')
|
||||
})
|
||||
const deps = {
|
||||
createOAuthService: () => ({
|
||||
async startOAuthFlow(
|
||||
onAuthorizationUrl: (authUrl: string) => void | Promise<void>,
|
||||
) {
|
||||
await onAuthorizationUrl('https://chatgpt.com/codex')
|
||||
return TOKENS
|
||||
},
|
||||
cleanup,
|
||||
}),
|
||||
openBrowser: async () => true,
|
||||
saveCodexCredentials,
|
||||
isBareMode: () => false,
|
||||
}
|
||||
|
||||
const { useCodexOAuthFlow } = await import(
|
||||
`./useCodexOAuthFlow.js?real-reject-${Date.now()}-${Math.random()}`
|
||||
)
|
||||
|
||||
function Harness(): React.ReactNode {
|
||||
const handleAuthenticated = React.useCallback(onAuthenticated, [onAuthenticated])
|
||||
const status = useCodexOAuthFlow({
|
||||
onAuthenticated: handleAuthenticated,
|
||||
deps,
|
||||
})
|
||||
|
||||
return <Text>{status.state === 'error' ? status.message : status.state}</Text>
|
||||
}
|
||||
|
||||
const streams = createTestStreams()
|
||||
const root = await createRoot({
|
||||
stdout: streams.stdout as unknown as NodeJS.WriteStream,
|
||||
stdin: streams.stdin as unknown as NodeJS.ReadStream,
|
||||
patchConsole: false,
|
||||
})
|
||||
root.render(<Harness />)
|
||||
|
||||
try {
|
||||
await waitForCondition(() => onAuthenticated.mock.calls.length === 1)
|
||||
await Bun.sleep(0)
|
||||
await Bun.sleep(0)
|
||||
expect(onAuthenticated).toHaveBeenCalled()
|
||||
expect(saveCodexCredentials).not.toHaveBeenCalled()
|
||||
} finally {
|
||||
root.unmount()
|
||||
streams.stdin.end()
|
||||
streams.stdout.end()
|
||||
await Bun.sleep(0)
|
||||
}
|
||||
})
|
||||
|
||||
test('persists credentials with profile linkage after downstream setup succeeds', async () => {
|
||||
const saveCodexCredentials = mock(() => ({ success: true }))
|
||||
const onAuthenticated = mock(
|
||||
async (
|
||||
_tokens: typeof TOKENS,
|
||||
persistCredentials: (options?: { profileId?: string }) => void,
|
||||
) => {
|
||||
persistCredentials({ profileId: 'profile_codex_oauth' })
|
||||
},
|
||||
)
|
||||
const cleanup = mock(() => {})
|
||||
const deps = {
|
||||
createOAuthService: () => ({
|
||||
async startOAuthFlow(
|
||||
onAuthorizationUrl: (authUrl: string) => void | Promise<void>,
|
||||
) {
|
||||
await onAuthorizationUrl('https://chatgpt.com/codex')
|
||||
return TOKENS
|
||||
},
|
||||
cleanup,
|
||||
}),
|
||||
openBrowser: async () => true,
|
||||
saveCodexCredentials,
|
||||
isBareMode: () => false,
|
||||
}
|
||||
|
||||
const { useCodexOAuthFlow } = await import(
|
||||
`./useCodexOAuthFlow.js?real-persist-${Date.now()}-${Math.random()}`
|
||||
)
|
||||
|
||||
function Harness(): React.ReactNode {
|
||||
const handleAuthenticated = React.useCallback(onAuthenticated, [onAuthenticated])
|
||||
useCodexOAuthFlow({
|
||||
onAuthenticated: handleAuthenticated,
|
||||
deps,
|
||||
})
|
||||
return <Text>waiting</Text>
|
||||
}
|
||||
|
||||
const streams = createTestStreams()
|
||||
const root = await createRoot({
|
||||
stdout: streams.stdout as unknown as NodeJS.WriteStream,
|
||||
stdin: streams.stdin as unknown as NodeJS.ReadStream,
|
||||
patchConsole: false,
|
||||
})
|
||||
root.render(<Harness />)
|
||||
|
||||
try {
|
||||
await waitForCondition(() => onAuthenticated.mock.calls.length === 1)
|
||||
await waitForCondition(() => saveCodexCredentials.mock.calls.length === 1)
|
||||
expect(onAuthenticated).toHaveBeenCalled()
|
||||
expect(saveCodexCredentials).toHaveBeenCalledWith({
|
||||
apiKey: TOKENS.apiKey,
|
||||
accessToken: TOKENS.accessToken,
|
||||
refreshToken: TOKENS.refreshToken,
|
||||
idToken: TOKENS.idToken,
|
||||
accountId: TOKENS.accountId,
|
||||
profileId: 'profile_codex_oauth',
|
||||
})
|
||||
} finally {
|
||||
root.unmount()
|
||||
streams.stdin.end()
|
||||
streams.stdout.end()
|
||||
await Bun.sleep(0)
|
||||
}
|
||||
})
|
||||
134
src/components/useCodexOAuthFlow.ts
Normal file
134
src/components/useCodexOAuthFlow.ts
Normal file
@@ -0,0 +1,134 @@
|
||||
import * as React from 'react'
|
||||
|
||||
import {
|
||||
CodexOAuthService,
|
||||
type CodexOAuthTokens,
|
||||
} from '../services/api/codexOAuth.js'
|
||||
import { openBrowser } from '../utils/browser.js'
|
||||
import { saveCodexCredentials } from '../utils/codexCredentials.js'
|
||||
import { isBareMode } from '../utils/envUtils.js'
|
||||
|
||||
export type CodexOAuthFlowStatus =
|
||||
| { state: 'starting' }
|
||||
| {
|
||||
state: 'waiting'
|
||||
authUrl: string
|
||||
browserOpened: boolean | null
|
||||
}
|
||||
| {
|
||||
state: 'error'
|
||||
message: string
|
||||
}
|
||||
|
||||
type PersistCodexOAuthCredentials = (options?: {
|
||||
profileId?: string
|
||||
}) => void
|
||||
|
||||
type CodexOAuthFlowDependencies = {
|
||||
createOAuthService?: () => Pick<
|
||||
CodexOAuthService,
|
||||
'startOAuthFlow' | 'cleanup'
|
||||
>
|
||||
openBrowser?: typeof openBrowser
|
||||
saveCodexCredentials?: typeof saveCodexCredentials
|
||||
isBareMode?: typeof isBareMode
|
||||
}
|
||||
|
||||
function createDefaultOAuthService(): Pick<
|
||||
CodexOAuthService,
|
||||
'startOAuthFlow' | 'cleanup'
|
||||
> {
|
||||
return new CodexOAuthService()
|
||||
}
|
||||
|
||||
export function useCodexOAuthFlow(options: {
|
||||
onAuthenticated: (
|
||||
tokens: CodexOAuthTokens,
|
||||
persistCredentials: PersistCodexOAuthCredentials,
|
||||
) => void | Promise<void>
|
||||
deps?: CodexOAuthFlowDependencies
|
||||
}): CodexOAuthFlowStatus {
|
||||
const { onAuthenticated } = options
|
||||
const createOAuthService =
|
||||
options.deps?.createOAuthService ?? createDefaultOAuthService
|
||||
const openBrowserFn = options.deps?.openBrowser ?? openBrowser
|
||||
const saveCredentials =
|
||||
options.deps?.saveCodexCredentials ?? saveCodexCredentials
|
||||
const isBareModeFn = options.deps?.isBareMode ?? isBareMode
|
||||
const [status, setStatus] = React.useState<CodexOAuthFlowStatus>({
|
||||
state: 'starting',
|
||||
})
|
||||
|
||||
React.useEffect(() => {
|
||||
if (isBareModeFn()) {
|
||||
setStatus({
|
||||
state: 'error',
|
||||
message:
|
||||
'Codex OAuth is unavailable in --bare because secure storage is disabled.',
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
let cancelled = false
|
||||
const oauthService = createOAuthService()
|
||||
|
||||
void oauthService
|
||||
.startOAuthFlow(async authUrl => {
|
||||
if (cancelled) return
|
||||
setStatus({
|
||||
state: 'waiting',
|
||||
authUrl,
|
||||
browserOpened: null,
|
||||
})
|
||||
const browserOpened = await openBrowserFn(authUrl)
|
||||
if (cancelled) return
|
||||
setStatus({
|
||||
state: 'waiting',
|
||||
authUrl,
|
||||
browserOpened,
|
||||
})
|
||||
})
|
||||
.then(async tokens => {
|
||||
if (cancelled) return
|
||||
|
||||
const persistCredentials: PersistCodexOAuthCredentials = options => {
|
||||
const saved = saveCredentials({
|
||||
apiKey: tokens.apiKey,
|
||||
accessToken: tokens.accessToken,
|
||||
refreshToken: tokens.refreshToken,
|
||||
idToken: tokens.idToken,
|
||||
accountId: tokens.accountId,
|
||||
profileId: options?.profileId,
|
||||
})
|
||||
if (!saved.success) {
|
||||
throw new Error(
|
||||
saved.warning ??
|
||||
'Codex OAuth succeeded, but credentials could not be saved securely.',
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
await onAuthenticated(tokens, persistCredentials)
|
||||
})
|
||||
.catch(error => {
|
||||
if (cancelled) return
|
||||
setStatus({
|
||||
state: 'error',
|
||||
message: error instanceof Error ? error.message : String(error),
|
||||
})
|
||||
})
|
||||
|
||||
return () => {
|
||||
cancelled = true
|
||||
oauthService.cleanup()
|
||||
}
|
||||
}, [
|
||||
createOAuthService,
|
||||
isBareModeFn,
|
||||
onAuthenticated,
|
||||
openBrowserFn,
|
||||
saveCredentials,
|
||||
])
|
||||
|
||||
return status
|
||||
}
|
||||
123
src/hooks/useApiKeyVerification.test.tsx
Normal file
123
src/hooks/useApiKeyVerification.test.tsx
Normal file
@@ -0,0 +1,123 @@
|
||||
import { PassThrough } from 'node:stream'
|
||||
|
||||
import { afterEach, expect, mock, test } from 'bun:test'
|
||||
import React from 'react'
|
||||
import { createRoot, Text } from '../ink.js'
|
||||
|
||||
type AuthState = {
|
||||
anthropicAuthEnabled: boolean
|
||||
claudeSubscriber: boolean
|
||||
key?: string
|
||||
source?: string
|
||||
}
|
||||
|
||||
function createTestStreams(): {
|
||||
stdout: PassThrough
|
||||
stdin: PassThrough & {
|
||||
isTTY: boolean
|
||||
setRawMode: (mode: boolean) => void
|
||||
ref: () => void
|
||||
unref: () => void
|
||||
}
|
||||
} {
|
||||
const stdout = new PassThrough()
|
||||
const stdin = new PassThrough() as PassThrough & {
|
||||
isTTY: boolean
|
||||
setRawMode: (mode: boolean) => void
|
||||
ref: () => void
|
||||
unref: () => void
|
||||
}
|
||||
|
||||
stdin.isTTY = true
|
||||
stdin.setRawMode = () => {}
|
||||
stdin.ref = () => {}
|
||||
stdin.unref = () => {}
|
||||
;(stdout as unknown as { columns: number }).columns = 120
|
||||
|
||||
return { stdout, stdin }
|
||||
}
|
||||
|
||||
async function waitForCondition(
|
||||
predicate: () => boolean,
|
||||
timeoutMs = 2000,
|
||||
): Promise<void> {
|
||||
const startedAt = Date.now()
|
||||
|
||||
while (Date.now() - startedAt < timeoutMs) {
|
||||
if (predicate()) {
|
||||
return
|
||||
}
|
||||
await Bun.sleep(10)
|
||||
}
|
||||
|
||||
throw new Error('Timed out waiting for useApiKeyVerification test state')
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
mock.restore()
|
||||
})
|
||||
|
||||
test('useApiKeyVerification resets stale missing status when the session switches to a third-party provider', async () => {
|
||||
const authState: AuthState = {
|
||||
anthropicAuthEnabled: true,
|
||||
claudeSubscriber: false,
|
||||
}
|
||||
const seenStatuses: string[] = []
|
||||
|
||||
mock.module('../utils/auth.js', () => ({
|
||||
getAnthropicApiKeyWithSource: () => ({
|
||||
key: authState.key,
|
||||
source: authState.source,
|
||||
}),
|
||||
getApiKeyFromApiKeyHelper: async () => undefined,
|
||||
isAnthropicAuthEnabled: () => authState.anthropicAuthEnabled,
|
||||
isClaudeAISubscriber: () => authState.claudeSubscriber,
|
||||
}))
|
||||
|
||||
mock.module('../bootstrap/state.js', () => ({
|
||||
getIsNonInteractiveSession: () => false,
|
||||
}))
|
||||
|
||||
mock.module('../services/api/claude.js', () => ({
|
||||
verifyApiKey: async () => true,
|
||||
}))
|
||||
|
||||
// @ts-expect-error cache-busting query string for Bun module mocks
|
||||
const { useApiKeyVerification } = await import(
|
||||
'./useApiKeyVerification.ts?switch-to-third-party'
|
||||
)
|
||||
|
||||
function Harness(): React.ReactNode {
|
||||
const { status } = useApiKeyVerification()
|
||||
|
||||
React.useEffect(() => {
|
||||
seenStatuses.push(status)
|
||||
}, [status])
|
||||
|
||||
return <Text>{status}</Text>
|
||||
}
|
||||
|
||||
const { stdout, stdin } = createTestStreams()
|
||||
const root = await createRoot({
|
||||
stdout: stdout as unknown as NodeJS.WriteStream,
|
||||
stdin: stdin as unknown as NodeJS.ReadStream,
|
||||
patchConsole: false,
|
||||
})
|
||||
|
||||
root.render(<Harness />)
|
||||
|
||||
await waitForCondition(() => seenStatuses.includes('missing'))
|
||||
|
||||
authState.anthropicAuthEnabled = false
|
||||
root.render(<Harness />)
|
||||
|
||||
await waitForCondition(() => seenStatuses.includes('valid'))
|
||||
|
||||
root.unmount()
|
||||
stdin.end()
|
||||
stdout.end()
|
||||
await Bun.sleep(0)
|
||||
|
||||
expect(seenStatuses[0]).toBe('missing')
|
||||
expect(seenStatuses).toContain('valid')
|
||||
})
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useCallback, useState } from 'react'
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { getIsNonInteractiveSession } from '../bootstrap/state.js'
|
||||
import { verifyApiKey } from '../services/api/claude.js'
|
||||
import {
|
||||
@@ -21,24 +21,43 @@ export type ApiKeyVerificationResult = {
|
||||
error: Error | null
|
||||
}
|
||||
|
||||
export function useApiKeyVerification(): ApiKeyVerificationResult {
|
||||
const [status, setStatus] = useState<VerificationStatus>(() => {
|
||||
if (!isAnthropicAuthEnabled() || isClaudeAISubscriber()) {
|
||||
return 'valid'
|
||||
}
|
||||
// Use skipRetrievingKeyFromApiKeyHelper to avoid executing apiKeyHelper
|
||||
// before trust dialog is shown (security: prevents RCE via settings.json)
|
||||
const { key, source } = getAnthropicApiKeyWithSource({
|
||||
skipRetrievingKeyFromApiKeyHelper: true,
|
||||
})
|
||||
// If apiKeyHelper is configured, we have a key source even though we
|
||||
// haven't executed it yet - return 'loading' to indicate we'll verify later
|
||||
if (key || source === 'apiKeyHelper') {
|
||||
return 'loading'
|
||||
}
|
||||
return 'missing'
|
||||
function getInitialVerificationStatus(): VerificationStatus {
|
||||
if (!isAnthropicAuthEnabled() || isClaudeAISubscriber()) {
|
||||
return 'valid'
|
||||
}
|
||||
// Use skipRetrievingKeyFromApiKeyHelper to avoid executing apiKeyHelper
|
||||
// before trust dialog is shown (security: prevents RCE via settings.json)
|
||||
const { key, source } = getAnthropicApiKeyWithSource({
|
||||
skipRetrievingKeyFromApiKeyHelper: true,
|
||||
})
|
||||
// If apiKeyHelper is configured, we have a key source even though we
|
||||
// haven't executed it yet - return 'loading' to indicate we'll verify later
|
||||
if (key || source === 'apiKeyHelper') {
|
||||
return 'loading'
|
||||
}
|
||||
return 'missing'
|
||||
}
|
||||
|
||||
export function useApiKeyVerification(): ApiKeyVerificationResult {
|
||||
const [status, setStatus] = useState<VerificationStatus>(
|
||||
getInitialVerificationStatus,
|
||||
)
|
||||
const [error, setError] = useState<Error | null>(null)
|
||||
const anthropicVerificationEnabled =
|
||||
isAnthropicAuthEnabled() && !isClaudeAISubscriber()
|
||||
|
||||
useEffect(() => {
|
||||
const nextStatus = anthropicVerificationEnabled
|
||||
? getInitialVerificationStatus()
|
||||
: 'valid'
|
||||
|
||||
setStatus(currentStatus =>
|
||||
currentStatus === nextStatus ? currentStatus : nextStatus,
|
||||
)
|
||||
if (nextStatus !== 'error') {
|
||||
setError(null)
|
||||
}
|
||||
}, [anthropicVerificationEnabled])
|
||||
|
||||
const verify = useCallback(async (): Promise<void> => {
|
||||
if (!isAnthropicAuthEnabled() || isClaudeAISubscriber()) {
|
||||
|
||||
166
src/services/api/codexOAuth.test.ts
Normal file
166
src/services/api/codexOAuth.test.ts
Normal file
@@ -0,0 +1,166 @@
|
||||
import { createServer } from 'node:http'
|
||||
|
||||
import { afterEach, expect, mock, test } from 'bun:test'
|
||||
|
||||
import { CodexOAuthService } from './codexOAuth.js'
|
||||
|
||||
const originalFetch = globalThis.fetch
|
||||
const originalCallbackPort = process.env.CODEX_OAUTH_CALLBACK_PORT
|
||||
const originalClientId = process.env.CODEX_OAUTH_CLIENT_ID
|
||||
|
||||
afterEach(() => {
|
||||
mock.restore()
|
||||
globalThis.fetch = originalFetch
|
||||
|
||||
if (originalCallbackPort === undefined) {
|
||||
delete process.env.CODEX_OAUTH_CALLBACK_PORT
|
||||
} else {
|
||||
process.env.CODEX_OAUTH_CALLBACK_PORT = originalCallbackPort
|
||||
}
|
||||
|
||||
if (originalClientId === undefined) {
|
||||
delete process.env.CODEX_OAUTH_CLIENT_ID
|
||||
} else {
|
||||
process.env.CODEX_OAUTH_CLIENT_ID = originalClientId
|
||||
}
|
||||
})
|
||||
|
||||
async function getFreePort(): Promise<number> {
|
||||
return await new Promise((resolve, reject) => {
|
||||
const server = createServer()
|
||||
|
||||
server.once('error', reject)
|
||||
server.listen(0, '127.0.0.1', () => {
|
||||
const address = server.address()
|
||||
if (!address || typeof address === 'string') {
|
||||
server.close(() => reject(new Error('Failed to allocate test port.')))
|
||||
return
|
||||
}
|
||||
|
||||
const { port } = address
|
||||
server.close(error => {
|
||||
if (error) {
|
||||
reject(error)
|
||||
return
|
||||
}
|
||||
resolve(port)
|
||||
})
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
function buildCallbackRequest(authUrl: string): string {
|
||||
const authorizeUrl = new URL(authUrl)
|
||||
const redirectUri = authorizeUrl.searchParams.get('redirect_uri')
|
||||
const state = authorizeUrl.searchParams.get('state')
|
||||
|
||||
if (!redirectUri || !state) {
|
||||
throw new Error('Codex OAuth test did not receive a valid authorization URL.')
|
||||
}
|
||||
|
||||
const callbackUrl = new URL(redirectUri)
|
||||
callbackUrl.searchParams.set('code', 'auth-code')
|
||||
callbackUrl.searchParams.set('state', state)
|
||||
return callbackUrl.toString()
|
||||
}
|
||||
|
||||
test('serves updated success copy after a successful Codex OAuth flow', async () => {
|
||||
const callbackPort = await getFreePort()
|
||||
process.env.CODEX_OAUTH_CALLBACK_PORT = String(callbackPort)
|
||||
process.env.CODEX_OAUTH_CLIENT_ID = 'test-client-id'
|
||||
|
||||
globalThis.fetch = mock(async (input, init) => {
|
||||
const url = String(input)
|
||||
if (url.startsWith('http://localhost:')) {
|
||||
return originalFetch(input, init)
|
||||
}
|
||||
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
access_token: 'access-token',
|
||||
refresh_token: 'refresh-token',
|
||||
}),
|
||||
{
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
},
|
||||
)
|
||||
}) as typeof fetch
|
||||
|
||||
const service = new CodexOAuthService()
|
||||
let callbackResponsePromise!: Promise<Response>
|
||||
|
||||
const flowPromise = service.startOAuthFlow(async authUrl => {
|
||||
callbackResponsePromise = originalFetch(buildCallbackRequest(authUrl))
|
||||
})
|
||||
|
||||
const tokens = await flowPromise
|
||||
const callbackResponse = await callbackResponsePromise
|
||||
const html = await callbackResponse.text()
|
||||
|
||||
expect(tokens.accessToken).toBe('access-token')
|
||||
expect(tokens.refreshToken).toBe('refresh-token')
|
||||
expect(html).toContain('You can return to OpenClaude now.')
|
||||
expect(html).toContain(
|
||||
'OpenClaude will finish activating your new Codex OAuth login.',
|
||||
)
|
||||
expect(html).not.toContain('continue automatically')
|
||||
})
|
||||
|
||||
test('cancellation during token exchange returns a cancelled page and rejects the flow', async () => {
|
||||
const callbackPort = await getFreePort()
|
||||
process.env.CODEX_OAUTH_CALLBACK_PORT = String(callbackPort)
|
||||
process.env.CODEX_OAUTH_CLIENT_ID = 'test-client-id'
|
||||
|
||||
let resolveFetchStart!: () => void
|
||||
const fetchStarted = new Promise<void>(resolve => {
|
||||
resolveFetchStart = resolve
|
||||
})
|
||||
|
||||
globalThis.fetch = mock((input, init) => {
|
||||
const url = String(input)
|
||||
if (url.startsWith('http://localhost:')) {
|
||||
return originalFetch(input, init)
|
||||
}
|
||||
|
||||
return new Promise<Response>((_resolve, reject) => {
|
||||
resolveFetchStart()
|
||||
|
||||
const signal = init?.signal
|
||||
if (!signal) {
|
||||
return
|
||||
}
|
||||
|
||||
if (signal.aborted) {
|
||||
reject(signal.reason)
|
||||
return
|
||||
}
|
||||
|
||||
signal.addEventListener(
|
||||
'abort',
|
||||
() => {
|
||||
reject(signal.reason)
|
||||
},
|
||||
{ once: true },
|
||||
)
|
||||
})
|
||||
}) as typeof fetch
|
||||
|
||||
const service = new CodexOAuthService()
|
||||
let callbackResponsePromise!: Promise<Response>
|
||||
|
||||
const flowPromise = service.startOAuthFlow(async authUrl => {
|
||||
callbackResponsePromise = originalFetch(buildCallbackRequest(authUrl))
|
||||
})
|
||||
|
||||
await fetchStarted
|
||||
service.cleanup()
|
||||
|
||||
await expect(flowPromise).rejects.toThrow('Codex OAuth flow was cancelled.')
|
||||
|
||||
const callbackResponse = await callbackResponsePromise
|
||||
const html = await callbackResponse.text()
|
||||
|
||||
expect(html).toContain('Codex login cancelled')
|
||||
expect(html).toContain('retry in OpenClaude')
|
||||
})
|
||||
307
src/services/api/codexOAuth.ts
Normal file
307
src/services/api/codexOAuth.ts
Normal file
@@ -0,0 +1,307 @@
|
||||
import { AuthCodeListener } from '../oauth/auth-code-listener.js'
|
||||
import {
|
||||
generateCodeChallenge,
|
||||
generateCodeVerifier,
|
||||
generateState,
|
||||
} from '../oauth/crypto.js'
|
||||
import {
|
||||
asTrimmedString,
|
||||
CODEX_OAUTH_ISSUER,
|
||||
CODEX_OAUTH_ORIGINATOR,
|
||||
CODEX_OAUTH_SCOPE,
|
||||
escapeHtml,
|
||||
exchangeCodexIdTokenForApiKey,
|
||||
getCodexOAuthCallbackPort,
|
||||
getCodexOAuthClientId,
|
||||
parseChatgptAccountId,
|
||||
} from './codexOAuthShared.js'
|
||||
|
||||
type CodexOAuthTokenResponse = {
|
||||
id_token?: string
|
||||
access_token?: string
|
||||
refresh_token?: string
|
||||
}
|
||||
|
||||
export type CodexOAuthTokens = {
|
||||
apiKey?: string
|
||||
accessToken: string
|
||||
refreshToken: string
|
||||
idToken?: string
|
||||
accountId?: string
|
||||
}
|
||||
|
||||
function buildCodexAuthorizeUrl(options: {
|
||||
port: number
|
||||
codeChallenge: string
|
||||
state: string
|
||||
}): string {
|
||||
const redirectUri = `http://localhost:${options.port}/auth/callback`
|
||||
const authUrl = new URL(`${CODEX_OAUTH_ISSUER}/oauth/authorize`)
|
||||
|
||||
authUrl.searchParams.append('response_type', 'code')
|
||||
authUrl.searchParams.append('client_id', getCodexOAuthClientId())
|
||||
authUrl.searchParams.append('redirect_uri', redirectUri)
|
||||
authUrl.searchParams.append('scope', CODEX_OAUTH_SCOPE)
|
||||
authUrl.searchParams.append('code_challenge', options.codeChallenge)
|
||||
authUrl.searchParams.append('code_challenge_method', 'S256')
|
||||
authUrl.searchParams.append('id_token_add_organizations', 'true')
|
||||
authUrl.searchParams.append('codex_cli_simplified_flow', 'true')
|
||||
authUrl.searchParams.append('state', options.state)
|
||||
authUrl.searchParams.append('originator', CODEX_OAUTH_ORIGINATOR)
|
||||
|
||||
return authUrl.toString()
|
||||
}
|
||||
|
||||
function renderSuccessPage(): string {
|
||||
return `<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<title>Codex Login Complete</title>
|
||||
<style>
|
||||
body { font-family: sans-serif; padding: 32px; line-height: 1.5; color: #111827; }
|
||||
h1 { margin: 0 0 12px; font-size: 22px; }
|
||||
p { margin: 0 0 10px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>Codex login complete</h1>
|
||||
<p>You can return to OpenClaude now.</p>
|
||||
<p>OpenClaude will finish activating your new Codex OAuth login.</p>
|
||||
</body>
|
||||
</html>`
|
||||
}
|
||||
|
||||
function renderErrorPage(message: string): string {
|
||||
const safeMessage = escapeHtml(message)
|
||||
return `<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<title>Codex Login Failed</title>
|
||||
<style>
|
||||
body { font-family: sans-serif; padding: 32px; line-height: 1.5; color: #111827; }
|
||||
h1 { margin: 0 0 12px; font-size: 22px; color: #991b1b; }
|
||||
p { margin: 0 0 10px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>Codex login failed</h1>
|
||||
<p>${safeMessage}</p>
|
||||
<p>You can close this window and try again in OpenClaude.</p>
|
||||
</body>
|
||||
</html>`
|
||||
}
|
||||
|
||||
function renderCancelledPage(): string {
|
||||
return `<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<title>Codex Login Cancelled</title>
|
||||
<style>
|
||||
body { font-family: sans-serif; padding: 32px; line-height: 1.5; color: #111827; }
|
||||
h1 { margin: 0 0 12px; font-size: 22px; }
|
||||
p { margin: 0 0 10px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>Codex login cancelled</h1>
|
||||
<p>You can close this window and retry in OpenClaude.</p>
|
||||
</body>
|
||||
</html>`
|
||||
}
|
||||
|
||||
async function exchangeAuthorizationCode(options: {
|
||||
authorizationCode: string
|
||||
codeVerifier: string
|
||||
port: number
|
||||
signal?: AbortSignal
|
||||
}): Promise<CodexOAuthTokens> {
|
||||
const redirectUri = `http://localhost:${options.port}/auth/callback`
|
||||
const body = new URLSearchParams({
|
||||
grant_type: 'authorization_code',
|
||||
code: options.authorizationCode,
|
||||
redirect_uri: redirectUri,
|
||||
client_id: getCodexOAuthClientId(),
|
||||
code_verifier: options.codeVerifier,
|
||||
})
|
||||
|
||||
const response = await fetch(`${CODEX_OAUTH_ISSUER}/oauth/token`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
},
|
||||
body,
|
||||
signal: options.signal
|
||||
? AbortSignal.any([options.signal, AbortSignal.timeout(15_000)])
|
||||
: AbortSignal.timeout(15_000),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text().catch(() => '')
|
||||
throw new Error(
|
||||
errorText.trim()
|
||||
? `Codex OAuth token exchange failed (${response.status}): ${errorText.trim()}`
|
||||
: `Codex OAuth token exchange failed with status ${response.status}.`,
|
||||
)
|
||||
}
|
||||
|
||||
const payload = (await response.json()) as CodexOAuthTokenResponse
|
||||
const accessToken = asTrimmedString(payload.access_token)
|
||||
const refreshToken = asTrimmedString(payload.refresh_token)
|
||||
if (!accessToken || !refreshToken) {
|
||||
throw new Error(
|
||||
'Codex OAuth completed, but the token response was missing credentials.',
|
||||
)
|
||||
}
|
||||
|
||||
const idToken = asTrimmedString(payload.id_token)
|
||||
const apiKey = idToken
|
||||
? await exchangeCodexIdTokenForApiKey(idToken).catch(() => undefined)
|
||||
: undefined
|
||||
|
||||
return {
|
||||
apiKey,
|
||||
accessToken,
|
||||
refreshToken,
|
||||
idToken,
|
||||
accountId:
|
||||
parseChatgptAccountId(idToken) ?? parseChatgptAccountId(accessToken),
|
||||
}
|
||||
}
|
||||
|
||||
export class CodexOAuthService {
|
||||
private authCodeListener: AuthCodeListener | null = null
|
||||
private port: number | null = null
|
||||
private tokenExchangeAbortController: AbortController | null = null
|
||||
|
||||
private buildCancellationError(): Error {
|
||||
return new Error('Codex OAuth flow was cancelled.')
|
||||
}
|
||||
|
||||
async startOAuthFlow(
|
||||
authURLHandler: (authUrl: string) => Promise<void>,
|
||||
): Promise<CodexOAuthTokens> {
|
||||
const codeVerifier = generateCodeVerifier()
|
||||
const callbackPort = getCodexOAuthCallbackPort()
|
||||
const authCodeListener = new AuthCodeListener('/auth/callback')
|
||||
|
||||
this.authCodeListener = authCodeListener
|
||||
this.port = null
|
||||
|
||||
try {
|
||||
const port = await authCodeListener.start(callbackPort)
|
||||
this.port = port
|
||||
|
||||
const state = generateState()
|
||||
const codeChallenge = await generateCodeChallenge(codeVerifier)
|
||||
const authUrl = buildCodexAuthorizeUrl({
|
||||
port,
|
||||
codeChallenge,
|
||||
state,
|
||||
})
|
||||
|
||||
try {
|
||||
const authorizationCode = await authCodeListener.waitForAuthorization(
|
||||
state,
|
||||
async () => {
|
||||
await authURLHandler(authUrl)
|
||||
},
|
||||
)
|
||||
|
||||
const tokenExchangeAbortController = new AbortController()
|
||||
this.tokenExchangeAbortController = tokenExchangeAbortController
|
||||
|
||||
let tokens: CodexOAuthTokens
|
||||
try {
|
||||
tokens = await exchangeAuthorizationCode({
|
||||
authorizationCode,
|
||||
codeVerifier,
|
||||
port,
|
||||
signal: tokenExchangeAbortController.signal,
|
||||
})
|
||||
} finally {
|
||||
if (
|
||||
this.tokenExchangeAbortController === tokenExchangeAbortController
|
||||
) {
|
||||
this.tokenExchangeAbortController = null
|
||||
}
|
||||
}
|
||||
|
||||
if (this.authCodeListener !== authCodeListener) {
|
||||
throw this.buildCancellationError()
|
||||
}
|
||||
|
||||
authCodeListener.handleSuccessRedirect([], res => {
|
||||
res.writeHead(200, {
|
||||
'Content-Type': 'text/html; charset=utf-8',
|
||||
})
|
||||
res.end(renderSuccessPage())
|
||||
})
|
||||
|
||||
return tokens
|
||||
} catch (error) {
|
||||
const resolvedError =
|
||||
this.authCodeListener === authCodeListener
|
||||
? error
|
||||
: this.buildCancellationError()
|
||||
|
||||
if (authCodeListener.hasPendingResponse()) {
|
||||
const isCancellation =
|
||||
resolvedError instanceof Error &&
|
||||
resolvedError.message === 'Codex OAuth flow was cancelled.'
|
||||
|
||||
authCodeListener.handleErrorRedirect(res => {
|
||||
res.writeHead(isCancellation ? 200 : 400, {
|
||||
'Content-Type': 'text/html; charset=utf-8',
|
||||
})
|
||||
res.end(
|
||||
isCancellation
|
||||
? renderCancelledPage()
|
||||
: renderErrorPage(
|
||||
resolvedError instanceof Error
|
||||
? resolvedError.message
|
||||
: String(resolvedError),
|
||||
),
|
||||
)
|
||||
})
|
||||
}
|
||||
throw resolvedError
|
||||
} finally {
|
||||
this.cleanup()
|
||||
}
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error)
|
||||
if (
|
||||
message.includes('EADDRINUSE') ||
|
||||
message.includes(String(callbackPort))
|
||||
) {
|
||||
throw new Error(
|
||||
`Codex OAuth needs localhost:${callbackPort} for its callback. Close any app already using that port and try again.`,
|
||||
)
|
||||
}
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
cleanup(): void {
|
||||
const cancellationError = this.buildCancellationError()
|
||||
|
||||
this.tokenExchangeAbortController?.abort(cancellationError)
|
||||
this.tokenExchangeAbortController = null
|
||||
|
||||
if (this.authCodeListener?.hasPendingResponse()) {
|
||||
this.authCodeListener.handleErrorRedirect(res => {
|
||||
res.writeHead(200, {
|
||||
'Content-Type': 'text/html; charset=utf-8',
|
||||
})
|
||||
res.end(renderCancelledPage())
|
||||
})
|
||||
}
|
||||
|
||||
this.authCodeListener?.cancelPendingAuthorization(cancellationError)
|
||||
this.authCodeListener = null
|
||||
this.port = null
|
||||
}
|
||||
}
|
||||
139
src/services/api/codexOAuthShared.ts
Normal file
139
src/services/api/codexOAuthShared.ts
Normal file
@@ -0,0 +1,139 @@
|
||||
export const CODEX_OAUTH_ISSUER = 'https://auth.openai.com'
|
||||
export const CODEX_REFRESH_URL = `${CODEX_OAUTH_ISSUER}/oauth/token`
|
||||
export const DEFAULT_CODEX_OAUTH_CLIENT_ID = 'app_EMoamEEZ73f0CkXaXp7hrann'
|
||||
export const DEFAULT_CODEX_OAUTH_CALLBACK_PORT = 1455
|
||||
export const CODEX_OAUTH_SCOPE =
|
||||
'openid profile email offline_access api.connectors.read api.connectors.invoke'
|
||||
export const CODEX_OAUTH_ORIGINATOR = 'codex_cli_rs'
|
||||
export const CODEX_API_KEY_TOKEN_NAME = 'openai-api-key'
|
||||
export const CODEX_ID_TOKEN_SUBJECT_TYPE =
|
||||
'urn:ietf:params:oauth:token-type:id_token'
|
||||
export const CODEX_TOKEN_EXCHANGE_GRANT =
|
||||
'urn:ietf:params:oauth:grant-type:token-exchange'
|
||||
|
||||
export function asTrimmedString(value: unknown): string | undefined {
|
||||
if (typeof value !== 'string') return undefined
|
||||
const trimmed = value.trim()
|
||||
return trimmed ? trimmed : undefined
|
||||
}
|
||||
|
||||
export function getCodexOAuthClientId(
|
||||
env: NodeJS.ProcessEnv = process.env,
|
||||
): string {
|
||||
return asTrimmedString(env.CODEX_OAUTH_CLIENT_ID) ?? DEFAULT_CODEX_OAUTH_CLIENT_ID
|
||||
}
|
||||
|
||||
export function getCodexOAuthCallbackPort(
|
||||
env: NodeJS.ProcessEnv = process.env,
|
||||
): number {
|
||||
const rawPort = asTrimmedString(env.CODEX_OAUTH_CALLBACK_PORT)
|
||||
if (!rawPort) {
|
||||
return DEFAULT_CODEX_OAUTH_CALLBACK_PORT
|
||||
}
|
||||
|
||||
const parsed = Number.parseInt(rawPort, 10)
|
||||
if (Number.isInteger(parsed) && parsed > 0 && parsed <= 65535) {
|
||||
return parsed
|
||||
}
|
||||
|
||||
return DEFAULT_CODEX_OAUTH_CALLBACK_PORT
|
||||
}
|
||||
|
||||
export function decodeJwtPayload(
|
||||
token: string,
|
||||
): Record<string, unknown> | undefined {
|
||||
const parts = token.split('.')
|
||||
if (parts.length < 2) return undefined
|
||||
|
||||
try {
|
||||
const normalized = parts[1].replace(/-/g, '+').replace(/_/g, '/')
|
||||
const padded = normalized + '='.repeat((4 - (normalized.length % 4)) % 4)
|
||||
const json = Buffer.from(padded, 'base64').toString('utf8')
|
||||
const parsed = JSON.parse(json)
|
||||
return parsed && typeof parsed === 'object'
|
||||
? (parsed as Record<string, unknown>)
|
||||
: undefined
|
||||
} catch {
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
export function parseChatgptAccountId(
|
||||
token: string | undefined,
|
||||
): string | undefined {
|
||||
if (!token) return undefined
|
||||
|
||||
const payload = decodeJwtPayload(token)
|
||||
const nestedAuth =
|
||||
payload?.['https://api.openai.com/auth'] &&
|
||||
typeof payload['https://api.openai.com/auth'] === 'object'
|
||||
? (payload['https://api.openai.com/auth'] as Record<string, unknown>)
|
||||
: undefined
|
||||
|
||||
return (
|
||||
asTrimmedString(
|
||||
nestedAuth?.chatgpt_account_id ??
|
||||
payload?.['https://api.openai.com/auth.chatgpt_account_id'] ??
|
||||
payload?.chatgpt_account_id,
|
||||
) ?? undefined
|
||||
)
|
||||
}
|
||||
|
||||
export function escapeHtml(value: string): string {
|
||||
return value.replace(/[&<>"']/g, char => {
|
||||
switch (char) {
|
||||
case '&':
|
||||
return '&'
|
||||
case '<':
|
||||
return '<'
|
||||
case '>':
|
||||
return '>'
|
||||
case '"':
|
||||
return '"'
|
||||
case '\'':
|
||||
return '''
|
||||
default:
|
||||
return char
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
export async function exchangeCodexIdTokenForApiKey(
|
||||
idToken: string,
|
||||
): Promise<string> {
|
||||
const body = new URLSearchParams({
|
||||
grant_type: CODEX_TOKEN_EXCHANGE_GRANT,
|
||||
client_id: getCodexOAuthClientId(),
|
||||
requested_token: CODEX_API_KEY_TOKEN_NAME,
|
||||
subject_token: idToken,
|
||||
subject_token_type: CODEX_ID_TOKEN_SUBJECT_TYPE,
|
||||
})
|
||||
|
||||
const response = await fetch(CODEX_REFRESH_URL, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
},
|
||||
body,
|
||||
signal: AbortSignal.timeout(15_000),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const bodyText = await response.text().catch(() => '')
|
||||
throw new Error(
|
||||
bodyText.trim()
|
||||
? `Codex API key exchange failed (${response.status}): ${bodyText.trim()}`
|
||||
: `Codex API key exchange failed with status ${response.status}.`,
|
||||
)
|
||||
}
|
||||
|
||||
const payload = (await response.json()) as { access_token?: string }
|
||||
const apiKey = asTrimmedString(payload.access_token)
|
||||
if (!apiKey) {
|
||||
throw new Error(
|
||||
'Codex API key exchange completed, but no API key token was returned.',
|
||||
)
|
||||
}
|
||||
|
||||
return apiKey
|
||||
}
|
||||
@@ -8,10 +8,6 @@ import {
|
||||
convertCodexResponseToAnthropicMessage,
|
||||
convertToolsToResponsesTools,
|
||||
} from './codexShim.js'
|
||||
import {
|
||||
resolveCodexApiCredentials,
|
||||
resolveProviderRequest,
|
||||
} from './providerConfig.js'
|
||||
|
||||
const tempDirs: string[] = []
|
||||
const originalEnv = {
|
||||
@@ -63,6 +59,10 @@ async function collectStreamEventTypes(responseText: string): Promise<string[]>
|
||||
return events
|
||||
}
|
||||
|
||||
async function importFreshProviderConfigModule() {
|
||||
return import(`./providerConfig.js?ts=${Date.now()}-${Math.random()}`)
|
||||
}
|
||||
|
||||
describe('Codex provider config', () => {
|
||||
const originalOpenaiBaseUrl = process.env.OPENAI_BASE_URL
|
||||
const originalOpenaiApiBase = process.env.OPENAI_API_BASE
|
||||
@@ -79,7 +79,8 @@ describe('Codex provider config', () => {
|
||||
else process.env.OPENAI_API_BASE = originalOpenaiApiBase
|
||||
})
|
||||
|
||||
test('resolves codexplan alias to Codex transport with reasoning', () => {
|
||||
test('resolves codexplan alias to Codex transport with reasoning', async () => {
|
||||
const { resolveProviderRequest } = await importFreshProviderConfigModule()
|
||||
delete process.env.OPENAI_BASE_URL
|
||||
delete process.env.OPENAI_API_BASE
|
||||
delete process.env.CLAUDE_CODE_USE_GITHUB
|
||||
@@ -91,7 +92,8 @@ describe('Codex provider config', () => {
|
||||
expect(resolved.baseUrl).toBe('https://chatgpt.com/backend-api/codex')
|
||||
})
|
||||
|
||||
test('resolves codexspark alias to Codex transport with Codex base URL', () => {
|
||||
test('resolves codexspark alias to Codex transport with Codex base URL', async () => {
|
||||
const { resolveProviderRequest } = await importFreshProviderConfigModule()
|
||||
delete process.env.OPENAI_BASE_URL
|
||||
delete process.env.OPENAI_API_BASE
|
||||
delete process.env.CLAUDE_CODE_USE_GITHUB
|
||||
@@ -102,7 +104,8 @@ describe('Codex provider config', () => {
|
||||
expect(resolved.baseUrl).toBe('https://chatgpt.com/backend-api/codex')
|
||||
})
|
||||
|
||||
test('does not force Codex transport when a local non-Codex base URL is explicit', () => {
|
||||
test('does not force Codex transport when a local non-Codex base URL is explicit', async () => {
|
||||
const { resolveProviderRequest } = await importFreshProviderConfigModule()
|
||||
const resolved = resolveProviderRequest({
|
||||
model: 'codexplan',
|
||||
baseUrl: 'http://127.0.0.1:8080/v1',
|
||||
@@ -113,7 +116,8 @@ describe('Codex provider config', () => {
|
||||
expect(resolved.resolvedModel).toBe('gpt-5.4')
|
||||
})
|
||||
|
||||
test('resolves codexplan to Codex transport even when OPENAI_BASE_URL is the string "undefined"', () => {
|
||||
test('resolves codexplan to Codex transport even when OPENAI_BASE_URL is the string "undefined"', async () => {
|
||||
const { resolveProviderRequest } = await importFreshProviderConfigModule()
|
||||
// On Windows, env vars can leak as the literal string "undefined" instead of
|
||||
// the JS value undefined when not properly unset (issue #336).
|
||||
process.env.OPENAI_BASE_URL = 'undefined'
|
||||
@@ -121,20 +125,23 @@ describe('Codex provider config', () => {
|
||||
expect(resolved.transport).toBe('codex_responses')
|
||||
})
|
||||
|
||||
test('resolves codexplan to Codex transport even when OPENAI_BASE_URL is an empty string', () => {
|
||||
test('resolves codexplan to Codex transport even when OPENAI_BASE_URL is an empty string', async () => {
|
||||
const { resolveProviderRequest } = await importFreshProviderConfigModule()
|
||||
process.env.OPENAI_BASE_URL = ''
|
||||
const resolved = resolveProviderRequest({ model: 'codexplan' })
|
||||
expect(resolved.transport).toBe('codex_responses')
|
||||
})
|
||||
|
||||
test('prefers explicit baseUrl option over env var', () => {
|
||||
test('prefers explicit baseUrl option over env var', async () => {
|
||||
const { resolveProviderRequest } = await importFreshProviderConfigModule()
|
||||
process.env.OPENAI_BASE_URL = 'https://example.com/v1'
|
||||
const resolved = resolveProviderRequest({ model: 'codexplan', baseUrl: 'https://chatgpt.com/backend-api/codex' })
|
||||
expect(resolved.transport).toBe('codex_responses')
|
||||
expect(resolved.baseUrl).toBe('https://chatgpt.com/backend-api/codex')
|
||||
})
|
||||
|
||||
test('default gpt-4o uses OpenAI base URL (no regression)', () => {
|
||||
test('default gpt-4o uses OpenAI base URL (no regression)', async () => {
|
||||
const { resolveProviderRequest } = await importFreshProviderConfigModule()
|
||||
delete process.env.OPENAI_BASE_URL
|
||||
delete process.env.CLAUDE_CODE_USE_GITHUB
|
||||
|
||||
@@ -144,7 +151,8 @@ describe('Codex provider config', () => {
|
||||
expect(resolved.resolvedModel).toBe('gpt-4o')
|
||||
})
|
||||
|
||||
test('resolves codexplan from env var OPENAI_MODEL to Codex endpoint', () => {
|
||||
test('resolves codexplan from env var OPENAI_MODEL to Codex endpoint', async () => {
|
||||
const { resolveProviderRequest } = await importFreshProviderConfigModule()
|
||||
process.env.OPENAI_MODEL = 'codexplan'
|
||||
delete process.env.OPENAI_BASE_URL
|
||||
delete process.env.CLAUDE_CODE_USE_GITHUB
|
||||
@@ -155,7 +163,8 @@ describe('Codex provider config', () => {
|
||||
expect(resolved.resolvedModel).toBe('gpt-5.4')
|
||||
})
|
||||
|
||||
test('does not override custom base URL for codexplan (e.g., local provider)', () => {
|
||||
test('does not override custom base URL for codexplan (e.g., local provider)', async () => {
|
||||
const { resolveProviderRequest } = await importFreshProviderConfigModule()
|
||||
process.env.OPENAI_MODEL = 'codexplan'
|
||||
process.env.OPENAI_BASE_URL = 'http://localhost:11434/v1'
|
||||
delete process.env.CLAUDE_CODE_USE_GITHUB
|
||||
@@ -165,7 +174,8 @@ describe('Codex provider config', () => {
|
||||
expect(resolved.baseUrl).toBe('http://localhost:11434/v1')
|
||||
})
|
||||
|
||||
test('loads Codex credentials from auth.json fallback', () => {
|
||||
test('loads Codex credentials from auth.json fallback', async () => {
|
||||
const { resolveCodexApiCredentials } = await importFreshProviderConfigModule()
|
||||
const authPath = createTempAuthJson({
|
||||
tokens: {
|
||||
access_token: 'header.payload.signature',
|
||||
@@ -181,6 +191,31 @@ describe('Codex provider config', () => {
|
||||
expect(credentials.accountId).toBe('acct_test')
|
||||
expect(credentials.source).toBe('auth.json')
|
||||
})
|
||||
|
||||
test('does not treat auth.json id_token as a Codex bearer credential', async () => {
|
||||
const { resolveCodexApiCredentials } = await importFreshProviderConfigModule()
|
||||
const idTokenPayload = Buffer.from(
|
||||
JSON.stringify({
|
||||
'https://api.openai.com/auth': {
|
||||
chatgpt_account_id: 'acct_from_id_token',
|
||||
},
|
||||
}),
|
||||
'utf8',
|
||||
).toString('base64url')
|
||||
const authPath = createTempAuthJson({
|
||||
tokens: {
|
||||
id_token: `header.${idTokenPayload}.signature`,
|
||||
},
|
||||
})
|
||||
|
||||
const credentials = resolveCodexApiCredentials({
|
||||
CODEX_AUTH_JSON_PATH: authPath,
|
||||
} as NodeJS.ProcessEnv)
|
||||
|
||||
expect(credentials.apiKey).toBe('')
|
||||
expect(credentials.accountId).toBe('acct_from_id_token')
|
||||
expect(credentials.source).toBe('none')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Codex request translation', () => {
|
||||
|
||||
@@ -1,7 +1,13 @@
|
||||
import {
|
||||
readCodexCredentialsAsync,
|
||||
refreshCodexAccessTokenIfNeeded,
|
||||
} from '../../utils/codexCredentials.js'
|
||||
import { logForDebugging } from '../../utils/debug.js'
|
||||
import { isBareMode } from '../../utils/envUtils.js'
|
||||
import {
|
||||
DEFAULT_CODEX_BASE_URL,
|
||||
isCodexBaseUrl,
|
||||
resolveCodexApiCredentials,
|
||||
resolveRuntimeCodexCredentials,
|
||||
resolveProviderRequest,
|
||||
} from './providerConfig.js'
|
||||
|
||||
@@ -391,6 +397,18 @@ export function getCodexUsageUrl(baseUrl = DEFAULT_CODEX_BASE_URL): string {
|
||||
}
|
||||
|
||||
export async function fetchCodexUsage(): Promise<CodexUsageData> {
|
||||
const refreshResult = await refreshCodexAccessTokenIfNeeded().catch(
|
||||
async error => {
|
||||
logForDebugging(
|
||||
`[codex] access token refresh failed before usage fetch: ${error instanceof Error ? error.message : String(error)}`,
|
||||
{ level: 'warn' },
|
||||
)
|
||||
return {
|
||||
refreshed: false,
|
||||
credentials: await readCodexCredentialsAsync(),
|
||||
}
|
||||
},
|
||||
)
|
||||
const request = resolveProviderRequest({
|
||||
model: process.env.OPENAI_MODEL,
|
||||
baseUrl: process.env.OPENAI_BASE_URL,
|
||||
@@ -401,16 +419,19 @@ export async function fetchCodexUsage(): Promise<CodexUsageData> {
|
||||
)
|
||||
}
|
||||
|
||||
const credentials = resolveCodexApiCredentials()
|
||||
const credentials = resolveRuntimeCodexCredentials({
|
||||
storedCredentials: refreshResult.credentials,
|
||||
})
|
||||
if (!credentials.apiKey) {
|
||||
const oauthHint = isBareMode() ? '' : ', choose Codex OAuth in /provider'
|
||||
const authHint = credentials.authPath
|
||||
? ` or place a Codex auth.json at ${credentials.authPath}`
|
||||
: ''
|
||||
? `${oauthHint} or place a Codex auth.json at ${credentials.authPath}`
|
||||
: oauthHint
|
||||
throw new Error(`Codex auth is required. Set CODEX_API_KEY${authHint}.`)
|
||||
}
|
||||
if (!credentials.accountId) {
|
||||
throw new Error(
|
||||
'Codex auth is missing chatgpt_account_id. Re-login with the Codex CLI or set CHATGPT_ACCOUNT_ID/CODEX_ACCOUNT_ID.',
|
||||
'Codex auth is missing chatgpt_account_id. Re-login with Codex OAuth, the Codex CLI, or set CHATGPT_ACCOUNT_ID/CODEX_ACCOUNT_ID.',
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -22,7 +22,12 @@
|
||||
*/
|
||||
|
||||
import { APIError } from '@anthropic-ai/sdk'
|
||||
import { isEnvTruthy } from '../../utils/envUtils.js'
|
||||
import {
|
||||
readCodexCredentialsAsync,
|
||||
refreshCodexAccessTokenIfNeeded,
|
||||
} from '../../utils/codexCredentials.js'
|
||||
import { logForDebugging } from '../../utils/debug.js'
|
||||
import { isBareMode, isEnvTruthy } from '../../utils/envUtils.js'
|
||||
import { resolveGeminiCredential } from '../../utils/geminiAuth.js'
|
||||
import { hydrateGeminiAccessTokenFromSecureStorage } from '../../utils/geminiCredentials.js'
|
||||
import { hydrateGithubModelsTokenFromSecureStorage } from '../../utils/githubModelsCredentials.js'
|
||||
@@ -44,7 +49,7 @@ import {
|
||||
} from './codexShim.js'
|
||||
import {
|
||||
isLocalProviderUrl,
|
||||
resolveCodexApiCredentials,
|
||||
resolveRuntimeCodexCredentials,
|
||||
resolveProviderRequest,
|
||||
getGithubEndpointType,
|
||||
} from './providerConfig.js'
|
||||
@@ -1139,7 +1144,6 @@ class OpenAIShimMessages {
|
||||
const githubEndpointType = getGithubEndpointType(request.baseUrl)
|
||||
const isGithubMode = isGithubModelsMode()
|
||||
const isGithubWithCodexTransport = isGithubMode && request.transport === 'codex_responses'
|
||||
const isGithubCopilotEndpoint = isGithubMode && githubEndpointType === 'copilot'
|
||||
|
||||
if (isGithubWithCodexTransport) {
|
||||
const apiKey = this.providerOverride?.apiKey ?? process.env.OPENAI_API_KEY ?? ''
|
||||
@@ -1166,11 +1170,26 @@ class OpenAIShimMessages {
|
||||
}
|
||||
|
||||
if (request.transport === 'codex_responses' && !isGithubMode) {
|
||||
const credentials = resolveCodexApiCredentials()
|
||||
const refreshResult = await refreshCodexAccessTokenIfNeeded().catch(
|
||||
async error => {
|
||||
logForDebugging(
|
||||
`[codex] access token refresh failed before request: ${error instanceof Error ? error.message : String(error)}`,
|
||||
{ level: 'warn' },
|
||||
)
|
||||
return {
|
||||
refreshed: false,
|
||||
credentials: await readCodexCredentialsAsync(),
|
||||
}
|
||||
},
|
||||
)
|
||||
const credentials = resolveRuntimeCodexCredentials({
|
||||
storedCredentials: refreshResult.credentials,
|
||||
})
|
||||
if (!credentials.apiKey) {
|
||||
const oauthHint = isBareMode() ? '' : ', choose Codex OAuth in /provider'
|
||||
const authHint = credentials.authPath
|
||||
? ` or place a Codex auth.json at ${credentials.authPath}`
|
||||
: ''
|
||||
? `${oauthHint} or place a Codex auth.json at ${credentials.authPath}`
|
||||
: oauthHint
|
||||
const safeModel =
|
||||
redactSecretValueForDisplay(request.requestedModel, process.env as SecretValueSource) ??
|
||||
'the requested model'
|
||||
@@ -1180,7 +1199,7 @@ class OpenAIShimMessages {
|
||||
}
|
||||
if (!credentials.accountId) {
|
||||
throw new Error(
|
||||
'Codex auth is missing chatgpt_account_id. Re-login with the Codex CLI or set CHATGPT_ACCOUNT_ID/CODEX_ACCOUNT_ID.',
|
||||
'Codex auth is missing chatgpt_account_id. Re-login with Codex OAuth, the Codex CLI, or set CHATGPT_ACCOUNT_ID/CODEX_ACCOUNT_ID.',
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
225
src/services/api/providerConfig.codexSecureStorage.test.ts
Normal file
225
src/services/api/providerConfig.codexSecureStorage.test.ts
Normal file
@@ -0,0 +1,225 @@
|
||||
import { afterEach, describe, expect, mock, test } from 'bun:test'
|
||||
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import * as realOs from 'node:os'
|
||||
|
||||
function makeJwt(payload: Record<string, unknown>): string {
|
||||
const header = Buffer.from(JSON.stringify({ alg: 'none', typ: 'JWT' }))
|
||||
.toString('base64url')
|
||||
const body = Buffer.from(JSON.stringify(payload)).toString('base64url')
|
||||
return `${header}.${body}.signature`
|
||||
}
|
||||
|
||||
describe('resolveCodexApiCredentials with secure storage', () => {
|
||||
afterEach(() => {
|
||||
mock.restore()
|
||||
})
|
||||
|
||||
test('loads Codex credentials from OpenClaude secure storage', async () => {
|
||||
mock.module('../../utils/codexCredentials.js', () => ({
|
||||
isCodexRefreshFailureCoolingDown: () => false,
|
||||
readCodexCredentials: () => ({
|
||||
apiKey: 'codex-api-key-token',
|
||||
accessToken: 'header.payload.signature',
|
||||
accountId: 'acct_secure',
|
||||
}),
|
||||
}))
|
||||
|
||||
// @ts-expect-error cache-busting query string for Bun module mocks
|
||||
const { resolveCodexApiCredentials } = await import(
|
||||
'./providerConfig.js?codex-secure-storage'
|
||||
)
|
||||
|
||||
const credentials = resolveCodexApiCredentials({} as NodeJS.ProcessEnv)
|
||||
expect(credentials.apiKey).toBe('codex-api-key-token')
|
||||
expect(credentials.accountId).toBe('acct_secure')
|
||||
expect(credentials.source).toBe('secure-storage')
|
||||
})
|
||||
|
||||
test('prefers explicit env credentials over secure storage', async () => {
|
||||
mock.module('../../utils/codexCredentials.js', () => ({
|
||||
isCodexRefreshFailureCoolingDown: () => false,
|
||||
readCodexCredentials: () => ({
|
||||
accessToken: 'stored-token',
|
||||
accountId: 'acct_stored',
|
||||
}),
|
||||
}))
|
||||
|
||||
// @ts-expect-error cache-busting query string for Bun module mocks
|
||||
const { resolveCodexApiCredentials } = await import(
|
||||
'./providerConfig.js?codex-env-precedence'
|
||||
)
|
||||
|
||||
const credentials = resolveCodexApiCredentials({
|
||||
CODEX_API_KEY: 'env-token',
|
||||
CHATGPT_ACCOUNT_ID: 'acct_env',
|
||||
} as NodeJS.ProcessEnv)
|
||||
|
||||
expect(credentials.apiKey).toBe('env-token')
|
||||
expect(credentials.accountId).toBe('acct_env')
|
||||
expect(credentials.source).toBe('env')
|
||||
})
|
||||
|
||||
test('parses nested chatgpt_account_id from a CODEX_API_KEY JWT', async () => {
|
||||
mock.module('../../utils/codexCredentials.js', () => ({
|
||||
isCodexRefreshFailureCoolingDown: () => false,
|
||||
readCodexCredentials: () => undefined,
|
||||
}))
|
||||
|
||||
// @ts-expect-error cache-busting query string for Bun module mocks
|
||||
const { resolveCodexApiCredentials } = await import(
|
||||
'./providerConfig.js?codex-env-nested-account'
|
||||
)
|
||||
|
||||
const credentials = resolveCodexApiCredentials({
|
||||
CODEX_API_KEY: makeJwt({
|
||||
'https://api.openai.com/auth': {
|
||||
chatgpt_account_id: 'acct_nested_env',
|
||||
},
|
||||
}),
|
||||
} as NodeJS.ProcessEnv)
|
||||
|
||||
expect(credentials.accountId).toBe('acct_nested_env')
|
||||
expect(credentials.source).toBe('env')
|
||||
})
|
||||
|
||||
test('parses nested chatgpt_account_id from auth.json tokens', async () => {
|
||||
mock.module('../../utils/codexCredentials.js', () => ({
|
||||
isCodexRefreshFailureCoolingDown: () => false,
|
||||
readCodexCredentials: () => undefined,
|
||||
}))
|
||||
|
||||
const tempDir = mkdtempSync(join(tmpdir(), 'openclaude-codex-auth-'))
|
||||
const authPath = join(tempDir, 'auth.json')
|
||||
|
||||
writeFileSync(
|
||||
authPath,
|
||||
JSON.stringify({
|
||||
openai_api_key: makeJwt({
|
||||
'https://api.openai.com/auth': {
|
||||
chatgpt_account_id: 'acct_nested_auth_json',
|
||||
},
|
||||
}),
|
||||
}),
|
||||
'utf8',
|
||||
)
|
||||
|
||||
try {
|
||||
// @ts-expect-error cache-busting query string for Bun module mocks
|
||||
const { resolveCodexApiCredentials } = await import(
|
||||
'./providerConfig.js?codex-auth-json-nested-account'
|
||||
)
|
||||
|
||||
const credentials = resolveCodexApiCredentials({
|
||||
CODEX_AUTH_JSON_PATH: authPath,
|
||||
} as NodeJS.ProcessEnv)
|
||||
|
||||
expect(credentials.accountId).toBe('acct_nested_auth_json')
|
||||
expect(credentials.source).toBe('auth.json')
|
||||
} finally {
|
||||
rmSync(tempDir, { force: true, recursive: true })
|
||||
}
|
||||
})
|
||||
|
||||
test('does not read default auth.json when secure storage already has Codex credentials', async () => {
|
||||
mock.module('../../utils/codexCredentials.js', () => ({
|
||||
isCodexRefreshFailureCoolingDown: () => false,
|
||||
readCodexCredentials: () => ({
|
||||
apiKey: 'codex-api-key-token',
|
||||
accessToken: 'header.payload.signature',
|
||||
accountId: 'acct_secure',
|
||||
}),
|
||||
}))
|
||||
|
||||
// @ts-expect-error cache-busting query string for Bun module mocks
|
||||
const { resolveCodexApiCredentials } = await import(
|
||||
'./providerConfig.js?codex-secure-storage-no-auth-io'
|
||||
)
|
||||
|
||||
const credentials = resolveCodexApiCredentials({} as NodeJS.ProcessEnv)
|
||||
expect(credentials.apiKey).toBe('codex-api-key-token')
|
||||
expect(credentials.accountId).toBe('acct_secure')
|
||||
expect(credentials.source).toBe('secure-storage')
|
||||
})
|
||||
|
||||
test('falls back to the default auth.json when stored Codex refresh is cooling down', async () => {
|
||||
const tempHomeDir = mkdtempSync(join(tmpdir(), 'openclaude-codex-home-'))
|
||||
const authJson = JSON.stringify({
|
||||
openai_api_key: makeJwt({
|
||||
'https://api.openai.com/auth': {
|
||||
chatgpt_account_id: 'acct_auth_json',
|
||||
},
|
||||
}),
|
||||
})
|
||||
mkdirSync(join(tempHomeDir, '.codex'), { recursive: true })
|
||||
writeFileSync(join(tempHomeDir, '.codex', 'auth.json'), authJson, 'utf8')
|
||||
|
||||
mock.module('node:os', () => ({
|
||||
...realOs,
|
||||
homedir: () => tempHomeDir,
|
||||
}))
|
||||
|
||||
mock.module('../../utils/codexCredentials.js', () => ({
|
||||
isCodexRefreshFailureCoolingDown: () => true,
|
||||
readCodexCredentials: () => ({
|
||||
accessToken: 'stored-token',
|
||||
refreshToken: 'refresh-stored',
|
||||
accountId: 'acct_stored',
|
||||
lastRefreshFailureAt: Date.now(),
|
||||
}),
|
||||
}))
|
||||
|
||||
// @ts-expect-error cache-busting query string for Bun module mocks
|
||||
const { resolveCodexApiCredentials } = await import(
|
||||
'./providerConfig.js?codex-refresh-cooldown-fallback'
|
||||
)
|
||||
|
||||
try {
|
||||
const credentials = resolveCodexApiCredentials({} as NodeJS.ProcessEnv)
|
||||
expect(credentials.source).toBe('auth.json')
|
||||
expect(credentials.accountId).toBe('acct_auth_json')
|
||||
expect(credentials.apiKey).not.toBe('stored-token')
|
||||
} finally {
|
||||
rmSync(tempHomeDir, { force: true, recursive: true })
|
||||
}
|
||||
})
|
||||
|
||||
test('preserves the stored account id when auth.json fallback lacks one', async () => {
|
||||
const tempHomeDir = mkdtempSync(join(tmpdir(), 'openclaude-codex-home-'))
|
||||
const authJson = JSON.stringify({
|
||||
openai_api_key: 'auth-json-access-token',
|
||||
})
|
||||
mkdirSync(join(tempHomeDir, '.codex'), { recursive: true })
|
||||
writeFileSync(join(tempHomeDir, '.codex', 'auth.json'), authJson, 'utf8')
|
||||
|
||||
mock.module('node:os', () => ({
|
||||
...realOs,
|
||||
homedir: () => tempHomeDir,
|
||||
}))
|
||||
|
||||
mock.module('../../utils/codexCredentials.js', () => ({
|
||||
isCodexRefreshFailureCoolingDown: () => true,
|
||||
readCodexCredentials: () => ({
|
||||
accessToken: 'stored-token',
|
||||
refreshToken: 'refresh-stored',
|
||||
accountId: 'acct_stored',
|
||||
lastRefreshFailureAt: Date.now(),
|
||||
}),
|
||||
}))
|
||||
|
||||
// @ts-expect-error cache-busting query string for Bun module mocks
|
||||
const { resolveCodexApiCredentials } = await import(
|
||||
'./providerConfig.js?codex-refresh-cooldown-account-id-fallback'
|
||||
)
|
||||
|
||||
try {
|
||||
const credentials = resolveCodexApiCredentials({} as NodeJS.ProcessEnv)
|
||||
expect(credentials.source).toBe('auth.json')
|
||||
expect(credentials.apiKey).toBe('auth-json-access-token')
|
||||
expect(credentials.accountId).toBe('acct_stored')
|
||||
} finally {
|
||||
rmSync(tempHomeDir, { force: true, recursive: true })
|
||||
}
|
||||
})
|
||||
})
|
||||
107
src/services/api/providerConfig.runtimeCodexCredentials.test.ts
Normal file
107
src/services/api/providerConfig.runtimeCodexCredentials.test.ts
Normal file
@@ -0,0 +1,107 @@
|
||||
import { afterEach, expect, mock, test } from 'bun:test'
|
||||
import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
|
||||
import { resolveRuntimeCodexCredentials } from './providerConfig.js'
|
||||
|
||||
afterEach(() => {
|
||||
mock.restore()
|
||||
})
|
||||
|
||||
function makeJwt(payload: Record<string, unknown>): string {
|
||||
const header = Buffer.from(JSON.stringify({ alg: 'none', typ: 'JWT' }))
|
||||
.toString('base64url')
|
||||
const body = Buffer.from(JSON.stringify(payload)).toString('base64url')
|
||||
return `${header}.${body}.signature`
|
||||
}
|
||||
|
||||
test('runtime credential resolution honors explicit auth.json over stored secure-storage tokens', () => {
|
||||
const tempDir = mkdtempSync(join(tmpdir(), 'openclaude-codex-explicit-auth-'))
|
||||
const authPath = join(tempDir, 'auth.json')
|
||||
|
||||
writeFileSync(
|
||||
authPath,
|
||||
JSON.stringify({
|
||||
openai_api_key: makeJwt({
|
||||
'https://api.openai.com/auth': {
|
||||
chatgpt_account_id: 'acct_explicit_auth_json',
|
||||
},
|
||||
}),
|
||||
}),
|
||||
'utf8',
|
||||
)
|
||||
|
||||
try {
|
||||
const credentials = resolveRuntimeCodexCredentials({
|
||||
env: {
|
||||
CODEX_AUTH_JSON_PATH: authPath,
|
||||
} as NodeJS.ProcessEnv,
|
||||
storedCredentials: {
|
||||
apiKey: 'stored-api-key',
|
||||
accessToken: 'stored-access-token',
|
||||
accountId: 'acct_stored',
|
||||
},
|
||||
})
|
||||
|
||||
expect(credentials.source).toBe('auth.json')
|
||||
expect(credentials.accountId).toBe('acct_explicit_auth_json')
|
||||
expect(credentials.apiKey).not.toBe('stored-api-key')
|
||||
} finally {
|
||||
rmSync(tempDir, { force: true, recursive: true })
|
||||
}
|
||||
})
|
||||
|
||||
test('runtime credential resolution preserves an explicit auth.json path even when it is missing', () => {
|
||||
const tempDir = mkdtempSync(join(tmpdir(), 'openclaude-codex-missing-auth-'))
|
||||
const authPath = join(tempDir, 'missing-auth.json')
|
||||
|
||||
try {
|
||||
const credentials = resolveRuntimeCodexCredentials({
|
||||
env: {
|
||||
CODEX_AUTH_JSON_PATH: authPath,
|
||||
} as NodeJS.ProcessEnv,
|
||||
storedCredentials: {
|
||||
apiKey: 'stored-api-key',
|
||||
accessToken: 'stored-access-token',
|
||||
accountId: 'acct_stored',
|
||||
},
|
||||
})
|
||||
|
||||
expect(credentials.source).toBe('none')
|
||||
expect(credentials.authPath).toBe(authPath)
|
||||
expect(credentials.apiKey).toBe('')
|
||||
} finally {
|
||||
rmSync(tempDir, { force: true, recursive: true })
|
||||
}
|
||||
})
|
||||
|
||||
test('runtime credential resolution avoids sync secure-storage reads when async credentials are provided', async () => {
|
||||
let syncReadCalled = false
|
||||
|
||||
mock.module('../../utils/codexCredentials.js', () => ({
|
||||
isCodexRefreshFailureCoolingDown: () => false,
|
||||
readCodexCredentials: () => {
|
||||
syncReadCalled = true
|
||||
throw new Error('sync secure-storage read should not run in runtime resolution')
|
||||
},
|
||||
}))
|
||||
|
||||
// @ts-expect-error cache-busting query string for Bun module mocks
|
||||
const { resolveRuntimeCodexCredentials } = await import(
|
||||
'./providerConfig.js?runtime-no-sync-secure-storage'
|
||||
)
|
||||
|
||||
const credentials = resolveRuntimeCodexCredentials({
|
||||
env: {} as NodeJS.ProcessEnv,
|
||||
storedCredentials: {
|
||||
accessToken: 'stored-access-token',
|
||||
accountId: 'acct_stored',
|
||||
},
|
||||
})
|
||||
|
||||
expect(syncReadCalled).toBe(false)
|
||||
expect(credentials.source).toBe('secure-storage')
|
||||
expect(credentials.apiKey).toBe('stored-access-token')
|
||||
expect(credentials.accountId).toBe('acct_stored')
|
||||
})
|
||||
@@ -3,7 +3,16 @@ import { isIP } from 'node:net'
|
||||
import { homedir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
|
||||
import {
|
||||
isCodexRefreshFailureCoolingDown,
|
||||
readCodexCredentials,
|
||||
type CodexCredentialBlob,
|
||||
} from '../../utils/codexCredentials.js'
|
||||
import { isEnvTruthy } from '../../utils/envUtils.js'
|
||||
import {
|
||||
asTrimmedString,
|
||||
parseChatgptAccountId,
|
||||
} from './codexOAuthShared.js'
|
||||
|
||||
export const DEFAULT_OPENAI_BASE_URL = 'https://api.openai.com/v1'
|
||||
export const DEFAULT_CODEX_BASE_URL = 'https://chatgpt.com/backend-api/codex'
|
||||
@@ -78,7 +87,7 @@ export type ResolvedCodexCredentials = {
|
||||
apiKey: string
|
||||
accountId?: string
|
||||
authPath?: string
|
||||
source: 'env' | 'auth.json' | 'none'
|
||||
source: 'env' | 'secure-storage' | 'auth.json' | 'none'
|
||||
}
|
||||
|
||||
type ModelDescriptor = {
|
||||
@@ -114,12 +123,6 @@ function isPrivateIpv6Address(hostname: string): boolean {
|
||||
return (prefix & 0xfe00) === 0xfc00 || (prefix & 0xffc0) === 0xfe80
|
||||
}
|
||||
|
||||
function asTrimmedString(value: unknown): string | undefined {
|
||||
if (typeof value !== 'string') return undefined
|
||||
const trimmed = value.trim()
|
||||
return trimmed ? trimmed : undefined
|
||||
}
|
||||
|
||||
// Reads an env-var-style string intended as a URL or path, rejecting both
|
||||
// empty strings and the literal string "undefined" that Windows shells can
|
||||
// write when a variable is unset-then-referenced without quotes (issue #336).
|
||||
@@ -151,23 +154,6 @@ function readNestedString(
|
||||
return undefined
|
||||
}
|
||||
|
||||
function decodeJwtPayload(token: string): Record<string, unknown> | undefined {
|
||||
const parts = token.split('.')
|
||||
if (parts.length < 2) return undefined
|
||||
|
||||
try {
|
||||
const normalized = parts[1].replace(/-/g, '+').replace(/_/g, '/')
|
||||
const padded = normalized + '='.repeat((4 - (normalized.length % 4)) % 4)
|
||||
const json = Buffer.from(padded, 'base64').toString('utf8')
|
||||
const parsed = JSON.parse(json)
|
||||
return parsed && typeof parsed === 'object'
|
||||
? (parsed as Record<string, unknown>)
|
||||
: undefined
|
||||
} catch {
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
function parseReasoningEffort(value: string | undefined): ReasoningEffort | undefined {
|
||||
if (!value) return undefined
|
||||
const normalized = value.trim().toLowerCase()
|
||||
@@ -494,18 +480,6 @@ export function resolveCodexAuthPath(
|
||||
return join(homedir(), '.codex', 'auth.json')
|
||||
}
|
||||
|
||||
export function parseChatgptAccountId(
|
||||
token: string | undefined,
|
||||
): string | undefined {
|
||||
if (!token) return undefined
|
||||
const payload = decodeJwtPayload(token)
|
||||
const fromClaim = asTrimmedString(
|
||||
payload?.['https://api.openai.com/auth.chatgpt_account_id'],
|
||||
)
|
||||
if (fromClaim) return fromClaim
|
||||
return asTrimmedString(payload?.chatgpt_account_id)
|
||||
}
|
||||
|
||||
function loadCodexAuthJson(
|
||||
authPath: string,
|
||||
): Record<string, unknown> | undefined {
|
||||
@@ -521,8 +495,97 @@ function loadCodexAuthJson(
|
||||
}
|
||||
}
|
||||
|
||||
export function resolveCodexApiCredentials(
|
||||
env: NodeJS.ProcessEnv = process.env,
|
||||
function resolveCodexAuthJsonCredentials(options: {
|
||||
authJson: Record<string, unknown> | undefined
|
||||
authPath: string
|
||||
envAccountId?: string
|
||||
missingSource?: ResolvedCodexCredentials['source']
|
||||
}): ResolvedCodexCredentials {
|
||||
const { authJson, authPath, envAccountId } = options
|
||||
|
||||
if (!authJson) {
|
||||
return {
|
||||
apiKey: '',
|
||||
authPath,
|
||||
source: options.missingSource ?? 'none',
|
||||
}
|
||||
}
|
||||
|
||||
const apiKey = readNestedString(authJson, [
|
||||
['openai_api_key'],
|
||||
['openaiApiKey'],
|
||||
['access_token'],
|
||||
['accessToken'],
|
||||
['tokens', 'access_token'],
|
||||
['tokens', 'accessToken'],
|
||||
['auth', 'access_token'],
|
||||
['auth', 'accessToken'],
|
||||
['token', 'access_token'],
|
||||
['token', 'accessToken'],
|
||||
])
|
||||
// OIDC identity tokens can carry the ChatGPT account id, but they are not
|
||||
// valid bearer credentials for Codex API requests.
|
||||
const idToken = readNestedString(authJson, [
|
||||
['id_token'],
|
||||
['idToken'],
|
||||
['tokens', 'id_token'],
|
||||
['tokens', 'idToken'],
|
||||
])
|
||||
const accountId =
|
||||
envAccountId ??
|
||||
readNestedString(authJson, [
|
||||
['account_id'],
|
||||
['accountId'],
|
||||
['tokens', 'account_id'],
|
||||
['tokens', 'accountId'],
|
||||
['auth', 'account_id'],
|
||||
['auth', 'accountId'],
|
||||
]) ??
|
||||
parseChatgptAccountId(apiKey) ??
|
||||
parseChatgptAccountId(idToken)
|
||||
|
||||
if (!apiKey) {
|
||||
return {
|
||||
apiKey: '',
|
||||
accountId,
|
||||
authPath,
|
||||
source: options.missingSource ?? 'none',
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
apiKey,
|
||||
accountId,
|
||||
authPath,
|
||||
source: 'auth.json',
|
||||
}
|
||||
}
|
||||
|
||||
export function resolveStoredCodexCredentials(options: {
|
||||
storedCredentials: Pick<
|
||||
CodexCredentialBlob,
|
||||
'apiKey' | 'accessToken' | 'idToken' | 'accountId'
|
||||
>
|
||||
envAccountId?: string
|
||||
}): ResolvedCodexCredentials {
|
||||
const { storedCredentials, envAccountId } = options
|
||||
|
||||
return {
|
||||
apiKey: storedCredentials.apiKey ?? storedCredentials.accessToken,
|
||||
accountId:
|
||||
envAccountId ??
|
||||
storedCredentials.accountId ??
|
||||
parseChatgptAccountId(storedCredentials.idToken) ??
|
||||
parseChatgptAccountId(storedCredentials.accessToken),
|
||||
source: 'secure-storage',
|
||||
}
|
||||
}
|
||||
|
||||
function resolveEnvOrAuthJsonCodexCredentials(
|
||||
env: NodeJS.ProcessEnv,
|
||||
options?: {
|
||||
explicitAuthPathOnly?: boolean
|
||||
},
|
||||
): ResolvedCodexCredentials {
|
||||
const envApiKey = asTrimmedString(env.CODEX_API_KEY)
|
||||
const envAccountId =
|
||||
@@ -537,55 +600,127 @@ export function resolveCodexApiCredentials(
|
||||
}
|
||||
}
|
||||
|
||||
const explicitAuthPathConfigured = Boolean(
|
||||
asTrimmedString(env.CODEX_AUTH_JSON_PATH) ?? asTrimmedString(env.CODEX_HOME),
|
||||
)
|
||||
|
||||
if (!explicitAuthPathConfigured && options?.explicitAuthPathOnly) {
|
||||
return {
|
||||
apiKey: '',
|
||||
accountId: envAccountId,
|
||||
source: 'none',
|
||||
}
|
||||
}
|
||||
|
||||
const authPath = resolveCodexAuthPath(env)
|
||||
const authJson = loadCodexAuthJson(authPath)
|
||||
if (!authJson) {
|
||||
return {
|
||||
apiKey: '',
|
||||
authPath,
|
||||
source: 'none',
|
||||
}
|
||||
}
|
||||
|
||||
const apiKey = readNestedString(authJson, [
|
||||
['access_token'],
|
||||
['accessToken'],
|
||||
['tokens', 'access_token'],
|
||||
['tokens', 'accessToken'],
|
||||
['auth', 'access_token'],
|
||||
['auth', 'accessToken'],
|
||||
['token', 'access_token'],
|
||||
['token', 'accessToken'],
|
||||
['tokens', 'id_token'],
|
||||
['tokens', 'idToken'],
|
||||
])
|
||||
const accountId =
|
||||
envAccountId ??
|
||||
readNestedString(authJson, [
|
||||
['account_id'],
|
||||
['accountId'],
|
||||
['tokens', 'account_id'],
|
||||
['tokens', 'accountId'],
|
||||
['auth', 'account_id'],
|
||||
['auth', 'accountId'],
|
||||
]) ??
|
||||
parseChatgptAccountId(apiKey)
|
||||
|
||||
if (!apiKey) {
|
||||
return {
|
||||
apiKey: '',
|
||||
accountId,
|
||||
authPath,
|
||||
source: 'none',
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
apiKey,
|
||||
accountId,
|
||||
return resolveCodexAuthJsonCredentials({
|
||||
authJson,
|
||||
authPath,
|
||||
source: 'auth.json',
|
||||
envAccountId,
|
||||
})
|
||||
}
|
||||
|
||||
export function resolveRuntimeCodexCredentials(options?: {
|
||||
env?: NodeJS.ProcessEnv
|
||||
storedCredentials?: Pick<
|
||||
CodexCredentialBlob,
|
||||
'apiKey' | 'accessToken' | 'idToken' | 'accountId'
|
||||
>
|
||||
}): ResolvedCodexCredentials {
|
||||
const env = options?.env ?? process.env
|
||||
const explicitCredentials = resolveEnvOrAuthJsonCodexCredentials(env, {
|
||||
explicitAuthPathOnly: true,
|
||||
})
|
||||
const explicitAuthPathConfigured = Boolean(
|
||||
asTrimmedString(env.CODEX_AUTH_JSON_PATH) ?? asTrimmedString(env.CODEX_HOME),
|
||||
)
|
||||
const hasStoredCredentialsOption = Boolean(
|
||||
options &&
|
||||
Object.prototype.hasOwnProperty.call(options, 'storedCredentials'),
|
||||
)
|
||||
|
||||
if (
|
||||
explicitAuthPathConfigured ||
|
||||
explicitCredentials.source === 'env' ||
|
||||
explicitCredentials.source === 'auth.json'
|
||||
) {
|
||||
return explicitCredentials
|
||||
}
|
||||
|
||||
if (options?.storedCredentials?.accessToken) {
|
||||
return resolveStoredCodexCredentials({
|
||||
storedCredentials: options.storedCredentials,
|
||||
envAccountId:
|
||||
asTrimmedString(env.CODEX_ACCOUNT_ID) ??
|
||||
asTrimmedString(env.CHATGPT_ACCOUNT_ID),
|
||||
})
|
||||
}
|
||||
|
||||
if (hasStoredCredentialsOption) {
|
||||
return resolveEnvOrAuthJsonCodexCredentials(env)
|
||||
}
|
||||
|
||||
return resolveCodexApiCredentials(env)
|
||||
}
|
||||
|
||||
export function resolveCodexApiCredentials(
|
||||
env: NodeJS.ProcessEnv = process.env,
|
||||
): ResolvedCodexCredentials {
|
||||
const envAccountId =
|
||||
asTrimmedString(env.CODEX_ACCOUNT_ID) ??
|
||||
asTrimmedString(env.CHATGPT_ACCOUNT_ID)
|
||||
const envOrExplicitAuthJsonCredentials = resolveEnvOrAuthJsonCodexCredentials(
|
||||
env,
|
||||
{
|
||||
explicitAuthPathOnly: true,
|
||||
},
|
||||
)
|
||||
|
||||
if (
|
||||
envOrExplicitAuthJsonCredentials.source === 'env' ||
|
||||
envOrExplicitAuthJsonCredentials.source === 'auth.json' ||
|
||||
envOrExplicitAuthJsonCredentials.authPath
|
||||
) {
|
||||
return envOrExplicitAuthJsonCredentials
|
||||
}
|
||||
|
||||
const storedCredentials = readCodexCredentials()
|
||||
if (storedCredentials?.accessToken) {
|
||||
const resolvedStoredCredentials = resolveStoredCodexCredentials({
|
||||
storedCredentials,
|
||||
envAccountId,
|
||||
})
|
||||
|
||||
const shouldCheckDefaultAuthJson =
|
||||
!resolvedStoredCredentials.accountId ||
|
||||
isCodexRefreshFailureCoolingDown(storedCredentials)
|
||||
|
||||
if (!shouldCheckDefaultAuthJson) {
|
||||
return resolvedStoredCredentials
|
||||
}
|
||||
|
||||
const authPath = resolveCodexAuthPath(env)
|
||||
const authJson = loadCodexAuthJson(authPath)
|
||||
const resolvedAuthJsonCredentials = resolveCodexAuthJsonCredentials({
|
||||
authJson,
|
||||
authPath,
|
||||
envAccountId,
|
||||
})
|
||||
|
||||
if (resolvedAuthJsonCredentials.apiKey) {
|
||||
return {
|
||||
...resolvedAuthJsonCredentials,
|
||||
accountId:
|
||||
resolvedAuthJsonCredentials.accountId ??
|
||||
resolvedStoredCredentials.accountId,
|
||||
}
|
||||
}
|
||||
|
||||
return resolvedStoredCredentials
|
||||
}
|
||||
|
||||
return resolveEnvOrAuthJsonCodexCredentials(env)
|
||||
}
|
||||
|
||||
export function getReasoningEffortForModel(model: string): ReasoningEffort | undefined {
|
||||
@@ -595,3 +730,18 @@ export function getReasoningEffortForModel(model: string): ReasoningEffort | und
|
||||
const aliasConfig = CODEX_ALIAS_MODELS[alias]
|
||||
return aliasConfig?.reasoningEffort
|
||||
}
|
||||
|
||||
export function supportsCodexReasoningEffort(model: string): boolean {
|
||||
const normalized = model.trim().toLowerCase()
|
||||
const base = normalized.split('?', 1)[0] ?? normalized
|
||||
|
||||
if (base === 'gpt-5.3-codex-spark' || base === 'codexspark') {
|
||||
return false
|
||||
}
|
||||
|
||||
if (getReasoningEffortForModel(base) !== undefined) {
|
||||
return true
|
||||
}
|
||||
|
||||
return /^gpt-5(?:[.-]|$)/.test(base)
|
||||
}
|
||||
|
||||
155
src/services/oauth/auth-code-listener.analytics.test.ts
Normal file
155
src/services/oauth/auth-code-listener.analytics.test.ts
Normal file
@@ -0,0 +1,155 @@
|
||||
import { afterEach, expect, mock, test } from 'bun:test'
|
||||
|
||||
afterEach(() => {
|
||||
mock.restore()
|
||||
})
|
||||
|
||||
test('custom error responses log the error redirect analytics event', async () => {
|
||||
const events: Array<{
|
||||
name: string
|
||||
metadata: Record<string, boolean | number | undefined>
|
||||
}> = []
|
||||
|
||||
mock.module('src/services/analytics/index.js', () => ({
|
||||
logEvent: (
|
||||
name: string,
|
||||
metadata: Record<string, boolean | number | undefined>,
|
||||
) => {
|
||||
events.push({ name, metadata })
|
||||
},
|
||||
}))
|
||||
|
||||
const { AuthCodeListener } = await import(
|
||||
`./auth-code-listener.js?ts=${Date.now()}-${Math.random()}`
|
||||
)
|
||||
const listener = new AuthCodeListener('/callback')
|
||||
const response = {
|
||||
writeHead: () => {},
|
||||
end: () => {},
|
||||
}
|
||||
|
||||
;(listener as any).pendingResponse = response
|
||||
|
||||
listener.handleErrorRedirect(res => {
|
||||
res.writeHead(400, {
|
||||
'Content-Type': 'text/plain; charset=utf-8',
|
||||
})
|
||||
res.end('cancelled')
|
||||
})
|
||||
|
||||
expect(events).toEqual([
|
||||
{
|
||||
name: 'tengu_oauth_automatic_redirect_error',
|
||||
metadata: { custom_handler: true },
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
test('custom handlers that do not end the response are closed automatically and still log analytics', async () => {
|
||||
const events: Array<{
|
||||
name: string
|
||||
metadata: Record<string, boolean | number | undefined>
|
||||
}> = []
|
||||
const response = {
|
||||
destroyed: false,
|
||||
headersSent: false,
|
||||
writableEnded: false,
|
||||
writeHead: () => {
|
||||
response.headersSent = true
|
||||
},
|
||||
end: () => {
|
||||
response.writableEnded = true
|
||||
},
|
||||
}
|
||||
|
||||
mock.module('src/services/analytics/index.js', () => ({
|
||||
logEvent: (
|
||||
name: string,
|
||||
metadata: Record<string, boolean | number | undefined>,
|
||||
) => {
|
||||
events.push({ name, metadata })
|
||||
},
|
||||
}))
|
||||
|
||||
mock.module('../../utils/log.js', () => ({
|
||||
logError: () => {},
|
||||
}))
|
||||
|
||||
const { AuthCodeListener } = await import(
|
||||
`./auth-code-listener.js?ts=${Date.now()}-${Math.random()}`
|
||||
)
|
||||
const listener = new AuthCodeListener('/callback')
|
||||
|
||||
;(listener as any).pendingResponse = response
|
||||
|
||||
listener.handleErrorRedirect(res => {
|
||||
res.writeHead(400, {
|
||||
'Content-Type': 'text/plain; charset=utf-8',
|
||||
})
|
||||
})
|
||||
|
||||
expect(response.writableEnded).toBe(true)
|
||||
expect((listener as any).pendingResponse).toBeNull()
|
||||
expect(events).toEqual([
|
||||
{
|
||||
name: 'tengu_oauth_automatic_redirect_error',
|
||||
metadata: { custom_handler: true },
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
test('custom handlers that throw are logged, converted to a fallback response, and do not log analytics', async () => {
|
||||
const events: Array<{
|
||||
name: string
|
||||
metadata: Record<string, boolean | number | undefined>
|
||||
}> = []
|
||||
const loggedErrors: unknown[] = []
|
||||
const response = {
|
||||
destroyed: false,
|
||||
headersSent: false,
|
||||
writableEnded: false,
|
||||
statusCode: 0,
|
||||
body: '',
|
||||
writeHead: (statusCode: number) => {
|
||||
response.headersSent = true
|
||||
response.statusCode = statusCode
|
||||
},
|
||||
end: (body = '') => {
|
||||
response.writableEnded = true
|
||||
response.body = body
|
||||
},
|
||||
}
|
||||
|
||||
mock.module('src/services/analytics/index.js', () => ({
|
||||
logEvent: (
|
||||
name: string,
|
||||
metadata: Record<string, boolean | number | undefined>,
|
||||
) => {
|
||||
events.push({ name, metadata })
|
||||
},
|
||||
}))
|
||||
|
||||
mock.module('../../utils/log.js', () => ({
|
||||
logError: (error: unknown) => {
|
||||
loggedErrors.push(error)
|
||||
},
|
||||
}))
|
||||
|
||||
const { AuthCodeListener } = await import(
|
||||
`./auth-code-listener.js?ts=${Date.now()}-${Math.random()}`
|
||||
)
|
||||
const listener = new AuthCodeListener('/callback')
|
||||
|
||||
;(listener as any).pendingResponse = response
|
||||
|
||||
listener.handleErrorRedirect(() => {
|
||||
throw new Error('handler exploded')
|
||||
})
|
||||
|
||||
expect(response.statusCode).toBe(500)
|
||||
expect(response.body).toBe('Authentication redirect failed')
|
||||
expect(response.writableEnded).toBe(true)
|
||||
expect((listener as any).pendingResponse).toBeNull()
|
||||
expect(loggedErrors).toHaveLength(1)
|
||||
expect(events).toEqual([])
|
||||
})
|
||||
31
src/services/oauth/auth-code-listener.test.ts
Normal file
31
src/services/oauth/auth-code-listener.test.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
import { afterEach, expect, test } from 'bun:test'
|
||||
|
||||
import { AuthCodeListener } from './auth-code-listener.js'
|
||||
|
||||
const listeners: AuthCodeListener[] = []
|
||||
|
||||
afterEach(() => {
|
||||
while (listeners.length > 0) {
|
||||
listeners.pop()?.close()
|
||||
}
|
||||
})
|
||||
|
||||
test('cancelPendingAuthorization rejects a pending OAuth wait', async () => {
|
||||
const listener = new AuthCodeListener('/callback')
|
||||
listeners.push(listener)
|
||||
|
||||
await listener.start()
|
||||
|
||||
const pendingAuthorization = listener.waitForAuthorization(
|
||||
'state-test',
|
||||
async () => {},
|
||||
)
|
||||
|
||||
listener.cancelPendingAuthorization(
|
||||
new Error('Codex OAuth flow was cancelled.'),
|
||||
)
|
||||
|
||||
await expect(pendingAuthorization).rejects.toThrow(
|
||||
'Codex OAuth flow was cancelled.',
|
||||
)
|
||||
})
|
||||
@@ -71,6 +71,42 @@ export class AuthCodeListener {
|
||||
})
|
||||
}
|
||||
|
||||
private respondToPendingRequest(options: {
|
||||
handler: (res: ServerResponse) => void
|
||||
analyticsEvent:
|
||||
| 'tengu_oauth_automatic_redirect'
|
||||
| 'tengu_oauth_automatic_redirect_error'
|
||||
analyticsMetadata?: Record<string, boolean>
|
||||
}): void {
|
||||
if (!this.pendingResponse) return
|
||||
|
||||
const response = this.pendingResponse
|
||||
try {
|
||||
options.handler(response)
|
||||
|
||||
if (!response.writableEnded && !response.destroyed) {
|
||||
response.end()
|
||||
}
|
||||
|
||||
logEvent(options.analyticsEvent, options.analyticsMetadata ?? {})
|
||||
} catch (error) {
|
||||
logError(error)
|
||||
|
||||
if (!response.headersSent && !response.destroyed) {
|
||||
response.writeHead(500, {
|
||||
'Content-Type': 'text/plain; charset=utf-8',
|
||||
})
|
||||
}
|
||||
if (!response.writableEnded && !response.destroyed) {
|
||||
response.end('Authentication redirect failed')
|
||||
}
|
||||
} finally {
|
||||
if (this.pendingResponse === response) {
|
||||
this.pendingResponse = null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Completes the OAuth flow by redirecting the user's browser to a success page.
|
||||
* Different success pages are shown based on the granted scopes.
|
||||
@@ -85,9 +121,13 @@ export class AuthCodeListener {
|
||||
|
||||
// If custom handler provided, use it instead of default redirect
|
||||
if (customHandler) {
|
||||
customHandler(this.pendingResponse, scopes)
|
||||
this.pendingResponse = null
|
||||
logEvent('tengu_oauth_automatic_redirect', { custom_handler: true })
|
||||
this.respondToPendingRequest({
|
||||
handler: res => {
|
||||
customHandler(res, scopes)
|
||||
},
|
||||
analyticsEvent: 'tengu_oauth_automatic_redirect',
|
||||
analyticsMetadata: { custom_handler: true },
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
@@ -97,29 +137,48 @@ export class AuthCodeListener {
|
||||
: getOauthConfig().CONSOLE_SUCCESS_URL
|
||||
|
||||
// Send browser to success page
|
||||
this.pendingResponse.writeHead(302, { Location: successUrl })
|
||||
this.pendingResponse.end()
|
||||
this.pendingResponse = null
|
||||
|
||||
logEvent('tengu_oauth_automatic_redirect', {})
|
||||
this.respondToPendingRequest({
|
||||
handler: res => {
|
||||
res.writeHead(302, { Location: successUrl })
|
||||
res.end()
|
||||
},
|
||||
analyticsEvent: 'tengu_oauth_automatic_redirect',
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles error case by sending a redirect to the appropriate success page with an error indicator,
|
||||
* ensuring the browser flow is completed properly.
|
||||
*/
|
||||
handleErrorRedirect(): void {
|
||||
handleErrorRedirect(customHandler?: (res: ServerResponse) => void): void {
|
||||
if (!this.pendingResponse) return
|
||||
|
||||
if (customHandler) {
|
||||
this.respondToPendingRequest({
|
||||
handler: customHandler,
|
||||
analyticsEvent: 'tengu_oauth_automatic_redirect_error',
|
||||
analyticsMetadata: { custom_handler: true },
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// TODO: swap to a different url once we have an error page
|
||||
const errorUrl = getOauthConfig().CLAUDEAI_SUCCESS_URL
|
||||
|
||||
// Send browser to error page
|
||||
this.pendingResponse.writeHead(302, { Location: errorUrl })
|
||||
this.pendingResponse.end()
|
||||
this.pendingResponse = null
|
||||
this.respondToPendingRequest({
|
||||
handler: res => {
|
||||
res.writeHead(302, { Location: errorUrl })
|
||||
res.end()
|
||||
},
|
||||
analyticsEvent: 'tengu_oauth_automatic_redirect_error',
|
||||
})
|
||||
}
|
||||
|
||||
logEvent('tengu_oauth_automatic_redirect_error', {})
|
||||
cancelPendingAuthorization(
|
||||
error: Error = new Error('OAuth authorization was cancelled.'),
|
||||
): void {
|
||||
this.reject(error)
|
||||
this.close()
|
||||
}
|
||||
|
||||
private startLocalListener(onReady: () => Promise<void>): void {
|
||||
@@ -176,8 +235,7 @@ export class AuthCodeListener {
|
||||
|
||||
private handleError(err: Error): void {
|
||||
logError(err)
|
||||
this.close()
|
||||
this.reject(err)
|
||||
this.cancelPendingAuthorization(err)
|
||||
}
|
||||
|
||||
private resolve(authorizationCode: string): void {
|
||||
@@ -185,6 +243,7 @@ export class AuthCodeListener {
|
||||
this.promiseResolver(authorizationCode)
|
||||
this.promiseResolver = null
|
||||
this.promiseRejecter = null
|
||||
this.expectedState = null
|
||||
}
|
||||
}
|
||||
|
||||
@@ -193,6 +252,7 @@ export class AuthCodeListener {
|
||||
this.promiseRejecter(error)
|
||||
this.promiseResolver = null
|
||||
this.promiseRejecter = null
|
||||
this.expectedState = null
|
||||
}
|
||||
}
|
||||
|
||||
@@ -207,5 +267,8 @@ export class AuthCodeListener {
|
||||
this.localServer.removeAllListeners()
|
||||
this.localServer.close()
|
||||
}
|
||||
|
||||
this.expectedState = null
|
||||
this.port = 0
|
||||
}
|
||||
}
|
||||
|
||||
607
src/utils/codexCredentials.test.ts
Normal file
607
src/utils/codexCredentials.test.ts
Normal file
@@ -0,0 +1,607 @@
|
||||
/**
|
||||
* These tests avoid static imports so Bun can mock secureStorage before
|
||||
* codexCredentials is first loaded.
|
||||
*/
|
||||
import { afterEach, describe, expect, mock, test } from 'bun:test'
|
||||
|
||||
function makeJwt(payload: Record<string, unknown>): string {
|
||||
const header = Buffer.from(JSON.stringify({ alg: 'none', typ: 'JWT' }))
|
||||
.toString('base64url')
|
||||
const body = Buffer.from(JSON.stringify(payload)).toString('base64url')
|
||||
return `${header}.${body}.signature`
|
||||
}
|
||||
|
||||
describe('codexCredentials', () => {
|
||||
const originalSimple = process.env.CLAUDE_CODE_SIMPLE
|
||||
const originalCodeKey = process.env.CODEX_API_KEY
|
||||
const originalFetch = globalThis.fetch
|
||||
|
||||
afterEach(() => {
|
||||
mock.restore()
|
||||
globalThis.fetch = originalFetch
|
||||
|
||||
if (originalSimple === undefined) {
|
||||
delete process.env.CLAUDE_CODE_SIMPLE
|
||||
} else {
|
||||
process.env.CLAUDE_CODE_SIMPLE = originalSimple
|
||||
}
|
||||
|
||||
if (originalCodeKey === undefined) {
|
||||
delete process.env.CODEX_API_KEY
|
||||
} else {
|
||||
process.env.CODEX_API_KEY = originalCodeKey
|
||||
}
|
||||
})
|
||||
|
||||
test('save returns failure in bare mode', async () => {
|
||||
process.env.CLAUDE_CODE_SIMPLE = '1'
|
||||
|
||||
// @ts-expect-error cache-busting query string for Bun module mocks
|
||||
const { saveCodexCredentials } = await import(
|
||||
'./codexCredentials.js?save-bare-mode'
|
||||
)
|
||||
|
||||
const result = saveCodexCredentials({
|
||||
accessToken: 'token',
|
||||
accountId: 'acct_123',
|
||||
})
|
||||
|
||||
expect(result.success).toBe(false)
|
||||
expect(result.warning).toContain('Bare mode')
|
||||
})
|
||||
|
||||
test('saveCodexCredentials refuses plaintext fallback when native secure storage is unavailable', async () => {
|
||||
delete process.env.CLAUDE_CODE_SIMPLE
|
||||
|
||||
mock.module('./secureStorage/index.js', () => ({
|
||||
getSecureStorage: (options?: { allowPlainTextFallback?: boolean }) => {
|
||||
expect(options?.allowPlainTextFallback).toBe(false)
|
||||
return {
|
||||
read: () => null,
|
||||
readAsync: async () => null,
|
||||
update: () => ({
|
||||
success: false,
|
||||
warning:
|
||||
'Secure storage is unavailable on this platform without plaintext fallback.',
|
||||
}),
|
||||
delete: () => true,
|
||||
}
|
||||
},
|
||||
}))
|
||||
|
||||
// @ts-expect-error cache-busting query string for Bun module mocks
|
||||
const { saveCodexCredentials } = await import(
|
||||
'./codexCredentials.js?save-no-plaintext-fallback'
|
||||
)
|
||||
|
||||
const result = saveCodexCredentials({
|
||||
accessToken: 'token',
|
||||
accountId: 'acct_123',
|
||||
})
|
||||
|
||||
expect(result.success).toBe(false)
|
||||
expect(result.warning).toContain('without plaintext fallback')
|
||||
})
|
||||
|
||||
test('refreshCodexAccessTokenIfNeeded refreshes expired stored credentials', async () => {
|
||||
delete process.env.CLAUDE_CODE_SIMPLE
|
||||
delete process.env.CODEX_API_KEY
|
||||
|
||||
const expiredToken = makeJwt({
|
||||
exp: Math.floor((Date.now() - 60_000) / 1000),
|
||||
chatgpt_account_id: 'acct_old',
|
||||
})
|
||||
const freshAccessToken = makeJwt({
|
||||
exp: Math.floor((Date.now() + 3_600_000) / 1000),
|
||||
chatgpt_account_id: 'acct_new',
|
||||
})
|
||||
const freshIdToken = makeJwt({
|
||||
exp: Math.floor((Date.now() + 3_600_000) / 1000),
|
||||
'https://api.openai.com/auth': {
|
||||
chatgpt_account_id: 'acct_new',
|
||||
},
|
||||
})
|
||||
|
||||
let storageState: Record<string, unknown> = {
|
||||
codex: {
|
||||
accessToken: expiredToken,
|
||||
refreshToken: 'refresh-old',
|
||||
accountId: 'acct_old',
|
||||
},
|
||||
}
|
||||
|
||||
mock.module('./secureStorage/index.js', () => ({
|
||||
getSecureStorage: () => ({
|
||||
read: () => storageState,
|
||||
readAsync: async () => storageState,
|
||||
update: (next: Record<string, unknown>) => {
|
||||
storageState = next
|
||||
return { success: true }
|
||||
},
|
||||
}),
|
||||
}))
|
||||
|
||||
globalThis.fetch = mock(
|
||||
async (_input, init) => {
|
||||
const bodyText =
|
||||
typeof init?.body === 'string'
|
||||
? init.body
|
||||
: init?.body instanceof URLSearchParams
|
||||
? init.body.toString()
|
||||
: ''
|
||||
|
||||
if (
|
||||
bodyText.includes('grant_type=refresh_token') ||
|
||||
bodyText.includes('"grant_type":"refresh_token"')
|
||||
) {
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
access_token: freshAccessToken,
|
||||
refresh_token: 'refresh-new',
|
||||
id_token: freshIdToken,
|
||||
}),
|
||||
{
|
||||
status: 200,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
access_token: 'codex-api-key-token',
|
||||
}),
|
||||
{
|
||||
status: 200,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
},
|
||||
)
|
||||
},
|
||||
) as unknown as typeof fetch
|
||||
|
||||
// @ts-expect-error cache-busting query string for Bun module mocks
|
||||
const { refreshCodexAccessTokenIfNeeded, readCodexCredentials } =
|
||||
await import('./codexCredentials.js?refresh-success')
|
||||
|
||||
const result = await refreshCodexAccessTokenIfNeeded()
|
||||
expect(result.refreshed).toBe(true)
|
||||
|
||||
const stored = readCodexCredentials()
|
||||
expect(stored?.accessToken).toBe(freshAccessToken)
|
||||
expect(stored?.apiKey).toBe('codex-api-key-token')
|
||||
expect(stored?.refreshToken).toBe('refresh-new')
|
||||
expect(stored?.accountId).toBe('acct_new')
|
||||
})
|
||||
|
||||
test('refreshCodexAccessTokenIfNeeded backs off after a failed refresh attempt', async () => {
|
||||
delete process.env.CLAUDE_CODE_SIMPLE
|
||||
delete process.env.CODEX_API_KEY
|
||||
|
||||
const expiredToken = makeJwt({
|
||||
exp: Math.floor((Date.now() - 60_000) / 1000),
|
||||
chatgpt_account_id: 'acct_old',
|
||||
})
|
||||
|
||||
let storageState: Record<string, unknown> = {
|
||||
codex: {
|
||||
accessToken: expiredToken,
|
||||
refreshToken: 'refresh-old',
|
||||
accountId: 'acct_old',
|
||||
},
|
||||
}
|
||||
|
||||
mock.module('./secureStorage/index.js', () => ({
|
||||
getSecureStorage: () => ({
|
||||
read: () => storageState,
|
||||
readAsync: async () => storageState,
|
||||
update: (next: Record<string, unknown>) => {
|
||||
storageState = next
|
||||
return { success: true }
|
||||
},
|
||||
}),
|
||||
}))
|
||||
|
||||
let refreshAttempts = 0
|
||||
globalThis.fetch = mock(async () => {
|
||||
refreshAttempts += 1
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
error: {
|
||||
code: 'invalid_grant',
|
||||
message: 'refresh token expired',
|
||||
},
|
||||
}),
|
||||
{
|
||||
status: 400,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
},
|
||||
)
|
||||
}) as unknown as typeof fetch
|
||||
|
||||
// @ts-expect-error cache-busting query string for Bun module mocks
|
||||
const { refreshCodexAccessTokenIfNeeded, readCodexCredentials } =
|
||||
await import('./codexCredentials.js?refresh-cooldown')
|
||||
|
||||
await expect(refreshCodexAccessTokenIfNeeded()).rejects.toThrow(
|
||||
'Codex token refresh failed (invalid_grant): refresh token expired',
|
||||
)
|
||||
|
||||
const afterFailure = readCodexCredentials()
|
||||
expect(typeof afterFailure?.lastRefreshFailureAt).toBe('number')
|
||||
|
||||
const secondAttempt = await refreshCodexAccessTokenIfNeeded()
|
||||
expect(secondAttempt.refreshed).toBe(false)
|
||||
expect(secondAttempt.credentials?.accessToken).toBe(expiredToken)
|
||||
expect(refreshAttempts).toBe(1)
|
||||
})
|
||||
|
||||
test('refreshCodexAccessTokenIfNeeded drops a stale api key when id-token exchange fails', async () => {
|
||||
delete process.env.CLAUDE_CODE_SIMPLE
|
||||
delete process.env.CODEX_API_KEY
|
||||
|
||||
const expiredToken = makeJwt({
|
||||
exp: Math.floor((Date.now() - 60_000) / 1000),
|
||||
chatgpt_account_id: 'acct_old',
|
||||
})
|
||||
const freshAccessToken = makeJwt({
|
||||
exp: Math.floor((Date.now() + 3_600_000) / 1000),
|
||||
chatgpt_account_id: 'acct_new',
|
||||
})
|
||||
const freshIdToken = makeJwt({
|
||||
exp: Math.floor((Date.now() + 3_600_000) / 1000),
|
||||
'https://api.openai.com/auth': {
|
||||
chatgpt_account_id: 'acct_new',
|
||||
},
|
||||
})
|
||||
|
||||
let storageState: Record<string, unknown> = {
|
||||
codex: {
|
||||
apiKey: 'stale-api-key',
|
||||
accessToken: expiredToken,
|
||||
refreshToken: 'refresh-old',
|
||||
accountId: 'acct_old',
|
||||
},
|
||||
}
|
||||
|
||||
mock.module('./secureStorage/index.js', () => ({
|
||||
getSecureStorage: () => ({
|
||||
read: () => storageState,
|
||||
readAsync: async () => storageState,
|
||||
update: (next: Record<string, unknown>) => {
|
||||
storageState = next
|
||||
return { success: true }
|
||||
},
|
||||
}),
|
||||
}))
|
||||
|
||||
globalThis.fetch = mock(
|
||||
async (_input, init) => {
|
||||
const bodyText =
|
||||
typeof init?.body === 'string'
|
||||
? init.body
|
||||
: init?.body instanceof URLSearchParams
|
||||
? init.body.toString()
|
||||
: ''
|
||||
|
||||
if (bodyText.includes('grant_type=refresh_token')) {
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
access_token: freshAccessToken,
|
||||
refresh_token: 'refresh-new',
|
||||
id_token: freshIdToken,
|
||||
}),
|
||||
{
|
||||
status: 200,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
return new Response('exchange failed', {
|
||||
status: 500,
|
||||
})
|
||||
},
|
||||
) as unknown as typeof fetch
|
||||
|
||||
// @ts-expect-error cache-busting query string for Bun module mocks
|
||||
const { refreshCodexAccessTokenIfNeeded, readCodexCredentials } =
|
||||
await import('./codexCredentials.js?refresh-drop-stale-api-key')
|
||||
|
||||
const result = await refreshCodexAccessTokenIfNeeded()
|
||||
expect(result.refreshed).toBe(true)
|
||||
|
||||
const stored = readCodexCredentials()
|
||||
expect(stored?.accessToken).toBe(freshAccessToken)
|
||||
expect(stored?.apiKey).toBeUndefined()
|
||||
expect(stored?.refreshToken).toBe('refresh-new')
|
||||
expect(stored?.accountId).toBe('acct_new')
|
||||
})
|
||||
|
||||
test('refreshCodexAccessTokenIfNeeded deduplicates concurrent refresh attempts', async () => {
|
||||
delete process.env.CLAUDE_CODE_SIMPLE
|
||||
delete process.env.CODEX_API_KEY
|
||||
|
||||
const expiredToken = makeJwt({
|
||||
exp: Math.floor((Date.now() - 60_000) / 1000),
|
||||
chatgpt_account_id: 'acct_old',
|
||||
})
|
||||
const freshAccessToken = makeJwt({
|
||||
exp: Math.floor((Date.now() + 3_600_000) / 1000),
|
||||
chatgpt_account_id: 'acct_new',
|
||||
})
|
||||
const freshIdToken = makeJwt({
|
||||
exp: Math.floor((Date.now() + 3_600_000) / 1000),
|
||||
'https://api.openai.com/auth': {
|
||||
chatgpt_account_id: 'acct_new',
|
||||
},
|
||||
})
|
||||
|
||||
let storageState: Record<string, unknown> = {
|
||||
codex: {
|
||||
accessToken: expiredToken,
|
||||
refreshToken: 'refresh-old',
|
||||
accountId: 'acct_old',
|
||||
},
|
||||
}
|
||||
|
||||
mock.module('./secureStorage/index.js', () => ({
|
||||
getSecureStorage: () => ({
|
||||
read: () => storageState,
|
||||
readAsync: async () => storageState,
|
||||
update: (next: Record<string, unknown>) => {
|
||||
storageState = next
|
||||
return { success: true }
|
||||
},
|
||||
}),
|
||||
}))
|
||||
|
||||
let refreshAttempts = 0
|
||||
let releaseRefresh: (() => void) | undefined
|
||||
const refreshGate = new Promise<void>(resolve => {
|
||||
releaseRefresh = resolve
|
||||
})
|
||||
|
||||
globalThis.fetch = mock(async (_input, init) => {
|
||||
const bodyText =
|
||||
typeof init?.body === 'string'
|
||||
? init.body
|
||||
: init?.body instanceof URLSearchParams
|
||||
? init.body.toString()
|
||||
: ''
|
||||
|
||||
if (bodyText.includes('grant_type=refresh_token')) {
|
||||
refreshAttempts += 1
|
||||
await refreshGate
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
access_token: freshAccessToken,
|
||||
refresh_token: 'refresh-new',
|
||||
id_token: freshIdToken,
|
||||
}),
|
||||
{
|
||||
status: 200,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
access_token: 'codex-api-key-token',
|
||||
}),
|
||||
{
|
||||
status: 200,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
},
|
||||
)
|
||||
}) as unknown as typeof fetch
|
||||
|
||||
// @ts-expect-error cache-busting query string for Bun module mocks
|
||||
const { refreshCodexAccessTokenIfNeeded } = await import(
|
||||
'./codexCredentials.js?refresh-dedupe'
|
||||
)
|
||||
|
||||
const firstRefresh = refreshCodexAccessTokenIfNeeded()
|
||||
const secondRefresh = refreshCodexAccessTokenIfNeeded()
|
||||
releaseRefresh?.()
|
||||
|
||||
const [firstResult, secondResult] = await Promise.all([
|
||||
firstRefresh,
|
||||
secondRefresh,
|
||||
])
|
||||
|
||||
expect(refreshAttempts).toBe(1)
|
||||
expect(firstResult).toEqual(secondResult)
|
||||
expect(firstResult.refreshed).toBe(true)
|
||||
expect(firstResult.credentials?.accessToken).toBe(freshAccessToken)
|
||||
})
|
||||
|
||||
test('saveCodexCredentials preserves an existing linked profile id', async () => {
|
||||
delete process.env.CLAUDE_CODE_SIMPLE
|
||||
|
||||
let storageState: Record<string, unknown> = {
|
||||
codex: {
|
||||
accessToken: 'access-old',
|
||||
refreshToken: 'refresh-old',
|
||||
accountId: 'acct_old',
|
||||
profileId: 'profile_codex_oauth',
|
||||
},
|
||||
}
|
||||
|
||||
mock.module('./secureStorage/index.js', () => ({
|
||||
getSecureStorage: () => ({
|
||||
read: () => storageState,
|
||||
readAsync: async () => storageState,
|
||||
update: (next: Record<string, unknown>) => {
|
||||
storageState = next
|
||||
return { success: true }
|
||||
},
|
||||
}),
|
||||
}))
|
||||
|
||||
// @ts-expect-error cache-busting query string for Bun module mocks
|
||||
const { readCodexCredentials, saveCodexCredentials } = await import(
|
||||
'./codexCredentials.js?preserve-profile-id'
|
||||
)
|
||||
|
||||
const saved = saveCodexCredentials({
|
||||
accessToken: 'access-new',
|
||||
refreshToken: 'refresh-new',
|
||||
accountId: 'acct_new',
|
||||
})
|
||||
|
||||
expect(saved.success).toBe(true)
|
||||
expect(readCodexCredentials()?.profileId).toBe('profile_codex_oauth')
|
||||
})
|
||||
|
||||
test('attachCodexProfileIdToStoredCredentials links the saved profile id', async () => {
|
||||
delete process.env.CLAUDE_CODE_SIMPLE
|
||||
|
||||
let storageState: Record<string, unknown> = {
|
||||
codex: {
|
||||
accessToken: 'access-old',
|
||||
refreshToken: 'refresh-old',
|
||||
accountId: 'acct_old',
|
||||
},
|
||||
}
|
||||
|
||||
mock.module('./secureStorage/index.js', () => ({
|
||||
getSecureStorage: () => ({
|
||||
read: () => storageState,
|
||||
readAsync: async () => storageState,
|
||||
update: (next: Record<string, unknown>) => {
|
||||
storageState = next
|
||||
return { success: true }
|
||||
},
|
||||
}),
|
||||
}))
|
||||
|
||||
// @ts-expect-error cache-busting query string for Bun module mocks
|
||||
const {
|
||||
attachCodexProfileIdToStoredCredentials,
|
||||
readCodexCredentials,
|
||||
} = await import('./codexCredentials.js?attach-profile-id')
|
||||
|
||||
const result =
|
||||
attachCodexProfileIdToStoredCredentials('profile_codex_oauth')
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
expect(readCodexCredentials()?.profileId).toBe('profile_codex_oauth')
|
||||
})
|
||||
|
||||
test('refreshCodexAccessTokenIfNeeded uses async secure-storage reads in its request path', async () => {
|
||||
delete process.env.CLAUDE_CODE_SIMPLE
|
||||
delete process.env.CODEX_API_KEY
|
||||
|
||||
const freshToken = makeJwt({
|
||||
exp: Math.floor((Date.now() + 3_600_000) / 1000),
|
||||
chatgpt_account_id: 'acct_async',
|
||||
})
|
||||
|
||||
let storageState: Record<string, unknown> = {
|
||||
codex: {
|
||||
accessToken: freshToken,
|
||||
refreshToken: 'refresh-async',
|
||||
accountId: 'acct_async',
|
||||
},
|
||||
}
|
||||
|
||||
mock.module('./secureStorage/index.js', () => ({
|
||||
getSecureStorage: () => ({
|
||||
read: () => {
|
||||
throw new Error(
|
||||
'sync storage read should not run during refresh checks',
|
||||
)
|
||||
},
|
||||
readAsync: async () => storageState,
|
||||
update: (next: Record<string, unknown>) => {
|
||||
storageState = next
|
||||
return { success: true }
|
||||
},
|
||||
}),
|
||||
}))
|
||||
|
||||
// @ts-expect-error cache-busting query string for Bun module mocks
|
||||
const { refreshCodexAccessTokenIfNeeded } = await import(
|
||||
'./codexCredentials.js?refresh-async-read'
|
||||
)
|
||||
|
||||
const result = await refreshCodexAccessTokenIfNeeded()
|
||||
expect(result.refreshed).toBe(false)
|
||||
expect(result.credentials?.accessToken).toBe(freshToken)
|
||||
})
|
||||
|
||||
test('refreshCodexAccessTokenIfNeeded keeps a cooldown in memory when secure storage cannot persist it', async () => {
|
||||
delete process.env.CLAUDE_CODE_SIMPLE
|
||||
delete process.env.CODEX_API_KEY
|
||||
|
||||
const expiredToken = makeJwt({
|
||||
exp: Math.floor((Date.now() - 60_000) / 1000),
|
||||
chatgpt_account_id: 'acct_old',
|
||||
})
|
||||
|
||||
const storageState: Record<string, unknown> = {
|
||||
codex: {
|
||||
accessToken: expiredToken,
|
||||
refreshToken: 'refresh-old',
|
||||
accountId: 'acct_old',
|
||||
},
|
||||
}
|
||||
|
||||
mock.module('./secureStorage/index.js', () => ({
|
||||
getSecureStorage: () => ({
|
||||
read: () => storageState,
|
||||
readAsync: async () => storageState,
|
||||
update: () => ({
|
||||
success: false,
|
||||
warning: 'secure storage unavailable',
|
||||
}),
|
||||
}),
|
||||
}))
|
||||
|
||||
let refreshAttempts = 0
|
||||
globalThis.fetch = mock(async () => {
|
||||
refreshAttempts += 1
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
error: {
|
||||
code: 'invalid_grant',
|
||||
message: 'refresh token expired',
|
||||
},
|
||||
}),
|
||||
{
|
||||
status: 400,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
},
|
||||
)
|
||||
}) as unknown as typeof fetch
|
||||
|
||||
// @ts-expect-error cache-busting query string for Bun module mocks
|
||||
const { refreshCodexAccessTokenIfNeeded } = await import(
|
||||
'./codexCredentials.js?refresh-memory-cooldown'
|
||||
)
|
||||
|
||||
await expect(refreshCodexAccessTokenIfNeeded()).rejects.toThrow(
|
||||
'Codex token refresh failed (invalid_grant): refresh token expired',
|
||||
)
|
||||
|
||||
const secondAttempt = await refreshCodexAccessTokenIfNeeded()
|
||||
expect(secondAttempt.refreshed).toBe(false)
|
||||
expect(secondAttempt.credentials?.accessToken).toBe(expiredToken)
|
||||
expect(refreshAttempts).toBe(1)
|
||||
})
|
||||
})
|
||||
375
src/utils/codexCredentials.ts
Normal file
375
src/utils/codexCredentials.ts
Normal file
@@ -0,0 +1,375 @@
|
||||
import { isBareMode } from './envUtils.js'
|
||||
import { getSecureStorage } from './secureStorage/index.js'
|
||||
import {
|
||||
asTrimmedString,
|
||||
CODEX_REFRESH_URL,
|
||||
exchangeCodexIdTokenForApiKey,
|
||||
getCodexOAuthClientId,
|
||||
parseChatgptAccountId,
|
||||
decodeJwtPayload,
|
||||
} from '../services/api/codexOAuthShared.js'
|
||||
|
||||
export const CODEX_STORAGE_KEY = 'codex' as const
|
||||
const CODEX_TOKEN_REFRESH_SKEW_MS = 60_000
|
||||
const CODEX_TOKEN_REFRESH_RETRY_COOLDOWN_MS = 60_000
|
||||
|
||||
export type CodexCredentialBlob = {
|
||||
apiKey?: string
|
||||
accessToken: string
|
||||
refreshToken?: string
|
||||
idToken?: string
|
||||
accountId?: string
|
||||
profileId?: string
|
||||
lastRefreshAt?: number
|
||||
lastRefreshFailureAt?: number
|
||||
}
|
||||
|
||||
type CodexTokenRefreshResponse = {
|
||||
access_token?: string
|
||||
refresh_token?: string
|
||||
id_token?: string
|
||||
}
|
||||
|
||||
let inFlightCodexRefresh:
|
||||
| Promise<{
|
||||
refreshed: boolean
|
||||
credentials?: CodexCredentialBlob
|
||||
}>
|
||||
| null = null
|
||||
let inMemoryLastRefreshFailureAt: number | null = null
|
||||
|
||||
function getCodexSecureStorage() {
|
||||
return getSecureStorage({ allowPlainTextFallback: false })
|
||||
}
|
||||
|
||||
function parseJwtExpiryMs(token: string | undefined): number | undefined {
|
||||
if (!token) return undefined
|
||||
const payload = decodeJwtPayload(token)
|
||||
const exp = payload?.exp
|
||||
if (typeof exp === 'number' && Number.isFinite(exp)) {
|
||||
return exp * 1000
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
function normalizeCodexCredentialBlob(
|
||||
value: unknown,
|
||||
): CodexCredentialBlob | undefined {
|
||||
if (!value || typeof value !== 'object') return undefined
|
||||
|
||||
const record = value as Record<string, unknown>
|
||||
const apiKey = asTrimmedString(record.apiKey)
|
||||
const accessToken = asTrimmedString(record.accessToken)
|
||||
if (!accessToken) return undefined
|
||||
|
||||
const refreshToken = asTrimmedString(record.refreshToken)
|
||||
const idToken = asTrimmedString(record.idToken)
|
||||
const accountId =
|
||||
asTrimmedString(record.accountId) ??
|
||||
parseChatgptAccountId(idToken) ??
|
||||
parseChatgptAccountId(accessToken)
|
||||
const profileId = asTrimmedString(record.profileId)
|
||||
|
||||
const lastRefreshAt =
|
||||
typeof record.lastRefreshAt === 'number' &&
|
||||
Number.isFinite(record.lastRefreshAt)
|
||||
? record.lastRefreshAt
|
||||
: undefined
|
||||
const lastRefreshFailureAt =
|
||||
typeof record.lastRefreshFailureAt === 'number' &&
|
||||
Number.isFinite(record.lastRefreshFailureAt)
|
||||
? record.lastRefreshFailureAt
|
||||
: undefined
|
||||
|
||||
return {
|
||||
apiKey,
|
||||
accessToken,
|
||||
refreshToken,
|
||||
idToken,
|
||||
accountId,
|
||||
profileId,
|
||||
lastRefreshAt,
|
||||
lastRefreshFailureAt,
|
||||
}
|
||||
}
|
||||
|
||||
function shouldRefreshCodexToken(blob: CodexCredentialBlob): boolean {
|
||||
const expiresAt =
|
||||
parseJwtExpiryMs(blob.accessToken) ?? parseJwtExpiryMs(blob.idToken)
|
||||
if (expiresAt === undefined) {
|
||||
return false
|
||||
}
|
||||
return expiresAt <= Date.now() + CODEX_TOKEN_REFRESH_SKEW_MS
|
||||
}
|
||||
|
||||
function isWithinRefreshFailureCooldown(
|
||||
blob: CodexCredentialBlob,
|
||||
now = Date.now(),
|
||||
): boolean {
|
||||
const lastRefreshFailureAt = Math.max(
|
||||
blob.lastRefreshFailureAt ?? 0,
|
||||
inMemoryLastRefreshFailureAt ?? 0,
|
||||
)
|
||||
|
||||
if (!lastRefreshFailureAt) {
|
||||
return false
|
||||
}
|
||||
|
||||
return (
|
||||
now - lastRefreshFailureAt < CODEX_TOKEN_REFRESH_RETRY_COOLDOWN_MS
|
||||
)
|
||||
}
|
||||
|
||||
function getRefreshErrorMessage(
|
||||
status: number,
|
||||
bodyText: string,
|
||||
): string {
|
||||
if (!bodyText.trim()) {
|
||||
return `Codex token refresh failed with status ${status}.`
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(bodyText) as Record<string, unknown>
|
||||
const nestedError =
|
||||
parsed.error && typeof parsed.error === 'object'
|
||||
? (parsed.error as Record<string, unknown>)
|
||||
: undefined
|
||||
const code = asTrimmedString(nestedError?.code ?? parsed.code)
|
||||
const message =
|
||||
asTrimmedString(nestedError?.message ?? parsed.error_description) ??
|
||||
bodyText.trim()
|
||||
return code
|
||||
? `Codex token refresh failed (${code}): ${message}`
|
||||
: `Codex token refresh failed with status ${status}: ${message}`
|
||||
} catch {
|
||||
return `Codex token refresh failed with status ${status}: ${bodyText.trim()}`
|
||||
}
|
||||
}
|
||||
|
||||
export function readCodexCredentials(): CodexCredentialBlob | undefined {
|
||||
if (isBareMode()) return undefined
|
||||
|
||||
try {
|
||||
const data = getCodexSecureStorage().read()
|
||||
return normalizeCodexCredentialBlob(data?.codex)
|
||||
} catch {
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
export async function readCodexCredentialsAsync(): Promise<
|
||||
CodexCredentialBlob | undefined
|
||||
> {
|
||||
if (isBareMode()) return undefined
|
||||
|
||||
try {
|
||||
const data = await getCodexSecureStorage().readAsync()
|
||||
return normalizeCodexCredentialBlob(data?.codex)
|
||||
} catch {
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
export function isCodexRefreshFailureCoolingDown(
|
||||
blob: Pick<CodexCredentialBlob, 'lastRefreshFailureAt'>,
|
||||
now = Date.now(),
|
||||
): boolean {
|
||||
return isWithinRefreshFailureCooldown(
|
||||
blob as CodexCredentialBlob,
|
||||
now,
|
||||
)
|
||||
}
|
||||
|
||||
export function saveCodexCredentials(
|
||||
credentials: CodexCredentialBlob,
|
||||
): { success: boolean; warning?: string } {
|
||||
if (isBareMode()) {
|
||||
return { success: false, warning: 'Bare mode: secure storage is disabled.' }
|
||||
}
|
||||
|
||||
const normalized = normalizeCodexCredentialBlob(credentials)
|
||||
if (!normalized) {
|
||||
return { success: false, warning: 'Codex credentials are incomplete.' }
|
||||
}
|
||||
|
||||
const secureStorage = getCodexSecureStorage()
|
||||
const previous = secureStorage.read() || {}
|
||||
const previousCodex = normalizeCodexCredentialBlob(previous[CODEX_STORAGE_KEY])
|
||||
const next = {
|
||||
...(previous as Record<string, unknown>),
|
||||
[CODEX_STORAGE_KEY]: {
|
||||
...normalized,
|
||||
profileId: normalized.profileId ?? previousCodex?.profileId,
|
||||
lastRefreshAt: normalized.lastRefreshAt ?? Date.now(),
|
||||
},
|
||||
}
|
||||
const result = secureStorage.update(next as typeof previous)
|
||||
if (result.success) {
|
||||
const storedCodex = normalizeCodexCredentialBlob(next[CODEX_STORAGE_KEY])
|
||||
inMemoryLastRefreshFailureAt = storedCodex?.lastRefreshFailureAt ?? null
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
export function attachCodexProfileIdToStoredCredentials(profileId: string): {
|
||||
success: boolean
|
||||
warning?: string
|
||||
} {
|
||||
if (isBareMode()) {
|
||||
return { success: false, warning: 'Bare mode: secure storage is disabled.' }
|
||||
}
|
||||
|
||||
const current = readCodexCredentials()
|
||||
if (!current) {
|
||||
return {
|
||||
success: false,
|
||||
warning: 'Codex credentials are not stored securely yet.',
|
||||
}
|
||||
}
|
||||
|
||||
return saveCodexCredentials({
|
||||
...current,
|
||||
profileId,
|
||||
})
|
||||
}
|
||||
|
||||
function persistCodexRefreshFailure(
|
||||
credentials: CodexCredentialBlob,
|
||||
occurredAt: number,
|
||||
): void {
|
||||
const result = saveCodexCredentials({
|
||||
...credentials,
|
||||
lastRefreshFailureAt: occurredAt,
|
||||
})
|
||||
if (!result.success) {
|
||||
inMemoryLastRefreshFailureAt = occurredAt
|
||||
}
|
||||
}
|
||||
|
||||
export function clearCodexCredentials(): {
|
||||
success: boolean
|
||||
warning?: string
|
||||
} {
|
||||
if (isBareMode()) {
|
||||
return { success: true }
|
||||
}
|
||||
|
||||
const secureStorage = getCodexSecureStorage()
|
||||
const previous = secureStorage.read() || {}
|
||||
const next = { ...(previous as Record<string, unknown>) }
|
||||
delete next[CODEX_STORAGE_KEY]
|
||||
const result = secureStorage.update(next as typeof previous)
|
||||
if (result.success) {
|
||||
inMemoryLastRefreshFailureAt = null
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
export async function refreshCodexAccessTokenIfNeeded(options?: {
|
||||
force?: boolean
|
||||
}): Promise<{
|
||||
refreshed: boolean
|
||||
credentials?: CodexCredentialBlob
|
||||
}> {
|
||||
if (isBareMode()) {
|
||||
return { refreshed: false }
|
||||
}
|
||||
|
||||
if (process.env.CODEX_API_KEY?.trim()) {
|
||||
return { refreshed: false }
|
||||
}
|
||||
|
||||
const current = await readCodexCredentialsAsync()
|
||||
if (!current) {
|
||||
return { refreshed: false }
|
||||
}
|
||||
|
||||
if (!current.refreshToken) {
|
||||
return { refreshed: false, credentials: current }
|
||||
}
|
||||
|
||||
if (!options?.force && !shouldRefreshCodexToken(current)) {
|
||||
return { refreshed: false, credentials: current }
|
||||
}
|
||||
|
||||
if (!options?.force && isWithinRefreshFailureCooldown(current)) {
|
||||
return { refreshed: false, credentials: current }
|
||||
}
|
||||
|
||||
if (inFlightCodexRefresh) {
|
||||
return inFlightCodexRefresh
|
||||
}
|
||||
|
||||
inFlightCodexRefresh = (async () => {
|
||||
const refreshAttemptedAt = Date.now()
|
||||
|
||||
try {
|
||||
const body = new URLSearchParams({
|
||||
client_id: getCodexOAuthClientId(),
|
||||
grant_type: 'refresh_token',
|
||||
refresh_token: current.refreshToken,
|
||||
})
|
||||
|
||||
const response = await fetch(CODEX_REFRESH_URL, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
},
|
||||
body,
|
||||
signal: AbortSignal.timeout(15_000),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const bodyText = await response.text().catch(() => '')
|
||||
throw new Error(getRefreshErrorMessage(response.status, bodyText))
|
||||
}
|
||||
|
||||
const payload = (await response.json()) as CodexTokenRefreshResponse
|
||||
const accessToken = asTrimmedString(payload.access_token)
|
||||
if (!accessToken) {
|
||||
throw new Error(
|
||||
'Codex token refresh succeeded without a new access token.',
|
||||
)
|
||||
}
|
||||
|
||||
const next: CodexCredentialBlob = {
|
||||
accessToken,
|
||||
refreshToken:
|
||||
asTrimmedString(payload.refresh_token) ?? current.refreshToken,
|
||||
idToken: asTrimmedString(payload.id_token) ?? current.idToken,
|
||||
accountId:
|
||||
parseChatgptAccountId(payload.id_token) ??
|
||||
parseChatgptAccountId(payload.access_token) ??
|
||||
current.accountId,
|
||||
lastRefreshAt: Date.now(),
|
||||
}
|
||||
|
||||
const idTokenForExchange = next.idToken ?? current.idToken
|
||||
if (idTokenForExchange) {
|
||||
next.apiKey = await exchangeCodexIdTokenForApiKey(
|
||||
idTokenForExchange,
|
||||
).catch(() => undefined)
|
||||
}
|
||||
|
||||
const saveResult = saveCodexCredentials(next)
|
||||
if (!saveResult.success) {
|
||||
throw new Error(
|
||||
saveResult.warning ??
|
||||
'Codex token refresh succeeded but credentials could not be saved.',
|
||||
)
|
||||
}
|
||||
|
||||
return {
|
||||
refreshed: true,
|
||||
credentials: next,
|
||||
}
|
||||
} catch (error) {
|
||||
persistCodexRefreshFailure(current, refreshAttemptedAt)
|
||||
throw error
|
||||
} finally {
|
||||
inFlightCodexRefresh = null
|
||||
}
|
||||
})()
|
||||
|
||||
return inFlightCodexRefresh
|
||||
}
|
||||
65
src/utils/effort.codex.test.ts
Normal file
65
src/utils/effort.codex.test.ts
Normal file
@@ -0,0 +1,65 @@
|
||||
import { afterEach, expect, mock, test } from 'bun:test'
|
||||
|
||||
afterEach(() => {
|
||||
mock.restore()
|
||||
})
|
||||
|
||||
async function importFreshEffortModule(options: {
|
||||
provider: 'codex' | 'openai'
|
||||
supportsCodexReasoningEffort: boolean
|
||||
}) {
|
||||
mock.module('./model/providers.js', () => ({
|
||||
getAPIProvider: () => options.provider,
|
||||
}))
|
||||
mock.module('./model/modelSupportOverrides.js', () => ({
|
||||
get3PModelCapabilityOverride: () => undefined,
|
||||
}))
|
||||
mock.module('../services/api/providerConfig.js', () => ({
|
||||
supportsCodexReasoningEffort: () => options.supportsCodexReasoningEffort,
|
||||
}))
|
||||
|
||||
return import(`./effort.js?ts=${Date.now()}-${Math.random()}`)
|
||||
}
|
||||
|
||||
test('gpt-5.4 on the ChatGPT Codex backend supports effort selection', async () => {
|
||||
const { getAvailableEffortLevels, modelSupportsEffort } =
|
||||
await importFreshEffortModule({
|
||||
provider: 'codex',
|
||||
supportsCodexReasoningEffort: true,
|
||||
})
|
||||
|
||||
expect(modelSupportsEffort('gpt-5.4')).toBe(true)
|
||||
expect(getAvailableEffortLevels('gpt-5.4')).toEqual([
|
||||
'low',
|
||||
'medium',
|
||||
'high',
|
||||
'xhigh',
|
||||
])
|
||||
})
|
||||
|
||||
test('gpt-5.4 on the OpenAI provider still supports effort selection', async () => {
|
||||
const { getAvailableEffortLevels, modelSupportsEffort } =
|
||||
await importFreshEffortModule({
|
||||
provider: 'openai',
|
||||
supportsCodexReasoningEffort: true,
|
||||
})
|
||||
|
||||
expect(modelSupportsEffort('gpt-5.4')).toBe(true)
|
||||
expect(getAvailableEffortLevels('gpt-5.4')).toEqual([
|
||||
'low',
|
||||
'medium',
|
||||
'high',
|
||||
'xhigh',
|
||||
])
|
||||
})
|
||||
|
||||
test('gpt-5.3-codex-spark stays without effort controls', async () => {
|
||||
const { getAvailableEffortLevels, modelSupportsEffort } =
|
||||
await importFreshEffortModule({
|
||||
provider: 'codex',
|
||||
supportsCodexReasoningEffort: false,
|
||||
})
|
||||
|
||||
expect(modelSupportsEffort('gpt-5.3-codex-spark')).toBe(false)
|
||||
expect(getAvailableEffortLevels('gpt-5.3-codex-spark')).toEqual([])
|
||||
})
|
||||
@@ -5,6 +5,7 @@ import { isProSubscriber, isMaxSubscriber, isTeamSubscriber } from './auth.js'
|
||||
import { getFeatureValue_CACHED_MAY_BE_STALE } from 'src/services/analytics/growthbook.js'
|
||||
import { getAPIProvider } from './model/providers.js'
|
||||
import { get3PModelCapabilityOverride } from './model/modelSupportOverrides.js'
|
||||
import { supportsCodexReasoningEffort } from '../services/api/providerConfig.js'
|
||||
import { isEnvTruthy } from './envUtils.js'
|
||||
import type { EffortLevel } from 'src/entrypoints/sdk/runtimeTypes.js'
|
||||
|
||||
@@ -37,6 +38,9 @@ export function modelSupportsEffort(model: string): boolean {
|
||||
if (supported3P !== undefined) {
|
||||
return supported3P
|
||||
}
|
||||
if (modelUsesOpenAIEffort(model) && supportsCodexReasoningEffort(model)) {
|
||||
return true
|
||||
}
|
||||
// Supported by a subset of Claude 4 models
|
||||
if (m.includes('opus-4-6') || m.includes('sonnet-4-6')) {
|
||||
return true
|
||||
@@ -86,6 +90,9 @@ export function modelUsesOpenAIEffort(model: string): boolean {
|
||||
}
|
||||
|
||||
export function getAvailableEffortLevels(model: string): EffortLevel[] | OpenAIEffortLevel[] {
|
||||
if (!modelSupportsEffort(model)) {
|
||||
return []
|
||||
}
|
||||
if (modelUsesOpenAIEffort(model)) {
|
||||
return [...OPENAI_EFFORT_LEVELS] as OpenAIEffortLevel[]
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import test from 'node:test'
|
||||
|
||||
import { DEFAULT_CODEX_BASE_URL } from '../services/api/providerConfig.ts'
|
||||
import {
|
||||
buildStartupEnvFromProfile,
|
||||
buildAtomicChatProfileEnv,
|
||||
@@ -12,7 +13,9 @@ import {
|
||||
buildLaunchEnv,
|
||||
buildOllamaProfileEnv,
|
||||
buildOpenAIProfileEnv,
|
||||
clearPersistedCodexOAuthProfile,
|
||||
createProfileFile,
|
||||
isPersistedCodexOAuthProfile,
|
||||
maskSecretForDisplay,
|
||||
loadProfileFile,
|
||||
PROFILE_FILE_NAME,
|
||||
@@ -23,6 +26,13 @@ import {
|
||||
type ProfileFile,
|
||||
} from './providerProfile.ts'
|
||||
|
||||
function makeJwt(payload: Record<string, unknown>): string {
|
||||
const header = Buffer.from(JSON.stringify({ alg: 'none', typ: 'JWT' }))
|
||||
.toString('base64url')
|
||||
const body = Buffer.from(JSON.stringify(payload)).toString('base64url')
|
||||
return `${header}.${body}.signature`
|
||||
}
|
||||
|
||||
function profile(profile: ProfileFile['profile'], env: ProfileFile['env']): ProfileFile {
|
||||
return {
|
||||
profile,
|
||||
@@ -330,6 +340,7 @@ test('codex profiles accept explicit codex credentials', () => {
|
||||
assert.deepEqual(env, {
|
||||
OPENAI_BASE_URL: 'https://chatgpt.com/backend-api/codex',
|
||||
OPENAI_MODEL: 'codexspark',
|
||||
CODEX_CREDENTIAL_SOURCE: 'existing',
|
||||
CODEX_API_KEY: 'codex-live',
|
||||
CHATGPT_ACCOUNT_ID: 'acct_123',
|
||||
})
|
||||
@@ -417,6 +428,77 @@ test('saveProfileFile writes a profile that loadProfileFile can read back', () =
|
||||
}
|
||||
})
|
||||
|
||||
test('buildCodexProfileEnv tags OAuth-saved profiles so logout can remove them safely', () => {
|
||||
const env = buildCodexProfileEnv({
|
||||
model: 'codexplan',
|
||||
apiKey: makeJwt({
|
||||
'https://api.openai.com/auth': {
|
||||
chatgpt_account_id: 'acct_oauth',
|
||||
},
|
||||
}),
|
||||
credentialSource: 'oauth',
|
||||
processEnv: {},
|
||||
})
|
||||
|
||||
assert.deepEqual(env, {
|
||||
OPENAI_BASE_URL: DEFAULT_CODEX_BASE_URL,
|
||||
OPENAI_MODEL: 'codexplan',
|
||||
CODEX_CREDENTIAL_SOURCE: 'oauth',
|
||||
CODEX_API_KEY: makeJwt({
|
||||
'https://api.openai.com/auth': {
|
||||
chatgpt_account_id: 'acct_oauth',
|
||||
},
|
||||
}),
|
||||
CHATGPT_ACCOUNT_ID: 'acct_oauth',
|
||||
})
|
||||
})
|
||||
|
||||
test('clearPersistedCodexOAuthProfile removes only persisted Codex OAuth profiles', async () => {
|
||||
const cwd = mkdtempSync(join(tmpdir(), 'openclaude-codex-oauth-profile-'))
|
||||
|
||||
try {
|
||||
const providerProfileModule = await import(
|
||||
`./providerProfile.ts?ts=${Date.now()}-${Math.random()}`
|
||||
)
|
||||
const {
|
||||
PROFILE_FILE_NAME,
|
||||
clearPersistedCodexOAuthProfile,
|
||||
createProfileFile,
|
||||
isPersistedCodexOAuthProfile,
|
||||
loadProfileFile,
|
||||
saveProfileFile,
|
||||
} = providerProfileModule
|
||||
const oauthProfile = createProfileFile('codex', {
|
||||
OPENAI_MODEL: 'codexplan',
|
||||
OPENAI_BASE_URL: DEFAULT_CODEX_BASE_URL,
|
||||
CHATGPT_ACCOUNT_ID: 'acct_oauth',
|
||||
CODEX_CREDENTIAL_SOURCE: 'oauth',
|
||||
})
|
||||
saveProfileFile(oauthProfile, { cwd })
|
||||
|
||||
assert.equal(isPersistedCodexOAuthProfile(loadProfileFile({ cwd })), true)
|
||||
assert.equal(
|
||||
clearPersistedCodexOAuthProfile({ cwd }),
|
||||
join(cwd, PROFILE_FILE_NAME),
|
||||
)
|
||||
assert.equal(loadProfileFile({ cwd }), null)
|
||||
|
||||
const existingCredentialProfile = createProfileFile('codex', {
|
||||
OPENAI_MODEL: 'codexplan',
|
||||
OPENAI_BASE_URL: DEFAULT_CODEX_BASE_URL,
|
||||
CHATGPT_ACCOUNT_ID: 'acct_existing',
|
||||
CODEX_CREDENTIAL_SOURCE: 'existing',
|
||||
})
|
||||
saveProfileFile(existingCredentialProfile, { cwd })
|
||||
|
||||
assert.equal(isPersistedCodexOAuthProfile(loadProfileFile({ cwd })), false)
|
||||
assert.equal(clearPersistedCodexOAuthProfile({ cwd }), null)
|
||||
assert.deepEqual(loadProfileFile({ cwd }), existingCredentialProfile)
|
||||
} finally {
|
||||
rmSync(cwd, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
test('buildStartupEnvFromProfile applies persisted gemini settings when no provider is explicitly selected', async () => {
|
||||
const env = await buildStartupEnvFromProfile({
|
||||
persisted: profile('gemini', {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
107
src/utils/providerSecrets.ts
Normal file
107
src/utils/providerSecrets.ts
Normal file
@@ -0,0 +1,107 @@
|
||||
const SECRET_ENV_KEYS = [
|
||||
'OPENAI_API_KEY',
|
||||
'CODEX_API_KEY',
|
||||
'GEMINI_API_KEY',
|
||||
'GOOGLE_API_KEY',
|
||||
'MISTRAL_API_KEY',
|
||||
] as const
|
||||
|
||||
export type SecretValueSource = Partial<
|
||||
Record<(typeof SECRET_ENV_KEYS)[number], string | undefined>
|
||||
>
|
||||
|
||||
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
|
||||
}
|
||||
@@ -6,11 +6,13 @@ import {
|
||||
resolveProviderRequest,
|
||||
} from '../services/api/providerConfig.js'
|
||||
import { getGlobalClaudeFile } from './env.js'
|
||||
import { isBareMode } from './envUtils.js'
|
||||
import {
|
||||
type GeminiResolvedCredential,
|
||||
resolveGeminiCredential,
|
||||
} from './geminiAuth.js'
|
||||
import { PROFILE_FILE_NAME, redactSecretValueForDisplay } from './providerProfile.js'
|
||||
import { PROFILE_FILE_NAME } from './providerProfile.js'
|
||||
import { redactSecretValueForDisplay } from './providerSecrets.js'
|
||||
|
||||
function isEnvTruthy(value: string | undefined): boolean {
|
||||
if (!value) return false
|
||||
@@ -82,6 +84,7 @@ export async function getProviderValidationError(
|
||||
) => Promise<GeminiResolvedCredential>
|
||||
},
|
||||
): Promise<string | null> {
|
||||
const secretSource = env
|
||||
const useOpenAI = isEnvTruthy(env.CLAUDE_CODE_USE_OPENAI)
|
||||
const useGithub = isEnvTruthy(env.CLAUDE_CODE_USE_GITHUB)
|
||||
|
||||
@@ -131,16 +134,17 @@ export async function getProviderValidationError(
|
||||
if (request.transport === 'codex_responses') {
|
||||
const credentials = resolveCodexApiCredentials(env)
|
||||
if (!credentials.apiKey) {
|
||||
const oauthHint = isBareMode() ? '' : ', choose Codex OAuth in /provider'
|
||||
const authHint = credentials.authPath
|
||||
? ` or put auth.json at ${credentials.authPath}`
|
||||
: ''
|
||||
? `${oauthHint} or put auth.json at ${credentials.authPath}`
|
||||
: oauthHint
|
||||
const safeModel =
|
||||
redactSecretValueForDisplay(request.requestedModel, env) ??
|
||||
redactSecretValueForDisplay(request.requestedModel, secretSource) ??
|
||||
'the requested model'
|
||||
return `Codex auth is required for ${safeModel}. Set CODEX_API_KEY${authHint}.`
|
||||
}
|
||||
if (!credentials.accountId) {
|
||||
return 'Codex auth is missing chatgpt_account_id. Re-login with Codex or set CHATGPT_ACCOUNT_ID/CODEX_ACCOUNT_ID.'
|
||||
return 'Codex auth is missing chatgpt_account_id. Re-login with Codex OAuth, Codex CLI, or set CHATGPT_ACCOUNT_ID/CODEX_ACCOUNT_ID.'
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
@@ -5,6 +5,16 @@ import { windowsCredentialStorage } from './windowsCredentialStorage.js'
|
||||
import { plainTextStorage } from './plainTextStorage.js'
|
||||
|
||||
export interface SecureStorageData {
|
||||
codex?: {
|
||||
apiKey?: string
|
||||
accessToken: string
|
||||
refreshToken?: string
|
||||
idToken?: string
|
||||
accountId?: string
|
||||
profileId?: string
|
||||
lastRefreshAt?: number
|
||||
lastRefreshFailureAt?: number
|
||||
}
|
||||
mcpOAuth?: Record<
|
||||
string,
|
||||
{
|
||||
@@ -36,22 +46,44 @@ export interface SecureStorage {
|
||||
delete(): boolean
|
||||
}
|
||||
|
||||
const unavailableSecureStorage: SecureStorage = {
|
||||
name: 'unavailable-secure-storage',
|
||||
read: () => null,
|
||||
readAsync: async () => null,
|
||||
update: () => ({
|
||||
success: false,
|
||||
warning:
|
||||
'Secure storage is unavailable on this platform without plaintext fallback.',
|
||||
}),
|
||||
delete: () => true,
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the appropriate secure storage implementation for the current platform.
|
||||
* Prefers native OS vaults (Keychain, libsecret, Credential Locker) with a plaintext fallback.
|
||||
*/
|
||||
export function getSecureStorage(): SecureStorage {
|
||||
export function getSecureStorage(options?: {
|
||||
allowPlainTextFallback?: boolean
|
||||
}): SecureStorage {
|
||||
const allowPlainTextFallback = options?.allowPlainTextFallback ?? true
|
||||
|
||||
if (process.platform === 'darwin') {
|
||||
return createFallbackStorage(macOsKeychainStorage, plainTextStorage)
|
||||
return allowPlainTextFallback
|
||||
? createFallbackStorage(macOsKeychainStorage, plainTextStorage)
|
||||
: macOsKeychainStorage
|
||||
}
|
||||
|
||||
if (process.platform === 'linux') {
|
||||
return createFallbackStorage(linuxSecretStorage, plainTextStorage)
|
||||
return allowPlainTextFallback
|
||||
? createFallbackStorage(linuxSecretStorage, plainTextStorage)
|
||||
: linuxSecretStorage
|
||||
}
|
||||
|
||||
if (process.platform === 'win32') {
|
||||
return createFallbackStorage(windowsCredentialStorage, plainTextStorage)
|
||||
return allowPlainTextFallback
|
||||
? createFallbackStorage(windowsCredentialStorage, plainTextStorage)
|
||||
: windowsCredentialStorage
|
||||
}
|
||||
|
||||
return plainTextStorage
|
||||
return allowPlainTextFallback ? plainTextStorage : unavailableSecureStorage
|
||||
}
|
||||
|
||||
@@ -64,8 +64,10 @@ describe("Secure Storage Platform Implementations", () => {
|
||||
windowsCredentialStorage.update(testData);
|
||||
|
||||
const script = mockExecaSync.mock.calls[0][1][1];
|
||||
const options = mockExecaSync.mock.calls[0][2];
|
||||
expect(script).toContain(expectedName);
|
||||
expect(script).toContain("Add-Type -AssemblyName System.Runtime.WindowsRuntime");
|
||||
expect(script).toContain("ProtectedData");
|
||||
expect(options.input).toContain("secret-token");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -85,32 +87,54 @@ describe("Secure Storage Platform Implementations", () => {
|
||||
windowsCredentialStorage.update(dataWithDollar);
|
||||
|
||||
const script = mockExecaSync.mock.calls[0][1][1];
|
||||
// Should use single quotes for the payload
|
||||
expect(script).toMatch(/'\{.*\}'/);
|
||||
// Should escape ' by doubling it
|
||||
expect(script).not.toContain("'token-with-$env:USERNAME'");
|
||||
// But since it's JSON, the value will be "token-with-$env:USERNAME" inside the single-quoted string
|
||||
// The JSON itself shouldn't have single quotes unless the data has them.
|
||||
const options = mockExecaSync.mock.calls[0][2];
|
||||
expect(script).toContain("[Console]::In.ReadToEnd()");
|
||||
expect(options.input).toContain("token-with-$env:USERNAME");
|
||||
|
||||
const dataWithQuote = { mcpOAuth: { "s": { accessToken: "token'quote", expiresAt: 1, serverName: "s", serverUrl: "u" } } };
|
||||
windowsCredentialStorage.update(dataWithQuote);
|
||||
const script2 = mockExecaSync.mock.calls[1][1][1];
|
||||
expect(script2).toContain("token''quote");
|
||||
const options2 = mockExecaSync.mock.calls[1][2];
|
||||
expect(options2.input).toContain("token'quote");
|
||||
});
|
||||
|
||||
test("delete() includes assembly load", () => {
|
||||
windowsCredentialStorage.delete();
|
||||
const script = mockExecaSync.mock.calls[0][1][1];
|
||||
const script = mockExecaSync.mock.calls[1][1][1];
|
||||
expect(script).toContain("Add-Type -AssemblyName System.Runtime.WindowsRuntime");
|
||||
});
|
||||
|
||||
test("escapes double quotes in username", () => {
|
||||
process.env.USER = 'user"name';
|
||||
windowsCredentialStorage.read();
|
||||
const script = mockExecaSync.mock.calls[0][1][1];
|
||||
const script = mockExecaSync.mock.calls[1][1][1];
|
||||
expect(script).toContain('user`"name');
|
||||
expect(script).not.toContain('user"name');
|
||||
});
|
||||
|
||||
test("read() falls back to legacy PasswordVault when the DPAPI payload is invalid JSON", () => {
|
||||
mockExecaSync
|
||||
.mockImplementationOnce(() => ({ exitCode: 0, stdout: "{not-json" }))
|
||||
.mockImplementationOnce(() => ({
|
||||
exitCode: 0,
|
||||
stdout: JSON.stringify(testData),
|
||||
}));
|
||||
|
||||
const result = windowsCredentialStorage.read();
|
||||
|
||||
expect(result).toEqual(testData);
|
||||
expect(mockExecaSync).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
test("read() fails closed when the legacy PasswordVault payload is invalid JSON", () => {
|
||||
mockExecaSync
|
||||
.mockImplementationOnce(() => ({ exitCode: 1, stdout: "" }))
|
||||
.mockImplementationOnce(() => ({ exitCode: 0, stdout: "{not-json" }));
|
||||
|
||||
const result = windowsCredentialStorage.read();
|
||||
|
||||
expect(result).toBeNull();
|
||||
expect(mockExecaSync).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Linux secret-tool Interaction", () => {
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import { execaSync } from 'execa'
|
||||
import { join } from 'path'
|
||||
import { getClaudeConfigHomeDir } from '../envUtils.js'
|
||||
import { jsonParse, jsonStringify } from '../slowOperations.js'
|
||||
import {
|
||||
CREDENTIALS_SERVICE_SUFFIX,
|
||||
@@ -8,90 +10,216 @@ import {
|
||||
import type { SecureStorage, SecureStorageData } from './index.js'
|
||||
|
||||
/**
|
||||
* Windows-specific secure storage implementation using the Windows Credential Locker.
|
||||
* Accessed via PowerShell's [Windows.Security.Credentials.PasswordVault].
|
||||
* Windows-specific secure storage implementation using DPAPI for new writes,
|
||||
* with best-effort reads/deletes from the legacy PasswordVault path.
|
||||
*/
|
||||
export const windowsCredentialStorage: SecureStorage = {
|
||||
name: 'credential-locker',
|
||||
read(): SecureStorageData | null {
|
||||
const resourceName = getSecureStorageServiceName(
|
||||
CREDENTIALS_SERVICE_SUFFIX,
|
||||
).replace(/"/g, '`"')
|
||||
const username = getUsername().replace(/"/g, '`"')
|
||||
// PowerShell script to retrieve password from vault
|
||||
const script = `
|
||||
Add-Type -AssemblyName System.Runtime.WindowsRuntime
|
||||
function escapePowerShellSingleQuoted(value: string): string {
|
||||
return value.replace(/'/g, "''")
|
||||
}
|
||||
|
||||
function getLegacyResourceName(): string {
|
||||
return getSecureStorageServiceName(CREDENTIALS_SERVICE_SUFFIX)
|
||||
}
|
||||
|
||||
function getWindowsSecureStorageEntropy(): string {
|
||||
return `${getLegacyResourceName()}:${getUsername()}`
|
||||
}
|
||||
|
||||
function getWindowsSecureStorageFilePath(): string {
|
||||
const resourceName = getLegacyResourceName().replace(/[^a-zA-Z0-9._-]/g, '_')
|
||||
return join(getClaudeConfigHomeDir(), `${resourceName}.secure.dpapi`)
|
||||
}
|
||||
|
||||
function runPowerShell(
|
||||
script: string,
|
||||
options?: { input?: string },
|
||||
): ReturnType<typeof execaSync> | null {
|
||||
try {
|
||||
return execaSync('powershell.exe', ['-Command', script], {
|
||||
input: options?.input,
|
||||
reject: false,
|
||||
})
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function getFailureWarning(
|
||||
result: ReturnType<typeof execaSync> | null,
|
||||
fallback: string,
|
||||
): string {
|
||||
const stderr = result?.stderr?.trim()
|
||||
if (stderr) {
|
||||
return stderr
|
||||
}
|
||||
|
||||
if (typeof result?.exitCode === 'number' && result.exitCode !== 0) {
|
||||
return `${fallback} (exit code ${result.exitCode}).`
|
||||
}
|
||||
|
||||
return fallback
|
||||
}
|
||||
|
||||
function readLegacyPasswordVault(): SecureStorageData | null {
|
||||
const resourceName = getLegacyResourceName().replace(/"/g, '`"')
|
||||
const username = getUsername().replace(/"/g, '`"')
|
||||
const script = `
|
||||
Add-Type -AssemblyName System.Runtime.WindowsRuntime
|
||||
try {
|
||||
$vault = New-Object Windows.Security.Credentials.PasswordVault
|
||||
$cred = $vault.Retrieve("${resourceName}", "${username}")
|
||||
$cred.FillPassword()
|
||||
[Console]::Out.Write($cred.Password)
|
||||
} catch {
|
||||
exit 1
|
||||
}
|
||||
`
|
||||
|
||||
const result = runPowerShell(script)
|
||||
if (result?.exitCode === 0 && result.stdout) {
|
||||
try {
|
||||
return jsonParse(result.stdout)
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
export const windowsCredentialStorage: SecureStorage = {
|
||||
name: 'credential-locker-dpapi',
|
||||
read(): SecureStorageData | null {
|
||||
const filePath = escapePowerShellSingleQuoted(
|
||||
getWindowsSecureStorageFilePath(),
|
||||
)
|
||||
const entropy = escapePowerShellSingleQuoted(
|
||||
getWindowsSecureStorageEntropy(),
|
||||
)
|
||||
const script = `
|
||||
try {
|
||||
$cred = $vault.Retrieve("${resourceName}", "${username}")
|
||||
$cred.FillPassword()
|
||||
$cred.Password
|
||||
Add-Type -AssemblyName System.Security
|
||||
$path = '${filePath}'
|
||||
if (!(Test-Path -LiteralPath $path)) {
|
||||
exit 1
|
||||
}
|
||||
|
||||
$protectedBase64 = [System.IO.File]::ReadAllText(
|
||||
$path,
|
||||
[System.Text.Encoding]::UTF8
|
||||
).Trim()
|
||||
if (-not $protectedBase64) {
|
||||
exit 1
|
||||
}
|
||||
|
||||
$protectedBytes = [Convert]::FromBase64String($protectedBase64)
|
||||
$entropyBytes = [System.Text.Encoding]::UTF8.GetBytes('${entropy}')
|
||||
$bytes = [System.Security.Cryptography.ProtectedData]::Unprotect(
|
||||
$protectedBytes,
|
||||
$entropyBytes,
|
||||
[System.Security.Cryptography.DataProtectionScope]::CurrentUser
|
||||
)
|
||||
[Console]::Out.Write([System.Text.Encoding]::UTF8.GetString($bytes))
|
||||
} catch {
|
||||
exit 1
|
||||
}
|
||||
`
|
||||
try {
|
||||
const result = execaSync('powershell.exe', ['-Command', script], {
|
||||
reject: false,
|
||||
})
|
||||
if (result.exitCode === 0 && result.stdout) {
|
||||
|
||||
const result = runPowerShell(script)
|
||||
if (result?.exitCode === 0 && result.stdout) {
|
||||
try {
|
||||
return jsonParse(result.stdout)
|
||||
} catch {
|
||||
return readLegacyPasswordVault()
|
||||
}
|
||||
} catch {
|
||||
// fall through
|
||||
}
|
||||
return null
|
||||
|
||||
return readLegacyPasswordVault()
|
||||
},
|
||||
async readAsync(): Promise<SecureStorageData | null> {
|
||||
return this.read()
|
||||
},
|
||||
update(data: SecureStorageData): { success: boolean; warning?: string } {
|
||||
const resourceName = getSecureStorageServiceName(
|
||||
CREDENTIALS_SERVICE_SUFFIX,
|
||||
).replace(/"/g, '`"')
|
||||
const username = getUsername().replace(/"/g, '`"')
|
||||
// Use single quotes for the payload and escape ' by doubling it ('').
|
||||
// This prevents PowerShell from expanding $... inside the string.
|
||||
const payload = jsonStringify(data).replace(/'/g, "''")
|
||||
// PowerShell script to add/update credential in vault
|
||||
const filePath = escapePowerShellSingleQuoted(
|
||||
getWindowsSecureStorageFilePath(),
|
||||
)
|
||||
const entropy = escapePowerShellSingleQuoted(
|
||||
getWindowsSecureStorageEntropy(),
|
||||
)
|
||||
const payload = jsonStringify(data)
|
||||
const script = `
|
||||
Add-Type -AssemblyName System.Runtime.WindowsRuntime
|
||||
$vault = New-Object Windows.Security.Credentials.PasswordVault
|
||||
$cred = New-Object Windows.Security.Credentials.PasswordCredential("${resourceName}", "${username}", '${payload}')
|
||||
$vault.Add($cred)
|
||||
try {
|
||||
Add-Type -AssemblyName System.Security
|
||||
$path = '${filePath}'
|
||||
$directory = [System.IO.Path]::GetDirectoryName($path)
|
||||
if ($directory) {
|
||||
[System.IO.Directory]::CreateDirectory($directory) | Out-Null
|
||||
}
|
||||
|
||||
$payload = [Console]::In.ReadToEnd()
|
||||
$bytes = [System.Text.Encoding]::UTF8.GetBytes($payload)
|
||||
$entropyBytes = [System.Text.Encoding]::UTF8.GetBytes('${entropy}')
|
||||
$protectedBytes = [System.Security.Cryptography.ProtectedData]::Protect(
|
||||
$bytes,
|
||||
$entropyBytes,
|
||||
[System.Security.Cryptography.DataProtectionScope]::CurrentUser
|
||||
)
|
||||
$protectedBase64 = [Convert]::ToBase64String($protectedBytes)
|
||||
[System.IO.File]::WriteAllText(
|
||||
$path,
|
||||
$protectedBase64,
|
||||
[System.Text.Encoding]::UTF8
|
||||
)
|
||||
} catch {
|
||||
Write-Error $_.Exception.Message
|
||||
exit 1
|
||||
}
|
||||
`
|
||||
try {
|
||||
const result = execaSync('powershell.exe', ['-Command', script], {
|
||||
reject: false,
|
||||
})
|
||||
return { success: result.exitCode === 0 }
|
||||
} catch {
|
||||
return { success: false }
|
||||
const result = runPowerShell(script, { input: payload })
|
||||
if (result?.exitCode === 0) {
|
||||
return { success: true }
|
||||
}
|
||||
|
||||
return {
|
||||
success: false,
|
||||
warning: getFailureWarning(
|
||||
result,
|
||||
'Windows secure storage could not encrypt credentials with DPAPI',
|
||||
),
|
||||
}
|
||||
},
|
||||
delete(): boolean {
|
||||
const resourceName = getSecureStorageServiceName(
|
||||
CREDENTIALS_SERVICE_SUFFIX,
|
||||
).replace(/"/g, '`"')
|
||||
const username = getUsername().replace(/"/g, '`"')
|
||||
// PowerShell script to remove credential from vault
|
||||
const script = `
|
||||
Add-Type -AssemblyName System.Runtime.WindowsRuntime
|
||||
$vault = New-Object Windows.Security.Credentials.PasswordVault
|
||||
const filePath = escapePowerShellSingleQuoted(
|
||||
getWindowsSecureStorageFilePath(),
|
||||
)
|
||||
const removeDpapiScript = `
|
||||
try {
|
||||
$path = '${filePath}'
|
||||
if (Test-Path -LiteralPath $path) {
|
||||
Remove-Item -LiteralPath $path -Force
|
||||
}
|
||||
} catch {
|
||||
exit 1
|
||||
}
|
||||
`
|
||||
const removeDpapiResult = runPowerShell(removeDpapiScript)
|
||||
|
||||
const resourceName = getLegacyResourceName().replace(/"/g, '`"')
|
||||
const username = getUsername().replace(/"/g, '`"')
|
||||
const removeLegacyScript = `
|
||||
Add-Type -AssemblyName System.Runtime.WindowsRuntime
|
||||
try {
|
||||
$vault = New-Object Windows.Security.Credentials.PasswordVault
|
||||
$cred = $vault.Retrieve("${resourceName}", "${username}")
|
||||
$vault.Remove($cred)
|
||||
} catch {
|
||||
exit 0
|
||||
}
|
||||
`
|
||||
try {
|
||||
const result = execaSync('powershell.exe', ['-Command', script], {
|
||||
reject: false,
|
||||
})
|
||||
return result.exitCode === 0
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
const removeLegacyResult = runPowerShell(removeLegacyScript)
|
||||
|
||||
void removeLegacyResult
|
||||
|
||||
return (removeDpapiResult?.exitCode ?? 1) === 0
|
||||
},
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user