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

* feat: add Codex OAuth provider flow

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

View File

@@ -2,7 +2,7 @@
OpenClaude is an open-source coding-agent CLI for cloud and local model providers. OpenClaude is an open-source coding-agent CLI for cloud and local model providers.
Use OpenAI-compatible APIs, Gemini, GitHub Models, Codex, Ollama, Atomic Chat, and other supported backends while keeping one terminal-first workflow: prompts, tools, agents, MCP, slash commands, and streaming output. Use OpenAI-compatible APIs, Gemini, GitHub Models, Codex OAuth, Codex, Ollama, Atomic Chat, and other supported backends while keeping one terminal-first workflow: prompts, tools, agents, MCP, slash commands, and streaming output.
[![PR Checks](https://github.com/Gitlawb/openclaude/actions/workflows/pr-checks.yml/badge.svg?branch=main)](https://github.com/Gitlawb/openclaude/actions/workflows/pr-checks.yml) [![PR Checks](https://github.com/Gitlawb/openclaude/actions/workflows/pr-checks.yml/badge.svg?branch=main)](https://github.com/Gitlawb/openclaude/actions/workflows/pr-checks.yml)
[![Release](https://img.shields.io/github/v/tag/Gitlawb/openclaude?label=release&color=0ea5e9)](https://github.com/Gitlawb/openclaude/tags) [![Release](https://img.shields.io/github/v/tag/Gitlawb/openclaude?label=release&color=0ea5e9)](https://github.com/Gitlawb/openclaude/tags)
@@ -16,7 +16,7 @@ Use OpenAI-compatible APIs, Gemini, GitHub Models, Codex, Ollama, Atomic Chat, a
- Use one CLI across cloud APIs and local model backends - Use one CLI across cloud APIs and local model backends
- Save provider profiles inside the app with `/provider` - Save provider profiles inside the app with `/provider`
- Run with OpenAI-compatible services, Gemini, GitHub Models, Codex, Ollama, Atomic Chat, and other supported providers - Run with OpenAI-compatible services, Gemini, GitHub Models, Codex OAuth, Codex, Ollama, Atomic Chat, and other supported providers
- Keep coding-agent workflows in one place: bash, file tools, grep, glob, agents, tasks, MCP, and web tools - Keep coding-agent workflows in one place: bash, file tools, grep, glob, agents, tasks, MCP, and web tools
- Use the bundled VS Code extension for launch integration and theme support - Use the bundled VS Code extension for launch integration and theme support
@@ -105,7 +105,8 @@ Advanced and source-build guides:
| OpenAI-compatible | `/provider` or env vars | Works with OpenAI, OpenRouter, DeepSeek, Groq, Mistral, LM Studio, and other compatible `/v1` servers | | OpenAI-compatible | `/provider` or env vars | Works with OpenAI, OpenRouter, DeepSeek, Groq, Mistral, LM Studio, and other compatible `/v1` servers |
| Gemini | `/provider` or env vars | Supports API key, access token, or local ADC workflow on current `main` | | Gemini | `/provider` or env vars | Supports API key, access token, or local ADC workflow on current `main` |
| GitHub Models | `/onboard-github` | Interactive onboarding with saved credentials | | GitHub Models | `/onboard-github` | Interactive onboarding with saved credentials |
| Codex | `/provider` | Uses existing Codex credentials when available | | Codex OAuth | `/provider` | Opens ChatGPT sign-in in your browser and stores Codex credentials securely |
| Codex | `/provider` | Uses existing Codex CLI auth, OpenClaude secure storage, or env credentials |
| Ollama | `/provider` or env vars | Local inference with no API key | | Ollama | `/provider` or env vars | Local inference with no API key |
| Atomic Chat | advanced setup | Local Apple Silicon backend | | Atomic Chat | advanced setup | Local Apple Silicon backend |
| Bedrock / Vertex / Foundry | env vars | Additional provider integrations for supported environments | | Bedrock / Vertex / Foundry | env vars | Additional provider integrations for supported environments |

View File

@@ -48,6 +48,8 @@ export OPENAI_MODEL=gpt-4o
`codexplan` maps to GPT-5.4 on the Codex backend with high reasoning. `codexplan` maps to GPT-5.4 on the Codex backend with high reasoning.
`codexspark` maps to GPT-5.3 Codex Spark for faster loops. `codexspark` maps to GPT-5.3 Codex Spark for faster loops.
If you use the in-app provider wizard, choose `Codex OAuth` to open ChatGPT sign-in in your browser and let OpenClaude store Codex credentials securely.
If you already use the Codex CLI, OpenClaude reads `~/.codex/auth.json` automatically. You can also point it elsewhere with `CODEX_AUTH_JSON_PATH` or override the token directly with `CODEX_API_KEY`. If you already use the Codex CLI, OpenClaude reads `~/.codex/auth.json` automatically. You can also point it elsewhere with `CODEX_AUTH_JSON_PATH` or override the token directly with `CODEX_API_KEY`.
```bash ```bash

View File

@@ -1,20 +1,28 @@
import { PassThrough } from 'node:stream' import { PassThrough } from 'node:stream'
import { expect, test } from 'bun:test' import { afterEach, expect, mock, test } from 'bun:test'
import React from 'react' import React from 'react'
import stripAnsi from 'strip-ansi' import stripAnsi from 'strip-ansi'
import { createRoot, render, useApp } from '../../ink.js' import { createRoot, render, useApp } from '../../ink.js'
import { AppStateProvider } from '../../state/AppState.js' import { AppStateProvider } from '../../state/AppState.js'
import { import {
applySavedProfileToCurrentSession,
buildCodexOAuthProfileEnv,
buildCurrentProviderSummary, buildCurrentProviderSummary,
buildProfileSaveMessage, buildProfileSaveMessage,
getProviderWizardDefaults, getProviderWizardDefaults,
ProviderWizard,
TextEntryDialog, TextEntryDialog,
} from './provider.js' } from './provider.js'
import { createProfileFile } from '../../utils/providerProfile.js'
const SYNC_START = '\x1B[?2026h' const SYNC_START = '\x1B[?2026h'
const SYNC_END = '\x1B[?2026l' 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 { function extractLastFrame(output: string): string {
let lastFrame: string | null = null let lastFrame: string | null = null
@@ -60,6 +68,51 @@ async function renderFinalFrame(node: React.ReactNode): Promise<string> {
return stripAnsi(extractLastFrame(getOutput())) 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(): { function createTestStreams(): {
stdout: PassThrough stdout: PassThrough
stdin: 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 { function StepChangeHarness(): React.ReactNode {
const { exit } = useApp() const { exit } = useApp()
const [step, setStep] = React.useState<'api' | 'model'>('api') 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') 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', () => { test('buildCurrentProviderSummary redacts poisoned model and endpoint values', () => {
const summary = buildCurrentProviderSummary({ const summary = buildCurrentProviderSummary({
processEnv: { processEnv: {
@@ -307,3 +549,12 @@ test('getProviderWizardDefaults ignores poisoned current provider values', () =>
expect(defaults.openAIBaseUrl).toBe('https://api.openai.com/v1') expect(defaults.openAIBaseUrl).toBe('https://api.openai.com/v1')
expect(defaults.geminiModel).toBe('gemini-2.0-flash') 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')
})

View File

@@ -10,8 +10,12 @@ import {
} from '../../components/CustomSelect/index.js' } from '../../components/CustomSelect/index.js'
import { Dialog } from '../../components/design-system/Dialog.js' import { Dialog } from '../../components/design-system/Dialog.js'
import { LoadingState } from '../../components/design-system/LoadingState.js' import { LoadingState } from '../../components/design-system/LoadingState.js'
import { useCodexOAuthFlow } from '../../components/useCodexOAuthFlow.js'
import { useTerminalSize } from '../../hooks/useTerminalSize.js' import { useTerminalSize } from '../../hooks/useTerminalSize.js'
import { Box, Text } from '../../ink.js' import { Box, Text } from '../../ink.js'
import {
type CodexOAuthTokens,
} from '../../services/api/codexOAuth.js'
import { import {
DEFAULT_CODEX_BASE_URL, DEFAULT_CODEX_BASE_URL,
DEFAULT_OPENAI_BASE_URL, DEFAULT_OPENAI_BASE_URL,
@@ -20,6 +24,8 @@ import {
resolveProviderRequest, resolveProviderRequest,
} from '../../services/api/providerConfig.js' } from '../../services/api/providerConfig.js'
import { import {
applySavedProfileToCurrentSession as applySharedProfileToCurrentSession,
buildCodexOAuthProfileEnv as buildSharedCodexOAuthProfileEnv,
buildCodexProfileEnv, buildCodexProfileEnv,
buildGeminiProfileEnv, buildGeminiProfileEnv,
buildMistralProfileEnv, buildMistralProfileEnv,
@@ -49,6 +55,7 @@ import {
readGeminiAccessToken, readGeminiAccessToken,
saveGeminiAccessToken, saveGeminiAccessToken,
} from '../../utils/geminiCredentials.js' } from '../../utils/geminiCredentials.js'
import { isBareMode } from '../../utils/envUtils.js'
import { import {
getGoalDefaultOpenAIModel, getGoalDefaultOpenAIModel,
normalizeRecommendationGoal, normalizeRecommendationGoal,
@@ -57,12 +64,13 @@ import {
type RecommendationGoal, type RecommendationGoal,
} from '../../utils/providerRecommendation.js' } from '../../utils/providerRecommendation.js'
import { import {
getOllamaChatBaseUrl,
getLocalOpenAICompatibleProviderLabel, getLocalOpenAICompatibleProviderLabel,
hasLocalOllama, hasLocalOllama,
listOllamaModels, listOllamaModels,
} from '../../utils/providerDiscovery.js' } from '../../utils/providerDiscovery.js'
type ProviderChoice = 'auto' | ProviderProfile | 'clear' type ProviderChoice = 'auto' | ProviderProfile | 'codex-oauth' | 'clear'
type Step = type Step =
| { name: 'choose' } | { name: 'choose' }
@@ -93,6 +101,7 @@ type Step =
apiKey?: string apiKey?: string
authMode: 'api-key' | 'access-token' | 'adc' authMode: 'api-key' | 'access-token' | 'adc'
} }
| { name: 'codex-oauth' }
| { name: 'codex-check' } | { name: 'codex-check' }
type CurrentProviderSummary = { type CurrentProviderSummary = {
@@ -131,6 +140,8 @@ type ProviderWizardDefaults = {
mistralBaseUrl: string mistralBaseUrl: string
} }
type SecretSourceEnv = NodeJS.ProcessEnv & Partial<ProfileEnv>
function isEnvTruthy(value: string | undefined): boolean { function isEnvTruthy(value: string | undefined): boolean {
if (!value) return false if (!value) return false
const normalized = value.trim().toLowerCase() const normalized = value.trim().toLowerCase()
@@ -139,7 +150,7 @@ function isEnvTruthy(value: string | undefined): boolean {
function getSafeDisplayValue( function getSafeDisplayValue(
value: string | undefined, value: string | undefined,
processEnv: NodeJS.ProcessEnv, processEnv: SecretSourceEnv,
profileEnv?: ProfileEnv, profileEnv?: ProfileEnv,
fallback = '(not set)', fallback = '(not set)',
): string { ): string {
@@ -151,14 +162,15 @@ function getSafeDisplayValue(
export function getProviderWizardDefaults( export function getProviderWizardDefaults(
processEnv: NodeJS.ProcessEnv = process.env, processEnv: NodeJS.ProcessEnv = process.env,
): ProviderWizardDefaults { ): ProviderWizardDefaults {
const secretSource = processEnv as SecretSourceEnv
const safeOpenAIModel = const safeOpenAIModel =
sanitizeProviderConfigValue(processEnv.OPENAI_MODEL, processEnv) || sanitizeProviderConfigValue(processEnv.OPENAI_MODEL, secretSource) ||
'gpt-4o' 'gpt-4o'
const safeOpenAIBaseUrl = const safeOpenAIBaseUrl =
sanitizeProviderConfigValue(processEnv.OPENAI_BASE_URL, processEnv) || sanitizeProviderConfigValue(processEnv.OPENAI_BASE_URL, secretSource) ||
DEFAULT_OPENAI_BASE_URL DEFAULT_OPENAI_BASE_URL
const safeGeminiModel = const safeGeminiModel =
sanitizeProviderConfigValue(processEnv.GEMINI_MODEL, processEnv) || sanitizeProviderConfigValue(processEnv.GEMINI_MODEL, secretSource) ||
DEFAULT_GEMINI_MODEL DEFAULT_GEMINI_MODEL
const safeMistralModel = const safeMistralModel =
sanitizeProviderConfigValue(processEnv.MISTRAL_MODEL, processEnv) || sanitizeProviderConfigValue(processEnv.MISTRAL_MODEL, processEnv) ||
@@ -181,6 +193,7 @@ export function buildCurrentProviderSummary(options?: {
persisted?: ProfileFile | null persisted?: ProfileFile | null
}): CurrentProviderSummary { }): CurrentProviderSummary {
const processEnv = options?.processEnv ?? process.env const processEnv = options?.processEnv ?? process.env
const secretSource = processEnv as SecretSourceEnv
const persisted = options?.persisted ?? loadProfileFile() const persisted = options?.persisted ?? loadProfileFile()
const savedProfileLabel = persisted?.profile ?? 'none' const savedProfileLabel = persisted?.profile ?? 'none'
@@ -189,11 +202,11 @@ export function buildCurrentProviderSummary(options?: {
providerLabel: 'Google Gemini', providerLabel: 'Google Gemini',
modelLabel: getSafeDisplayValue( modelLabel: getSafeDisplayValue(
processEnv.GEMINI_MODEL ?? DEFAULT_GEMINI_MODEL, processEnv.GEMINI_MODEL ?? DEFAULT_GEMINI_MODEL,
processEnv, secretSource,
), ),
endpointLabel: getSafeDisplayValue( endpointLabel: getSafeDisplayValue(
processEnv.GEMINI_BASE_URL ?? DEFAULT_GEMINI_BASE_URL, processEnv.GEMINI_BASE_URL ?? DEFAULT_GEMINI_BASE_URL,
processEnv, secretSource,
), ),
savedProfileLabel, savedProfileLabel,
} }
@@ -219,13 +232,13 @@ export function buildCurrentProviderSummary(options?: {
providerLabel: 'GitHub Models', providerLabel: 'GitHub Models',
modelLabel: getSafeDisplayValue( modelLabel: getSafeDisplayValue(
processEnv.OPENAI_MODEL ?? 'github:copilot', processEnv.OPENAI_MODEL ?? 'github:copilot',
processEnv, secretSource,
), ),
endpointLabel: getSafeDisplayValue( endpointLabel: getSafeDisplayValue(
processEnv.OPENAI_BASE_URL ?? processEnv.OPENAI_BASE_URL ??
processEnv.OPENAI_API_BASE ?? processEnv.OPENAI_API_BASE ??
'https://models.github.ai/inference', 'https://models.github.ai/inference',
processEnv, secretSource,
), ),
savedProfileLabel, savedProfileLabel,
} }
@@ -246,8 +259,8 @@ export function buildCurrentProviderSummary(options?: {
return { return {
providerLabel, providerLabel,
modelLabel: getSafeDisplayValue(request.requestedModel, processEnv), modelLabel: getSafeDisplayValue(request.requestedModel, secretSource),
endpointLabel: getSafeDisplayValue(request.baseUrl, processEnv), endpointLabel: getSafeDisplayValue(request.baseUrl, secretSource),
savedProfileLabel, savedProfileLabel,
} }
} }
@@ -258,11 +271,11 @@ export function buildCurrentProviderSummary(options?: {
processEnv.ANTHROPIC_MODEL ?? processEnv.ANTHROPIC_MODEL ??
processEnv.CLAUDE_MODEL ?? processEnv.CLAUDE_MODEL ??
'claude-sonnet-4-6', 'claude-sonnet-4-6',
processEnv, secretSource,
), ),
endpointLabel: getSafeDisplayValue( endpointLabel: getSafeDisplayValue(
processEnv.ANTHROPIC_BASE_URL ?? 'https://api.anthropic.com', processEnv.ANTHROPIC_BASE_URL ?? 'https://api.anthropic.com',
processEnv, secretSource,
), ),
savedProfileLabel, savedProfileLabel,
} }
@@ -376,6 +389,10 @@ export function buildProfileSaveMessage(
profile: ProviderProfile, profile: ProviderProfile,
env: ProfileEnv, env: ProfileEnv,
filePath: string, filePath: string,
options?: {
activatedInSession?: boolean
activationWarning?: string | null
},
): string { ): string {
const summary = buildSavedProfileSummary(profile, env) const summary = buildSavedProfileSummary(profile, env)
const lines = [ const lines = [
@@ -389,13 +406,24 @@ export function buildProfileSaveMessage(
} }
lines.push(`Profile: ${filePath}`) lines.push(`Profile: ${filePath}`)
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.') lines.push('Restart OpenClaude to use it.')
}
return lines.join('\n') return lines.join('\n')
} }
function buildUsageText(): string { function buildUsageText(): string {
const summary = buildCurrentProviderSummary() 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 [ return [
'Usage: /provider', 'Usage: /provider',
'', '',
@@ -406,7 +434,7 @@ function buildUsageText(): string {
`Current endpoint: ${summary.endpointLabel}`, `Current endpoint: ${summary.endpointLabel}`,
`Saved profile: ${summary.savedProfileLabel}`, `Saved profile: ${summary.savedProfileLabel}`,
'', '',
'Choose Auto, Ollama, OpenAI-compatible, Gemini, or Codex, then save a profile for the next OpenClaude restart.', availableProviders,
].join('\n') ].join('\n')
} }
@@ -415,12 +443,45 @@ function finishProfileSave(
profile: ProviderProfile, profile: ProviderProfile,
env: ProfileEnv, env: ProfileEnv,
): void { ): 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 { try {
const profileFile = createProfileFile(profile, env) const profileFile = createProfileFile(profile, env)
const filePath = saveProfileFile(profileFile) const filePath = saveProfileFile(profileFile)
onDone(buildProfileSaveMessage(profile, env, filePath), { const shouldActivateInSession = profile === 'codex'
const activationWarning = shouldActivateInSession
? await applySharedProfileToCurrentSession({ profileFile })
: null
onDone(
buildProfileSaveMessage(profile, env, filePath, {
activatedInSession:
shouldActivateInSession && activationWarning === null,
activationWarning,
}),
{
display: 'system', display: 'system',
}) },
)
} catch (error) { } catch (error) {
const message = error instanceof Error ? error.message : String(error) const message = error instanceof Error ? error.message : String(error)
onDone(`Failed to save provider profile: ${message}`, { onDone(`Failed to save provider profile: ${message}`, {
@@ -504,6 +565,10 @@ function ProviderChooser({
onCancel: () => void onCancel: () => void
}): React.ReactNode { }): React.ReactNode {
const summary = buildCurrentProviderSummary() 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>[] = [ const options: OptionWithDescription<ProviderChoice>[] = [
{ {
label: 'Auto', label: 'Auto',
@@ -537,6 +602,16 @@ function ProviderChooser({
value: 'codex', value: 'codex',
description: 'Use existing ChatGPT Codex CLI auth or env credentials', 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') { if (summary.savedProfileLabel !== 'none') {
@@ -554,10 +629,7 @@ function ProviderChooser({
onCancel={onCancel} onCancel={onCancel}
> >
<Box flexDirection="column" gap={1}> <Box flexDirection="column" gap={1}>
<Text> <Text>{helperText}</Text>
Save a provider profile for the next OpenClaude restart without
editing environment variables first.
</Text>
<Box flexDirection="column"> <Box flexDirection="column">
<Text dimColor>Current model: {summary.modelLabel}</Text> <Text dimColor>Current model: {summary.modelLabel}</Text>
<Text dimColor>Current endpoint: {summary.endpointLabel}</Text> <Text dimColor>Current endpoint: {summary.endpointLabel}</Text>
@@ -709,7 +781,9 @@ function AutoRecommendationStep({
{ label: 'Back', value: 'back' }, { label: 'Back', value: 'back' },
{ label: 'Cancel', value: 'cancel' }, { label: 'Cancel', value: 'cancel' },
]} ]}
onChange={value => (value === 'back' ? onBack() : onCancel())} onChange={(value: string) =>
value === 'back' ? onBack() : onCancel()
}
onCancel={onCancel} onCancel={onCancel}
/> />
</Box> </Box>
@@ -732,7 +806,7 @@ function AutoRecommendationStep({
{ label: 'Back', value: 'back' }, { label: 'Back', value: 'back' },
{ label: 'Cancel', value: 'cancel' }, { label: 'Cancel', value: 'cancel' },
]} ]}
onChange={value => { onChange={(value: string) => {
if (value === 'continue') { if (value === 'continue') {
onNeedOpenAI(status.defaultModel) onNeedOpenAI(status.defaultModel)
} else if (value === 'back') { } else if (value === 'back') {
@@ -765,7 +839,7 @@ function AutoRecommendationStep({
{ label: 'Back', value: 'back' }, { label: 'Back', value: 'back' },
{ label: 'Cancel', value: 'cancel' }, { label: 'Cancel', value: 'cancel' },
]} ]}
onChange={value => { onChange={(value: string) => {
if (value === 'save') { if (value === 'save') {
onSave( onSave(
'ollama', 'ollama',
@@ -867,7 +941,9 @@ function OllamaModelStep({
{ label: 'Back', value: 'back' }, { label: 'Back', value: 'back' },
{ label: 'Cancel', value: 'cancel' }, { label: 'Cancel', value: 'cancel' },
]} ]}
onChange={value => (value === 'back' ? onBack() : onCancel())} onChange={(value: string) =>
value === 'back' ? onBack() : onCancel()
}
onCancel={onCancel} onCancel={onCancel}
/> />
</Box> </Box>
@@ -888,7 +964,7 @@ function OllamaModelStep({
defaultFocusValue={status.defaultValue} defaultFocusValue={status.defaultValue}
inlineDescriptions inlineDescriptions
visibleOptionCount={Math.min(8, status.options.length)} visibleOptionCount={Math.min(8, status.options.length)}
onChange={value => { onChange={(value: string) => {
onSave( onSave(
'ollama', 'ollama',
buildOllamaProfileEnv(value, { 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({ function CodexCredentialStep({
onSave, onSave,
onBack, onBack,
@@ -924,7 +1078,9 @@ function CodexCredentialStep({
{ label: 'Back', value: 'back' }, { label: 'Back', value: 'back' },
{ label: 'Cancel', value: 'cancel' }, { label: 'Cancel', value: 'cancel' },
]} ]}
onChange={value => (value === 'back' ? onBack() : onCancel())} onChange={(value: string) =>
value === 'back' ? onBack() : onCancel()
}
onCancel={onCancel} onCancel={onCancel}
/> />
</Box> </Box>
@@ -958,9 +1114,10 @@ function CodexCredentialStep({
defaultFocusValue="codexplan" defaultFocusValue="codexplan"
inlineDescriptions inlineDescriptions
visibleOptionCount={options.length} visibleOptionCount={options.length}
onChange={value => { onChange={(value: string) => {
const env = buildCodexProfileEnv({ const env = buildCodexProfileEnv({
model: value, model: value,
credentialSource: credentials.credentialSource,
processEnv: process.env, processEnv: process.env,
}) })
if (env) { if (env) {
@@ -975,9 +1132,16 @@ function CodexCredentialStep({
} }
function resolveCodexCredentials(processEnv: NodeJS.ProcessEnv): function resolveCodexCredentials(processEnv: NodeJS.ProcessEnv):
| { ok: true; sourceDescription: string } | {
ok: true
sourceDescription: string
credentialSource: 'oauth' | 'existing'
}
| { ok: false; message: string } { | { ok: false; message: string } {
const credentials = resolveCodexApiCredentials(processEnv) 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) { if (!credentials.apiKey) {
const authHint = credentials.authPath const authHint = credentials.authPath
@@ -985,7 +1149,7 @@ function resolveCodexCredentials(processEnv: NodeJS.ProcessEnv):
: 'Set CODEX_API_KEY or re-login with the Codex CLI.' : 'Set CODEX_API_KEY or re-login with the Codex CLI.'
return { return {
ok: false, 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 { return {
ok: false, ok: false,
message: 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 { return {
ok: true, ok: true,
credentialSource:
credentials.source === 'secure-storage' ? 'oauth' : 'existing',
sourceDescription: sourceDescription:
credentials.source === 'env' credentials.source === 'env'
? 'the current shell environment' ? 'the current shell environment'
: credentials.source === 'secure-storage'
? 'OpenClaude secure storage'
: credentials.authPath ?? DEFAULT_CODEX_BASE_URL, : credentials.authPath ?? DEFAULT_CODEX_BASE_URL,
} }
} }
@@ -1035,6 +1203,8 @@ export function ProviderWizard({
name: 'mistral-key', name: 'mistral-key',
defaultModel: defaults.mistralModel, defaultModel: defaults.mistralModel,
}) })
} else if (value === 'codex-oauth') {
setStep({ name: 'codex-oauth' })
} else if (value === 'clear') { } else if (value === 'clear') {
const filePath = deleteProfileFile() const filePath = deleteProfileFile()
onDone(`Removed saved provider profile at ${filePath}. Restart OpenClaude to go back to normal startup.`, { onDone(`Removed saved provider profile at ${filePath}. Restart OpenClaude to go back to normal startup.`, {
@@ -1314,7 +1484,7 @@ export function ProviderWizard({
options={options} options={options}
inlineDescriptions inlineDescriptions
visibleOptionCount={options.length} visibleOptionCount={options.length}
onChange={value => { onChange={(value: string) => {
if (value === 'api-key') { if (value === 'api-key') {
setStep({ name: 'gemini-key' }) setStep({ name: 'gemini-key' })
} else if (value === 'access-token') { } else if (value === 'access-token') {
@@ -1470,6 +1640,15 @@ export function ProviderWizard({
onCancel={() => onDone()} onCancel={() => onDone()}
/> />
) )
case 'codex-oauth':
return (
<CodexOAuthStep
onSave={(profile, env) => finishProfileSave(onDone, profile, env)}
onBack={() => setStep({ name: 'choose' })}
onCancel={() => onDone()}
/>
)
} }
} }

View File

@@ -101,7 +101,7 @@ export function EffortPicker({ onSelect, onCancel }: Props) {
<Box marginBottom={1} flexDirection="column"> <Box marginBottom={1} flexDirection="column">
<Text color="remember" bold={true}>Set effort level</Text> <Text color="remember" bold={true}>Set effort level</Text>
<Text dimColor={true}> <Text dimColor={true}>
{usesOpenAIEffort {supportsEffort && usesOpenAIEffort
? `OpenAI/Codex provider (${provider})` ? `OpenAI/Codex provider (${provider})`
: supportsEffort : supportsEffort
? `Claude model · ${provider} provider` ? `Claude model · ${provider} provider`

View File

@@ -5,13 +5,14 @@ import React from 'react'
import stripAnsi from 'strip-ansi' import stripAnsi from 'strip-ansi'
import { createRoot } from '../ink.js' import { createRoot } from '../ink.js'
import { AppStateProvider } from '../state/AppState.js'
import { KeybindingSetup } from '../keybindings/KeybindingProviderSetup.js' import { KeybindingSetup } from '../keybindings/KeybindingProviderSetup.js'
import { AppStateProvider } from '../state/AppState.js'
const SYNC_START = '\x1B[?2026h' const SYNC_START = '\x1B[?2026h'
const SYNC_END = '\x1B[?2026l' const SYNC_END = '\x1B[?2026l'
const ORIGINAL_ENV = { const ORIGINAL_ENV = {
CLAUDE_CODE_SIMPLE: process.env.CLAUDE_CODE_SIMPLE,
CLAUDE_CODE_USE_GITHUB: process.env.CLAUDE_CODE_USE_GITHUB, CLAUDE_CODE_USE_GITHUB: process.env.CLAUDE_CODE_USE_GITHUB,
GITHUB_TOKEN: process.env.GITHUB_TOKEN, GITHUB_TOKEN: process.env.GITHUB_TOKEN,
GH_TOKEN: process.env.GH_TOKEN, GH_TOKEN: process.env.GH_TOKEN,
@@ -109,6 +110,9 @@ function createDeferred<T>(): {
function mockProviderProfilesModule(options?: { function mockProviderProfilesModule(options?: {
addProviderProfile?: (...args: unknown[]) => unknown addProviderProfile?: (...args: unknown[]) => unknown
getProviderProfiles?: () => unknown[]
updateProviderProfile?: (...args: unknown[]) => unknown
setActiveProviderProfile?: (...args: unknown[]) => unknown
}): void { }): void {
mock.module('../utils/providerProfiles.js', () => ({ mock.module('../utils/providerProfiles.js', () => ({
addProviderProfile: options?.addProviderProfile ?? (() => null), addProviderProfile: options?.addProviderProfile ?? (() => null),
@@ -131,17 +135,20 @@ function mockProviderProfilesModule(options?: {
model: 'mock-model', model: 'mock-model',
apiKey: '', apiKey: '',
}, },
getProviderProfiles: () => [], getProviderProfiles: options?.getProviderProfiles ?? (() => []),
setActiveProviderProfile: () => null, setActiveProviderProfile: options?.setActiveProviderProfile ?? (() => null),
updateProviderProfile: () => null, updateProviderProfile: options?.updateProviderProfile ?? (() => null),
})) }))
} }
function mockProviderManagerDependencies( function mockProviderManagerDependencies(
syncRead: () => string | undefined, githubSyncRead: () => string | undefined,
asyncRead: () => Promise<string | undefined>, githubAsyncRead: () => Promise<string | undefined>,
options?: { options?: {
addProviderProfile?: (...args: unknown[]) => unknown addProviderProfile?: (...args: unknown[]) => unknown
applySavedProfileToCurrentSession?: (...args: unknown[]) => Promise<string | null>
clearCodexCredentials?: () => { success: boolean; warning?: string }
getProviderProfiles?: () => unknown[]
hasLocalOllama?: () => Promise<boolean> hasLocalOllama?: () => Promise<boolean>
listOllamaModels?: () => Promise< listOllamaModels?: () => Promise<
Array<{ Array<{
@@ -153,9 +160,33 @@ function mockProviderManagerDependencies(
quantizationLevel?: string | null 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 { ): void {
mockProviderProfilesModule({ addProviderProfile: options?.addProviderProfile }) mockProviderProfilesModule({
addProviderProfile: options?.addProviderProfile,
getProviderProfiles: options?.getProviderProfiles,
updateProviderProfile: options?.updateProviderProfile,
setActiveProviderProfile: options?.setActiveProviderProfile,
})
mock.module('../utils/providerDiscovery.js', () => ({ mock.module('../utils/providerDiscovery.js', () => ({
hasLocalOllama: options?.hasLocalOllama ?? (async () => false), hasLocalOllama: options?.hasLocalOllama ?? (async () => false),
@@ -166,13 +197,65 @@ function mockProviderManagerDependencies(
clearGithubModelsToken: () => ({ success: true }), clearGithubModelsToken: () => ({ success: true }),
GITHUB_MODELS_HYDRATED_ENV_MARKER: 'CLAUDE_CODE_GITHUB_TOKEN_HYDRATED', GITHUB_MODELS_HYDRATED_ENV_MARKER: 'CLAUDE_CODE_GITHUB_TOKEN_HYDRATED',
hydrateGithubModelsTokenFromSecureStorage: () => {}, hydrateGithubModelsTokenFromSecureStorage: () => {},
readGithubModelsToken: syncRead, readGithubModelsToken: githubSyncRead,
readGithubModelsTokenAsync: asyncRead, 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', () => ({ mock.module('../utils/settings/settings.js', () => ({
updateSettingsForSource: () => ({ error: null }), updateSettingsForSource: () => ({ error: null }),
})) }))
mock.module('./useCodexOAuthFlow.js', () => ({
useCodexOAuthFlow:
options?.useCodexOAuthFlow ??
(() => ({
state: 'waiting' as const,
authUrl: 'https://chatgpt.com/codex',
browserOpened: true,
})),
}))
} }
async function waitForFrameOutput( async function waitForFrameOutput(
@@ -240,9 +323,9 @@ async function renderProviderManagerFrame(
onDone: (result?: unknown) => void onDone: (result?: unknown) => void
}>, }>,
options?: { options?: {
mode?: 'first-run' | 'manage'
waitForOutput?: (output: string) => boolean waitForOutput?: (output: string) => boolean
timeoutMs?: number timeoutMs?: number
mode?: 'first-run' | 'manage'
}, },
): Promise<string> { ): Promise<string> {
const mounted = await mountProviderManager(ProviderManager, { const mounted = await mountProviderManager(ProviderManager, {
@@ -305,6 +388,47 @@ test('ProviderManager resolves GitHub virtual provider from async storage withou
expect(asyncRead).toHaveBeenCalled() 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 () => { test('ProviderManager first-run Ollama preset auto-detects installed models', async () => {
delete process.env.CLAUDE_CODE_USE_GITHUB delete process.env.CLAUDE_CODE_USE_GITHUB
delete process.env.GITHUB_TOKEN delete process.env.GITHUB_TOKEN
@@ -395,43 +519,411 @@ test('ProviderManager first-run Ollama preset auto-detects installed models', as
await mounted.dispose() 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.CLAUDE_CODE_USE_GITHUB
delete process.env.GITHUB_TOKEN delete process.env.GITHUB_TOKEN
delete process.env.GH_TOKEN delete process.env.GH_TOKEN
const syncRead = mock(() => { const onDone = mock(() => {})
throw new Error('sync credential read should not run in ProviderManager render flow') const applySavedProfileToCurrentSession = mock(async () => null)
}) const persistCredentials = mock(() => {})
const deferredStoredToken = createDeferred<string | undefined>() const addProviderProfile = mock((payload: {
const asyncRead = mock(async () => deferredStoredToken.promise) 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 nonce = `${Date.now()}-${Math.random()}`
const { ProviderManager } = await import(`./ProviderManager.js?ts=${nonce}`) const { ProviderManager } = await import(`./ProviderManager.js?ts=${nonce}`)
const mounted = await mountProviderManager(ProviderManager) const mounted = await mountProviderManager(ProviderManager)
const firstFrame = await waitForFrameOutput( await waitForFrameOutput(
mounted.getOutput, 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...') mounted.stdin.write('j')
expect(firstFrame).not.toContain('No provider profiles configured yet.') await Bun.sleep(25)
mounted.stdin.write('\r')
deferredStoredToken.resolve('stored-token') await waitForFrameOutput(
const resolvedFrame = await waitForFrameOutput(
mounted.getOutput, 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') await Bun.sleep(25)
expect(resolvedFrame).toContain('token stored') 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() 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')
}) })

View File

@@ -1,9 +1,20 @@
import figures from 'figures' import figures from 'figures'
import * as React from 'react' import * as React from 'react'
import { DEFAULT_CODEX_BASE_URL } from '../services/api/providerConfig.js'
import { Box, Text } from '../ink.js' import { Box, Text } from '../ink.js'
import { useKeybinding } from '../keybindings/useKeybinding.js' import { useKeybinding } from '../keybindings/useKeybinding.js'
import type { ProviderProfile } from '../utils/config.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 { import {
addProviderProfile, addProviderProfile,
applyActiveProviderProfileFromConfig, applyActiveProviderProfileFromConfig,
@@ -16,10 +27,6 @@ import {
type ProviderProfileInput, type ProviderProfileInput,
updateProviderProfile, updateProviderProfile,
} from '../utils/providerProfiles.js' } from '../utils/providerProfiles.js'
import {
rankOllamaModels,
recommendOllamaModel,
} from '../utils/providerRecommendation.js'
import { import {
clearGithubModelsToken, clearGithubModelsToken,
GITHUB_MODELS_HYDRATED_ENV_MARKER, GITHUB_MODELS_HYDRATED_ENV_MARKER,
@@ -27,11 +34,22 @@ import {
readGithubModelsToken, readGithubModelsToken,
readGithubModelsTokenAsync, readGithubModelsTokenAsync,
} from '../utils/githubModelsCredentials.js' } 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 { 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 { Pane } from './design-system/Pane.js'
import TextInput from './TextInput.js' import TextInput from './TextInput.js'
import { useCodexOAuthFlow } from './useCodexOAuthFlow.js'
export type ProviderManagerResult = { export type ProviderManagerResult = {
action: 'saved' | 'cancelled' action: 'saved' | 'cancelled'
@@ -48,6 +66,7 @@ type Screen =
| 'menu' | 'menu'
| 'select-preset' | 'select-preset'
| 'select-ollama-model' | 'select-ollama-model'
| 'codex-oauth'
| 'form' | 'form'
| 'select-active' | 'select-active'
| 'select-edit' | 'select-edit'
@@ -105,6 +124,8 @@ const GITHUB_PROVIDER_ID = '__github_models__'
const GITHUB_PROVIDER_LABEL = 'GitHub Models' const GITHUB_PROVIDER_LABEL = 'GitHub Models'
const GITHUB_PROVIDER_DEFAULT_MODEL = 'github:copilot' const GITHUB_PROVIDER_DEFAULT_MODEL = 'github:copilot'
const GITHUB_PROVIDER_DEFAULT_BASE_URL = 'https://models.github.ai/inference' 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' type GithubCredentialSource = 'stored' | 'env' | 'none'
@@ -193,6 +214,111 @@ function getGithubProviderSummary(
return `github-models · ${GITHUB_PROVIDER_DEFAULT_BASE_URL} · ${getGithubProviderModel(processEnv)} · ${credentialSummary}${activeSuffix}` 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 { export function ProviderManager({ mode, onDone }: Props): React.ReactNode {
const initialGithubCredentialSource = getGithubCredentialSourceFromEnv() const initialGithubCredentialSource = getGithubCredentialSourceFromEnv()
const initialIsGithubActive = isEnvTruthy(process.env.CLAUDE_CODE_USE_GITHUB) const initialIsGithubActive = isEnvTruthy(process.env.CLAUDE_CODE_USE_GITHUB)
@@ -212,6 +338,7 @@ export function ProviderManager({ mode, onDone }: Props): React.ReactNode {
const [isGithubCredentialSourceResolved, setIsGithubCredentialSourceResolved] = const [isGithubCredentialSourceResolved, setIsGithubCredentialSourceResolved] =
React.useState(() => initialHasGithubCredential || initialIsGithubActive) React.useState(() => initialHasGithubCredential || initialIsGithubActive)
const githubRefreshEpochRef = React.useRef(0) const githubRefreshEpochRef = React.useRef(0)
const codexRefreshEpochRef = React.useRef(0)
const [screen, setScreen] = React.useState<Screen>( const [screen, setScreen] = React.useState<Screen>(
mode === 'first-run' ? 'select-preset' : 'menu', 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 [cursorOffset, setCursorOffset] = React.useState(0)
const [statusMessage, setStatusMessage] = React.useState<string | undefined>() const [statusMessage, setStatusMessage] = React.useState<string | undefined>()
const [errorMessage, setErrorMessage] = 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>({ const [ollamaSelection, setOllamaSelection] = React.useState<OllamaSelectionState>({
state: 'idle', 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(() => { React.useEffect(() => {
refreshGithubProviderState() refreshGithubProviderState()
refreshCodexOAuthCredentialState()
return () => { return () => {
githubRefreshEpochRef.current += 1 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 { function refreshProfiles(): void {
const nextProfiles = getProviderProfiles() const nextProfiles = getProviderProfiles()
setProfiles(nextProfiles) setProfiles(nextProfiles)
setActiveProfileId(getActiveProviderProfile()?.id) setActiveProfileId(getActiveProviderProfile()?.id)
refreshGithubProviderState() refreshGithubProviderState()
refreshCodexOAuthCredentialState()
} }
function clearStartupProviderOverrideFromUserSettings(): string | null { function clearStartupProviderOverrideFromUserSettings(): string | null {
@@ -292,6 +506,123 @@ export function ProviderManager({ mode, onDone }: Props): React.ReactNode {
return error ? error.message : null 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 { function closeWithCancelled(message: string): void {
onDone({ action: 'cancelled', message }) onDone({ action: 'cancelled', message })
} }
@@ -383,59 +714,6 @@ export function ProviderManager({ mode, onDone }: Props): React.ReactNode {
return null 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 { function startCreateFromPreset(preset: ProviderPreset): void {
const defaults = getProviderPresetDefaults(preset) const defaults = getProviderPresetDefaults(preset)
const nextDraft = { const nextDraft = {
@@ -557,7 +835,7 @@ export function ProviderManager({ mode, onDone }: Props): React.ReactNode {
description: 'Choose another provider preset', description: 'Choose another provider preset',
}, },
]} ]}
onChange={value => { onChange={(value: string) => {
if (value === 'manual') { if (value === 'manual') {
setFormStepIndex(0) setFormStepIndex(0)
setCursorOffset(draft.name.length) setCursorOffset(draft.name.length)
@@ -588,7 +866,7 @@ export function ProviderManager({ mode, onDone }: Props): React.ReactNode {
defaultFocusValue={ollamaSelection.defaultValue} defaultFocusValue={ollamaSelection.defaultValue}
inlineDescriptions inlineDescriptions
visibleOptionCount={Math.min(8, ollamaSelection.options.length)} visibleOptionCount={Math.min(8, ollamaSelection.options.length)}
onChange={value => { onChange={(value: string) => {
const nextDraft = { const nextDraft = {
...draft, ...draft,
model: value, model: value,
@@ -654,6 +932,7 @@ export function ProviderManager({ mode, onDone }: Props): React.ReactNode {
}) })
function renderPresetSelection(): React.ReactNode { function renderPresetSelection(): React.ReactNode {
const canUseCodexOAuth = !isBareMode()
const options = [ const options = [
{ {
value: 'anthropic', value: 'anthropic',
@@ -670,6 +949,16 @@ export function ProviderManager({ mode, onDone }: Props): React.ReactNode {
label: 'OpenAI', label: 'OpenAI',
description: 'OpenAI API with API key', 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', value: 'moonshotai',
label: 'Moonshot AI', label: 'Moonshot AI',
@@ -741,11 +1030,15 @@ export function ProviderManager({ mode, onDone }: Props): React.ReactNode {
</Text> </Text>
<Select <Select
options={options} options={options}
onChange={value => { onChange={(value: string) => {
if (value === 'skip') { if (value === 'skip') {
closeWithCancelled('Provider setup skipped') closeWithCancelled('Provider setup skipped')
return return
} }
if (value === 'codex-oauth') {
setScreen('codex-oauth')
return
}
startCreateFromPreset(value as ProviderPreset) startCreateFromPreset(value as ProviderPreset)
}} }}
onCancel={() => { onCancel={() => {
@@ -755,7 +1048,7 @@ export function ProviderManager({ mode, onDone }: Props): React.ReactNode {
} }
setScreen('menu') setScreen('menu')
}} }}
visibleOptionCount={Math.min(12, options.length)} visibleOptionCount={Math.min(13, options.length)}
/> />
</Box> </Box>
) )
@@ -832,6 +1125,15 @@ export function ProviderManager({ mode, onDone }: Props): React.ReactNode {
description: 'Remove a provider profile', description: 'Remove a provider profile',
disabled: !hasSelectableProviders, disabled: !hasSelectableProviders,
}, },
...(hasStoredCodexOAuthCredentials
? [
{
value: 'logout-codex-oauth',
label: 'Log out Codex OAuth',
description: 'Clear securely stored Codex OAuth credentials',
},
]
: []),
{ {
value: 'done', value: 'done',
label: 'Done', label: 'Done',
@@ -876,7 +1178,7 @@ export function ProviderManager({ mode, onDone }: Props): React.ReactNode {
</Box> </Box>
<Select <Select
options={options} options={options}
onChange={value => { onChange={(value: string) => {
setErrorMessage(undefined) setErrorMessage(undefined)
switch (value) { switch (value) {
case 'add': case 'add':
@@ -897,6 +1199,47 @@ export function ProviderManager({ mode, onDone }: Props): React.ReactNode {
setScreen('select-delete') setScreen('select-delete')
} }
break 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: default:
closeWithCancelled('Provider manager closed') closeWithCancelled('Provider manager closed')
break break
@@ -982,6 +1325,82 @@ export function ProviderManager({ mode, onDone }: Props): React.ReactNode {
case 'select-ollama-model': case 'select-ollama-model':
content = renderOllamaSelection() content = renderOllamaSelection()
break 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': case 'form':
content = renderForm() content = renderForm()
break break
@@ -990,34 +1409,7 @@ export function ProviderManager({ mode, onDone }: Props): React.ReactNode {
'Set active provider', 'Set active provider',
'No providers available. Add one first.', 'No providers available. Add one first.',
profileId => { profileId => {
if (profileId === GITHUB_PROVIDER_ID) { void activateSelectedProvider(profileId)
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')
}, },
{ includeGithub: true }, { includeGithub: true },
) )
@@ -1048,10 +1440,27 @@ export function ProviderManager({ mode, onDone }: Props): React.ReactNode {
return return
} }
const deletedCodexOAuthProfile =
findCodexOAuthProfile(
profiles,
storedCodexOAuthProfileId,
)?.id === profileId
const result = deleteProviderProfile(profileId) const result = deleteProviderProfile(profileId)
if (!result.removed) { if (!result.removed) {
setErrorMessage('Could not delete provider.') setErrorMessage('Could not delete provider.')
} else { } 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 const settingsOverrideError = result.activeProfileId
? clearStartupProviderOverrideFromUserSettings() ? clearStartupProviderOverrideFromUserSettings()
: null : null

View 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)
}
})

View 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
}

View 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')
})

View File

@@ -1,4 +1,4 @@
import { useCallback, useState } from 'react' import { useCallback, useEffect, useState } from 'react'
import { getIsNonInteractiveSession } from '../bootstrap/state.js' import { getIsNonInteractiveSession } from '../bootstrap/state.js'
import { verifyApiKey } from '../services/api/claude.js' import { verifyApiKey } from '../services/api/claude.js'
import { import {
@@ -21,8 +21,7 @@ export type ApiKeyVerificationResult = {
error: Error | null error: Error | null
} }
export function useApiKeyVerification(): ApiKeyVerificationResult { function getInitialVerificationStatus(): VerificationStatus {
const [status, setStatus] = useState<VerificationStatus>(() => {
if (!isAnthropicAuthEnabled() || isClaudeAISubscriber()) { if (!isAnthropicAuthEnabled() || isClaudeAISubscriber()) {
return 'valid' return 'valid'
} }
@@ -37,8 +36,28 @@ export function useApiKeyVerification(): ApiKeyVerificationResult {
return 'loading' return 'loading'
} }
return 'missing' return 'missing'
}) }
export function useApiKeyVerification(): ApiKeyVerificationResult {
const [status, setStatus] = useState<VerificationStatus>(
getInitialVerificationStatus,
)
const [error, setError] = useState<Error | null>(null) 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> => { const verify = useCallback(async (): Promise<void> => {
if (!isAnthropicAuthEnabled() || isClaudeAISubscriber()) { if (!isAnthropicAuthEnabled() || isClaudeAISubscriber()) {

View 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')
})

View 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
}
}

View 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 '&amp;'
case '<':
return '&lt;'
case '>':
return '&gt;'
case '"':
return '&quot;'
case '\'':
return '&#39;'
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
}

View File

@@ -8,10 +8,6 @@ import {
convertCodexResponseToAnthropicMessage, convertCodexResponseToAnthropicMessage,
convertToolsToResponsesTools, convertToolsToResponsesTools,
} from './codexShim.js' } from './codexShim.js'
import {
resolveCodexApiCredentials,
resolveProviderRequest,
} from './providerConfig.js'
const tempDirs: string[] = [] const tempDirs: string[] = []
const originalEnv = { const originalEnv = {
@@ -63,6 +59,10 @@ async function collectStreamEventTypes(responseText: string): Promise<string[]>
return events return events
} }
async function importFreshProviderConfigModule() {
return import(`./providerConfig.js?ts=${Date.now()}-${Math.random()}`)
}
describe('Codex provider config', () => { describe('Codex provider config', () => {
const originalOpenaiBaseUrl = process.env.OPENAI_BASE_URL const originalOpenaiBaseUrl = process.env.OPENAI_BASE_URL
const originalOpenaiApiBase = process.env.OPENAI_API_BASE const originalOpenaiApiBase = process.env.OPENAI_API_BASE
@@ -79,7 +79,8 @@ describe('Codex provider config', () => {
else process.env.OPENAI_API_BASE = originalOpenaiApiBase 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_BASE_URL
delete process.env.OPENAI_API_BASE delete process.env.OPENAI_API_BASE
delete process.env.CLAUDE_CODE_USE_GITHUB 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') 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_BASE_URL
delete process.env.OPENAI_API_BASE delete process.env.OPENAI_API_BASE
delete process.env.CLAUDE_CODE_USE_GITHUB 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') 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({ const resolved = resolveProviderRequest({
model: 'codexplan', model: 'codexplan',
baseUrl: 'http://127.0.0.1:8080/v1', baseUrl: 'http://127.0.0.1:8080/v1',
@@ -113,7 +116,8 @@ describe('Codex provider config', () => {
expect(resolved.resolvedModel).toBe('gpt-5.4') 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 // On Windows, env vars can leak as the literal string "undefined" instead of
// the JS value undefined when not properly unset (issue #336). // the JS value undefined when not properly unset (issue #336).
process.env.OPENAI_BASE_URL = 'undefined' process.env.OPENAI_BASE_URL = 'undefined'
@@ -121,20 +125,23 @@ describe('Codex provider config', () => {
expect(resolved.transport).toBe('codex_responses') 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 = '' process.env.OPENAI_BASE_URL = ''
const resolved = resolveProviderRequest({ model: 'codexplan' }) const resolved = resolveProviderRequest({ model: 'codexplan' })
expect(resolved.transport).toBe('codex_responses') 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' process.env.OPENAI_BASE_URL = 'https://example.com/v1'
const resolved = resolveProviderRequest({ model: 'codexplan', baseUrl: 'https://chatgpt.com/backend-api/codex' }) const resolved = resolveProviderRequest({ model: 'codexplan', baseUrl: 'https://chatgpt.com/backend-api/codex' })
expect(resolved.transport).toBe('codex_responses') expect(resolved.transport).toBe('codex_responses')
expect(resolved.baseUrl).toBe('https://chatgpt.com/backend-api/codex') 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.OPENAI_BASE_URL
delete process.env.CLAUDE_CODE_USE_GITHUB delete process.env.CLAUDE_CODE_USE_GITHUB
@@ -144,7 +151,8 @@ describe('Codex provider config', () => {
expect(resolved.resolvedModel).toBe('gpt-4o') 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' process.env.OPENAI_MODEL = 'codexplan'
delete process.env.OPENAI_BASE_URL delete process.env.OPENAI_BASE_URL
delete process.env.CLAUDE_CODE_USE_GITHUB delete process.env.CLAUDE_CODE_USE_GITHUB
@@ -155,7 +163,8 @@ describe('Codex provider config', () => {
expect(resolved.resolvedModel).toBe('gpt-5.4') 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_MODEL = 'codexplan'
process.env.OPENAI_BASE_URL = 'http://localhost:11434/v1' process.env.OPENAI_BASE_URL = 'http://localhost:11434/v1'
delete process.env.CLAUDE_CODE_USE_GITHUB delete process.env.CLAUDE_CODE_USE_GITHUB
@@ -165,7 +174,8 @@ describe('Codex provider config', () => {
expect(resolved.baseUrl).toBe('http://localhost:11434/v1') 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({ const authPath = createTempAuthJson({
tokens: { tokens: {
access_token: 'header.payload.signature', access_token: 'header.payload.signature',
@@ -181,6 +191,31 @@ describe('Codex provider config', () => {
expect(credentials.accountId).toBe('acct_test') expect(credentials.accountId).toBe('acct_test')
expect(credentials.source).toBe('auth.json') 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', () => { describe('Codex request translation', () => {

View File

@@ -1,7 +1,13 @@
import {
readCodexCredentialsAsync,
refreshCodexAccessTokenIfNeeded,
} from '../../utils/codexCredentials.js'
import { logForDebugging } from '../../utils/debug.js'
import { isBareMode } from '../../utils/envUtils.js'
import { import {
DEFAULT_CODEX_BASE_URL, DEFAULT_CODEX_BASE_URL,
isCodexBaseUrl, isCodexBaseUrl,
resolveCodexApiCredentials, resolveRuntimeCodexCredentials,
resolveProviderRequest, resolveProviderRequest,
} from './providerConfig.js' } from './providerConfig.js'
@@ -391,6 +397,18 @@ export function getCodexUsageUrl(baseUrl = DEFAULT_CODEX_BASE_URL): string {
} }
export async function fetchCodexUsage(): Promise<CodexUsageData> { 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({ const request = resolveProviderRequest({
model: process.env.OPENAI_MODEL, model: process.env.OPENAI_MODEL,
baseUrl: process.env.OPENAI_BASE_URL, 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) { if (!credentials.apiKey) {
const oauthHint = isBareMode() ? '' : ', choose Codex OAuth in /provider'
const authHint = credentials.authPath 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}.`) throw new Error(`Codex auth is required. Set CODEX_API_KEY${authHint}.`)
} }
if (!credentials.accountId) { if (!credentials.accountId) {
throw new Error( 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.',
) )
} }

View File

@@ -22,7 +22,12 @@
*/ */
import { APIError } from '@anthropic-ai/sdk' 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 { resolveGeminiCredential } from '../../utils/geminiAuth.js'
import { hydrateGeminiAccessTokenFromSecureStorage } from '../../utils/geminiCredentials.js' import { hydrateGeminiAccessTokenFromSecureStorage } from '../../utils/geminiCredentials.js'
import { hydrateGithubModelsTokenFromSecureStorage } from '../../utils/githubModelsCredentials.js' import { hydrateGithubModelsTokenFromSecureStorage } from '../../utils/githubModelsCredentials.js'
@@ -44,7 +49,7 @@ import {
} from './codexShim.js' } from './codexShim.js'
import { import {
isLocalProviderUrl, isLocalProviderUrl,
resolveCodexApiCredentials, resolveRuntimeCodexCredentials,
resolveProviderRequest, resolveProviderRequest,
getGithubEndpointType, getGithubEndpointType,
} from './providerConfig.js' } from './providerConfig.js'
@@ -1139,7 +1144,6 @@ class OpenAIShimMessages {
const githubEndpointType = getGithubEndpointType(request.baseUrl) const githubEndpointType = getGithubEndpointType(request.baseUrl)
const isGithubMode = isGithubModelsMode() const isGithubMode = isGithubModelsMode()
const isGithubWithCodexTransport = isGithubMode && request.transport === 'codex_responses' const isGithubWithCodexTransport = isGithubMode && request.transport === 'codex_responses'
const isGithubCopilotEndpoint = isGithubMode && githubEndpointType === 'copilot'
if (isGithubWithCodexTransport) { if (isGithubWithCodexTransport) {
const apiKey = this.providerOverride?.apiKey ?? process.env.OPENAI_API_KEY ?? '' const apiKey = this.providerOverride?.apiKey ?? process.env.OPENAI_API_KEY ?? ''
@@ -1166,11 +1170,26 @@ class OpenAIShimMessages {
} }
if (request.transport === 'codex_responses' && !isGithubMode) { 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) { if (!credentials.apiKey) {
const oauthHint = isBareMode() ? '' : ', choose Codex OAuth in /provider'
const authHint = credentials.authPath 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 = const safeModel =
redactSecretValueForDisplay(request.requestedModel, process.env as SecretValueSource) ?? redactSecretValueForDisplay(request.requestedModel, process.env as SecretValueSource) ??
'the requested model' 'the requested model'
@@ -1180,7 +1199,7 @@ class OpenAIShimMessages {
} }
if (!credentials.accountId) { if (!credentials.accountId) {
throw new Error( 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.',
) )
} }

View 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 })
}
})
})

View 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')
})

View File

@@ -3,7 +3,16 @@ import { isIP } from 'node:net'
import { homedir } from 'node:os' import { homedir } from 'node:os'
import { join } from 'node:path' import { join } from 'node:path'
import {
isCodexRefreshFailureCoolingDown,
readCodexCredentials,
type CodexCredentialBlob,
} from '../../utils/codexCredentials.js'
import { isEnvTruthy } from '../../utils/envUtils.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_OPENAI_BASE_URL = 'https://api.openai.com/v1'
export const DEFAULT_CODEX_BASE_URL = 'https://chatgpt.com/backend-api/codex' export const DEFAULT_CODEX_BASE_URL = 'https://chatgpt.com/backend-api/codex'
@@ -78,7 +87,7 @@ export type ResolvedCodexCredentials = {
apiKey: string apiKey: string
accountId?: string accountId?: string
authPath?: string authPath?: string
source: 'env' | 'auth.json' | 'none' source: 'env' | 'secure-storage' | 'auth.json' | 'none'
} }
type ModelDescriptor = { type ModelDescriptor = {
@@ -114,12 +123,6 @@ function isPrivateIpv6Address(hostname: string): boolean {
return (prefix & 0xfe00) === 0xfc00 || (prefix & 0xffc0) === 0xfe80 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 // 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 // empty strings and the literal string "undefined" that Windows shells can
// write when a variable is unset-then-referenced without quotes (issue #336). // write when a variable is unset-then-referenced without quotes (issue #336).
@@ -151,23 +154,6 @@ function readNestedString(
return undefined 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 { function parseReasoningEffort(value: string | undefined): ReasoningEffort | undefined {
if (!value) return undefined if (!value) return undefined
const normalized = value.trim().toLowerCase() const normalized = value.trim().toLowerCase()
@@ -494,18 +480,6 @@ export function resolveCodexAuthPath(
return join(homedir(), '.codex', 'auth.json') 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( function loadCodexAuthJson(
authPath: string, authPath: string,
): Record<string, unknown> | undefined { ): Record<string, unknown> | undefined {
@@ -521,8 +495,97 @@ function loadCodexAuthJson(
} }
} }
export function resolveCodexApiCredentials( function resolveCodexAuthJsonCredentials(options: {
env: NodeJS.ProcessEnv = process.env, 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 { ): ResolvedCodexCredentials {
const envApiKey = asTrimmedString(env.CODEX_API_KEY) const envApiKey = asTrimmedString(env.CODEX_API_KEY)
const envAccountId = 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 authPath = resolveCodexAuthPath(env)
const authJson = loadCodexAuthJson(authPath) const authJson = loadCodexAuthJson(authPath)
if (!authJson) { return resolveCodexAuthJsonCredentials({
return { authJson,
apiKey: '',
authPath, authPath,
source: 'none', 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,
} }
} }
const apiKey = readNestedString(authJson, [ return resolvedStoredCredentials
['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 { return resolveEnvOrAuthJsonCodexCredentials(env)
apiKey,
accountId,
authPath,
source: 'auth.json',
}
} }
export function getReasoningEffortForModel(model: string): ReasoningEffort | undefined { export function getReasoningEffortForModel(model: string): ReasoningEffort | undefined {
@@ -595,3 +730,18 @@ export function getReasoningEffortForModel(model: string): ReasoningEffort | und
const aliasConfig = CODEX_ALIAS_MODELS[alias] const aliasConfig = CODEX_ALIAS_MODELS[alias]
return aliasConfig?.reasoningEffort 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)
}

View 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([])
})

View 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.',
)
})

View File

@@ -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. * Completes the OAuth flow by redirecting the user's browser to a success page.
* Different success pages are shown based on the granted scopes. * 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 custom handler provided, use it instead of default redirect
if (customHandler) { if (customHandler) {
customHandler(this.pendingResponse, scopes) this.respondToPendingRequest({
this.pendingResponse = null handler: res => {
logEvent('tengu_oauth_automatic_redirect', { custom_handler: true }) customHandler(res, scopes)
},
analyticsEvent: 'tengu_oauth_automatic_redirect',
analyticsMetadata: { custom_handler: true },
})
return return
} }
@@ -97,29 +137,48 @@ export class AuthCodeListener {
: getOauthConfig().CONSOLE_SUCCESS_URL : getOauthConfig().CONSOLE_SUCCESS_URL
// Send browser to success page // Send browser to success page
this.pendingResponse.writeHead(302, { Location: successUrl }) this.respondToPendingRequest({
this.pendingResponse.end() handler: res => {
this.pendingResponse = null res.writeHead(302, { Location: successUrl })
res.end()
logEvent('tengu_oauth_automatic_redirect', {}) },
analyticsEvent: 'tengu_oauth_automatic_redirect',
})
} }
/** /**
* Handles error case by sending a redirect to the appropriate success page with an error indicator, * Handles error case by sending a redirect to the appropriate success page with an error indicator,
* ensuring the browser flow is completed properly. * ensuring the browser flow is completed properly.
*/ */
handleErrorRedirect(): void { handleErrorRedirect(customHandler?: (res: ServerResponse) => void): void {
if (!this.pendingResponse) return 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 // TODO: swap to a different url once we have an error page
const errorUrl = getOauthConfig().CLAUDEAI_SUCCESS_URL const errorUrl = getOauthConfig().CLAUDEAI_SUCCESS_URL
// Send browser to error page this.respondToPendingRequest({
this.pendingResponse.writeHead(302, { Location: errorUrl }) handler: res => {
this.pendingResponse.end() res.writeHead(302, { Location: errorUrl })
this.pendingResponse = null 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 { private startLocalListener(onReady: () => Promise<void>): void {
@@ -176,8 +235,7 @@ export class AuthCodeListener {
private handleError(err: Error): void { private handleError(err: Error): void {
logError(err) logError(err)
this.close() this.cancelPendingAuthorization(err)
this.reject(err)
} }
private resolve(authorizationCode: string): void { private resolve(authorizationCode: string): void {
@@ -185,6 +243,7 @@ export class AuthCodeListener {
this.promiseResolver(authorizationCode) this.promiseResolver(authorizationCode)
this.promiseResolver = null this.promiseResolver = null
this.promiseRejecter = null this.promiseRejecter = null
this.expectedState = null
} }
} }
@@ -193,6 +252,7 @@ export class AuthCodeListener {
this.promiseRejecter(error) this.promiseRejecter(error)
this.promiseResolver = null this.promiseResolver = null
this.promiseRejecter = null this.promiseRejecter = null
this.expectedState = null
} }
} }
@@ -207,5 +267,8 @@ export class AuthCodeListener {
this.localServer.removeAllListeners() this.localServer.removeAllListeners()
this.localServer.close() this.localServer.close()
} }
this.expectedState = null
this.port = 0
} }
} }

View 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)
})
})

View 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
}

View 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([])
})

View File

@@ -5,6 +5,7 @@ import { isProSubscriber, isMaxSubscriber, isTeamSubscriber } from './auth.js'
import { getFeatureValue_CACHED_MAY_BE_STALE } from 'src/services/analytics/growthbook.js' import { getFeatureValue_CACHED_MAY_BE_STALE } from 'src/services/analytics/growthbook.js'
import { getAPIProvider } from './model/providers.js' import { getAPIProvider } from './model/providers.js'
import { get3PModelCapabilityOverride } from './model/modelSupportOverrides.js' import { get3PModelCapabilityOverride } from './model/modelSupportOverrides.js'
import { supportsCodexReasoningEffort } from '../services/api/providerConfig.js'
import { isEnvTruthy } from './envUtils.js' import { isEnvTruthy } from './envUtils.js'
import type { EffortLevel } from 'src/entrypoints/sdk/runtimeTypes.js' import type { EffortLevel } from 'src/entrypoints/sdk/runtimeTypes.js'
@@ -37,6 +38,9 @@ export function modelSupportsEffort(model: string): boolean {
if (supported3P !== undefined) { if (supported3P !== undefined) {
return supported3P return supported3P
} }
if (modelUsesOpenAIEffort(model) && supportsCodexReasoningEffort(model)) {
return true
}
// Supported by a subset of Claude 4 models // Supported by a subset of Claude 4 models
if (m.includes('opus-4-6') || m.includes('sonnet-4-6')) { if (m.includes('opus-4-6') || m.includes('sonnet-4-6')) {
return true return true
@@ -86,6 +90,9 @@ export function modelUsesOpenAIEffort(model: string): boolean {
} }
export function getAvailableEffortLevels(model: string): EffortLevel[] | OpenAIEffortLevel[] { export function getAvailableEffortLevels(model: string): EffortLevel[] | OpenAIEffortLevel[] {
if (!modelSupportsEffort(model)) {
return []
}
if (modelUsesOpenAIEffort(model)) { if (modelUsesOpenAIEffort(model)) {
return [...OPENAI_EFFORT_LEVELS] as OpenAIEffortLevel[] return [...OPENAI_EFFORT_LEVELS] as OpenAIEffortLevel[]
} }

View File

@@ -4,6 +4,7 @@ import { tmpdir } from 'node:os'
import { join } from 'node:path' import { join } from 'node:path'
import test from 'node:test' import test from 'node:test'
import { DEFAULT_CODEX_BASE_URL } from '../services/api/providerConfig.ts'
import { import {
buildStartupEnvFromProfile, buildStartupEnvFromProfile,
buildAtomicChatProfileEnv, buildAtomicChatProfileEnv,
@@ -12,7 +13,9 @@ import {
buildLaunchEnv, buildLaunchEnv,
buildOllamaProfileEnv, buildOllamaProfileEnv,
buildOpenAIProfileEnv, buildOpenAIProfileEnv,
clearPersistedCodexOAuthProfile,
createProfileFile, createProfileFile,
isPersistedCodexOAuthProfile,
maskSecretForDisplay, maskSecretForDisplay,
loadProfileFile, loadProfileFile,
PROFILE_FILE_NAME, PROFILE_FILE_NAME,
@@ -23,6 +26,13 @@ import {
type ProfileFile, type ProfileFile,
} from './providerProfile.ts' } 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 { function profile(profile: ProfileFile['profile'], env: ProfileFile['env']): ProfileFile {
return { return {
profile, profile,
@@ -330,6 +340,7 @@ test('codex profiles accept explicit codex credentials', () => {
assert.deepEqual(env, { assert.deepEqual(env, {
OPENAI_BASE_URL: 'https://chatgpt.com/backend-api/codex', OPENAI_BASE_URL: 'https://chatgpt.com/backend-api/codex',
OPENAI_MODEL: 'codexspark', OPENAI_MODEL: 'codexspark',
CODEX_CREDENTIAL_SOURCE: 'existing',
CODEX_API_KEY: 'codex-live', CODEX_API_KEY: 'codex-live',
CHATGPT_ACCOUNT_ID: 'acct_123', 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 () => { test('buildStartupEnvFromProfile applies persisted gemini settings when no provider is explicitly selected', async () => {
const env = await buildStartupEnvFromProfile({ const env = await buildStartupEnvFromProfile({
persisted: profile('gemini', { persisted: profile('gemini', {

View File

@@ -7,6 +7,7 @@ import {
resolveCodexApiCredentials, resolveCodexApiCredentials,
resolveProviderRequest, resolveProviderRequest,
} from '../services/api/providerConfig.ts' } from '../services/api/providerConfig.ts'
import { parseChatgptAccountId } from '../services/api/codexOAuthShared.js'
import { import {
getGoalDefaultOpenAIModel, getGoalDefaultOpenAIModel,
normalizeRecommendationGoal, normalizeRecommendationGoal,
@@ -14,6 +15,20 @@ import {
} from './providerRecommendation.ts' } from './providerRecommendation.ts'
import { readGeminiAccessToken } from './geminiCredentials.ts' import { readGeminiAccessToken } from './geminiCredentials.ts'
import { getOllamaChatBaseUrl } from './providerDiscovery.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 PROFILE_FILE_NAME = '.openclaude-profile.json'
export const DEFAULT_GEMINI_BASE_URL = export const DEFAULT_GEMINI_BASE_URL =
@@ -33,6 +48,7 @@ const PROFILE_ENV_KEYS = [
'OPENAI_MODEL', 'OPENAI_MODEL',
'OPENAI_API_KEY', 'OPENAI_API_KEY',
'CODEX_API_KEY', 'CODEX_API_KEY',
'CODEX_CREDENTIAL_SOURCE',
'CHATGPT_ACCOUNT_ID', 'CHATGPT_ACCOUNT_ID',
'CODEX_ACCOUNT_ID', 'CODEX_ACCOUNT_ID',
'GEMINI_API_KEY', 'GEMINI_API_KEY',
@@ -46,21 +62,20 @@ const PROFILE_ENV_KEYS = [
'MISTRAL_MODEL', 'MISTRAL_MODEL',
] as const ] as const
const SECRET_ENV_KEYS = [ export type ProviderProfile =
'OPENAI_API_KEY', | 'openai'
'CODEX_API_KEY', | 'ollama'
'GEMINI_API_KEY', | 'codex'
'GOOGLE_API_KEY', | 'gemini'
'MISTRAL_API_KEY', | 'atomic-chat'
] as const | 'mistral'
export type ProviderProfile = 'openai' | 'ollama' | 'codex' | 'gemini' | 'atomic-chat' | 'mistral'
export type ProfileEnv = { export type ProfileEnv = {
OPENAI_BASE_URL?: string OPENAI_BASE_URL?: string
OPENAI_MODEL?: string OPENAI_MODEL?: string
OPENAI_API_KEY?: string OPENAI_API_KEY?: string
CODEX_API_KEY?: string CODEX_API_KEY?: string
CODEX_CREDENTIAL_SOURCE?: 'oauth' | 'existing'
CHATGPT_ACCOUNT_ID?: string CHATGPT_ACCOUNT_ID?: string
CODEX_ACCOUNT_ID?: string CODEX_ACCOUNT_ID?: string
GEMINI_API_KEY?: string GEMINI_API_KEY?: string
@@ -78,13 +93,6 @@ export type ProfileFile = {
createdAt: string createdAt: string
} }
type SecretValueSource = Partial<
Pick<
NodeJS.ProcessEnv & ProfileEnv,
(typeof SECRET_ENV_KEYS)[number]
>
>
type ProfileFileLocation = { type ProfileFileLocation = {
cwd?: string cwd?: string
filePath?: 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( export function buildOllamaProfileEnv(
model: string, model: string,
options: { options: {
@@ -335,6 +247,7 @@ export function buildCodexProfileEnv(options: {
model?: string | null model?: string | null
baseUrl?: string | null baseUrl?: string | null
apiKey?: string | null apiKey?: string | null
credentialSource?: 'oauth' | 'existing'
processEnv?: NodeJS.ProcessEnv processEnv?: NodeJS.ProcessEnv
}): ProfileEnv | null { }): ProfileEnv | null {
const processEnv = options.processEnv ?? process.env const processEnv = options.processEnv ?? process.env
@@ -346,10 +259,14 @@ export function buildCodexProfileEnv(options: {
if (!credentials.apiKey || !credentials.accountId) { if (!credentials.apiKey || !credentials.accountId) {
return null return null
} }
const credentialSource =
options.credentialSource ??
(credentials.source === 'secure-storage' ? 'oauth' : 'existing')
const env: ProfileEnv = { const env: ProfileEnv = {
OPENAI_BASE_URL: options.baseUrl || DEFAULT_CODEX_BASE_URL, OPENAI_BASE_URL: options.baseUrl || DEFAULT_CODEX_BASE_URL,
OPENAI_MODEL: options.model || 'codexplan', OPENAI_MODEL: options.model || 'codexplan',
CODEX_CREDENTIAL_SOURCE: credentialSource,
} }
if (key) { if (key) {
@@ -399,6 +316,30 @@ export function buildMistralProfileEnv(options: {
return env 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( export function createProfileFile(
profile: ProviderProfile, profile: ProviderProfile,
env: ProfileEnv, 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 { export function loadProfileFile(options?: ProfileFileLocation): ProfileFile | null {
const filePath = resolveProfileFilePath(options) const filePath = resolveProfileFilePath(options)
if (!existsSync(filePath)) { if (!existsSync(filePath)) {
@@ -545,6 +506,7 @@ export async function buildLaunchEnv(options: {
delete env.CLAUDE_CODE_USE_OPENAI delete env.CLAUDE_CODE_USE_OPENAI
delete env.CLAUDE_CODE_USE_GITHUB delete env.CLAUDE_CODE_USE_GITHUB
delete env.CODEX_CREDENTIAL_SOURCE
env.GEMINI_MODEL = env.GEMINI_MODEL =
shellGeminiModel || shellGeminiModel ||
@@ -668,6 +630,7 @@ export async function buildLaunchEnv(options: {
delete env.CLAUDE_CODE_USE_FOUNDRY delete env.CLAUDE_CODE_USE_FOUNDRY
delete env.CLAUDE_CODE_USE_GEMINI delete env.CLAUDE_CODE_USE_GEMINI
delete env.CLAUDE_CODE_USE_GITHUB delete env.CLAUDE_CODE_USE_GITHUB
delete env.CODEX_CREDENTIAL_SOURCE
delete env.GEMINI_API_KEY delete env.GEMINI_API_KEY
delete env.GEMINI_AUTH_MODE delete env.GEMINI_AUTH_MODE
delete env.GEMINI_ACCESS_TOKEN delete env.GEMINI_ACCESS_TOKEN
@@ -838,3 +801,40 @@ export function applyProfileEnvToProcessEnv(
Object.assign(targetEnv, nextEnv) 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
}

View 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
}

View File

@@ -6,11 +6,13 @@ import {
resolveProviderRequest, resolveProviderRequest,
} from '../services/api/providerConfig.js' } from '../services/api/providerConfig.js'
import { getGlobalClaudeFile } from './env.js' import { getGlobalClaudeFile } from './env.js'
import { isBareMode } from './envUtils.js'
import { import {
type GeminiResolvedCredential, type GeminiResolvedCredential,
resolveGeminiCredential, resolveGeminiCredential,
} from './geminiAuth.js' } 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 { function isEnvTruthy(value: string | undefined): boolean {
if (!value) return false if (!value) return false
@@ -82,6 +84,7 @@ export async function getProviderValidationError(
) => Promise<GeminiResolvedCredential> ) => Promise<GeminiResolvedCredential>
}, },
): Promise<string | null> { ): Promise<string | null> {
const secretSource = env
const useOpenAI = isEnvTruthy(env.CLAUDE_CODE_USE_OPENAI) const useOpenAI = isEnvTruthy(env.CLAUDE_CODE_USE_OPENAI)
const useGithub = isEnvTruthy(env.CLAUDE_CODE_USE_GITHUB) const useGithub = isEnvTruthy(env.CLAUDE_CODE_USE_GITHUB)
@@ -131,16 +134,17 @@ export async function getProviderValidationError(
if (request.transport === 'codex_responses') { if (request.transport === 'codex_responses') {
const credentials = resolveCodexApiCredentials(env) const credentials = resolveCodexApiCredentials(env)
if (!credentials.apiKey) { if (!credentials.apiKey) {
const oauthHint = isBareMode() ? '' : ', choose Codex OAuth in /provider'
const authHint = credentials.authPath const authHint = credentials.authPath
? ` or put auth.json at ${credentials.authPath}` ? `${oauthHint} or put auth.json at ${credentials.authPath}`
: '' : oauthHint
const safeModel = const safeModel =
redactSecretValueForDisplay(request.requestedModel, env) ?? redactSecretValueForDisplay(request.requestedModel, secretSource) ??
'the requested model' 'the requested model'
return `Codex auth is required for ${safeModel}. Set CODEX_API_KEY${authHint}.` return `Codex auth is required for ${safeModel}. Set CODEX_API_KEY${authHint}.`
} }
if (!credentials.accountId) { 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 return null
} }

View File

@@ -5,6 +5,16 @@ import { windowsCredentialStorage } from './windowsCredentialStorage.js'
import { plainTextStorage } from './plainTextStorage.js' import { plainTextStorage } from './plainTextStorage.js'
export interface SecureStorageData { export interface SecureStorageData {
codex?: {
apiKey?: string
accessToken: string
refreshToken?: string
idToken?: string
accountId?: string
profileId?: string
lastRefreshAt?: number
lastRefreshFailureAt?: number
}
mcpOAuth?: Record< mcpOAuth?: Record<
string, string,
{ {
@@ -36,22 +46,44 @@ export interface SecureStorage {
delete(): boolean 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. * Get the appropriate secure storage implementation for the current platform.
* Prefers native OS vaults (Keychain, libsecret, Credential Locker) with a plaintext fallback. * 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') { if (process.platform === 'darwin') {
return createFallbackStorage(macOsKeychainStorage, plainTextStorage) return allowPlainTextFallback
? createFallbackStorage(macOsKeychainStorage, plainTextStorage)
: macOsKeychainStorage
} }
if (process.platform === 'linux') { if (process.platform === 'linux') {
return createFallbackStorage(linuxSecretStorage, plainTextStorage) return allowPlainTextFallback
? createFallbackStorage(linuxSecretStorage, plainTextStorage)
: linuxSecretStorage
} }
if (process.platform === 'win32') { if (process.platform === 'win32') {
return createFallbackStorage(windowsCredentialStorage, plainTextStorage) return allowPlainTextFallback
? createFallbackStorage(windowsCredentialStorage, plainTextStorage)
: windowsCredentialStorage
} }
return plainTextStorage return allowPlainTextFallback ? plainTextStorage : unavailableSecureStorage
} }

View File

@@ -64,8 +64,10 @@ describe("Secure Storage Platform Implementations", () => {
windowsCredentialStorage.update(testData); windowsCredentialStorage.update(testData);
const script = mockExecaSync.mock.calls[0][1][1]; const script = mockExecaSync.mock.calls[0][1][1];
const options = mockExecaSync.mock.calls[0][2];
expect(script).toContain(expectedName); 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); windowsCredentialStorage.update(dataWithDollar);
const script = mockExecaSync.mock.calls[0][1][1]; const script = mockExecaSync.mock.calls[0][1][1];
// Should use single quotes for the payload const options = mockExecaSync.mock.calls[0][2];
expect(script).toMatch(/'\{.*\}'/); expect(script).toContain("[Console]::In.ReadToEnd()");
// Should escape ' by doubling it expect(options.input).toContain("token-with-$env:USERNAME");
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 dataWithQuote = { mcpOAuth: { "s": { accessToken: "token'quote", expiresAt: 1, serverName: "s", serverUrl: "u" } } }; const dataWithQuote = { mcpOAuth: { "s": { accessToken: "token'quote", expiresAt: 1, serverName: "s", serverUrl: "u" } } };
windowsCredentialStorage.update(dataWithQuote); windowsCredentialStorage.update(dataWithQuote);
const script2 = mockExecaSync.mock.calls[1][1][1]; const options2 = mockExecaSync.mock.calls[1][2];
expect(script2).toContain("token''quote"); expect(options2.input).toContain("token'quote");
}); });
test("delete() includes assembly load", () => { test("delete() includes assembly load", () => {
windowsCredentialStorage.delete(); 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"); expect(script).toContain("Add-Type -AssemblyName System.Runtime.WindowsRuntime");
}); });
test("escapes double quotes in username", () => { test("escapes double quotes in username", () => {
process.env.USER = 'user"name'; process.env.USER = 'user"name';
windowsCredentialStorage.read(); 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).toContain('user`"name');
expect(script).not.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", () => { describe("Linux secret-tool Interaction", () => {

View File

@@ -1,4 +1,6 @@
import { execaSync } from 'execa' import { execaSync } from 'execa'
import { join } from 'path'
import { getClaudeConfigHomeDir } from '../envUtils.js'
import { jsonParse, jsonStringify } from '../slowOperations.js' import { jsonParse, jsonStringify } from '../slowOperations.js'
import { import {
CREDENTIALS_SERVICE_SUFFIX, CREDENTIALS_SERVICE_SUFFIX,
@@ -8,90 +10,216 @@ import {
import type { SecureStorage, SecureStorageData } from './index.js' import type { SecureStorage, SecureStorageData } from './index.js'
/** /**
* Windows-specific secure storage implementation using the Windows Credential Locker. * Windows-specific secure storage implementation using DPAPI for new writes,
* Accessed via PowerShell's [Windows.Security.Credentials.PasswordVault]. * with best-effort reads/deletes from the legacy PasswordVault path.
*/ */
export const windowsCredentialStorage: SecureStorage = { function escapePowerShellSingleQuoted(value: string): string {
name: 'credential-locker', return value.replace(/'/g, "''")
read(): SecureStorageData | null { }
const resourceName = getSecureStorageServiceName(
CREDENTIALS_SERVICE_SUFFIX, function getLegacyResourceName(): string {
).replace(/"/g, '`"') 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 username = getUsername().replace(/"/g, '`"')
// PowerShell script to retrieve password from vault
const script = ` const script = `
Add-Type -AssemblyName System.Runtime.WindowsRuntime Add-Type -AssemblyName System.Runtime.WindowsRuntime
$vault = New-Object Windows.Security.Credentials.PasswordVault
try { try {
$vault = New-Object Windows.Security.Credentials.PasswordVault
$cred = $vault.Retrieve("${resourceName}", "${username}") $cred = $vault.Retrieve("${resourceName}", "${username}")
$cred.FillPassword() $cred.FillPassword()
$cred.Password [Console]::Out.Write($cred.Password)
} catch { } catch {
exit 1 exit 1
} }
` `
const result = runPowerShell(script)
if (result?.exitCode === 0 && result.stdout) {
try { try {
const result = execaSync('powershell.exe', ['-Command', script], {
reject: false,
})
if (result.exitCode === 0 && result.stdout) {
return jsonParse(result.stdout) return jsonParse(result.stdout)
}
} catch { } catch {
// fall through
}
return null 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 {
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
}
`
const result = runPowerShell(script)
if (result?.exitCode === 0 && result.stdout) {
try {
return jsonParse(result.stdout)
} catch {
return readLegacyPasswordVault()
}
}
return readLegacyPasswordVault()
}, },
async readAsync(): Promise<SecureStorageData | null> { async readAsync(): Promise<SecureStorageData | null> {
return this.read() return this.read()
}, },
update(data: SecureStorageData): { success: boolean; warning?: string } { update(data: SecureStorageData): { success: boolean; warning?: string } {
const resourceName = getSecureStorageServiceName( const filePath = escapePowerShellSingleQuoted(
CREDENTIALS_SERVICE_SUFFIX, getWindowsSecureStorageFilePath(),
).replace(/"/g, '`"') )
const username = getUsername().replace(/"/g, '`"') const entropy = escapePowerShellSingleQuoted(
// Use single quotes for the payload and escape ' by doubling it (''). getWindowsSecureStorageEntropy(),
// This prevents PowerShell from expanding $... inside the string. )
const payload = jsonStringify(data).replace(/'/g, "''") const payload = jsonStringify(data)
// PowerShell script to add/update credential in vault
const script = ` 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 { try {
const result = execaSync('powershell.exe', ['-Command', script], { Add-Type -AssemblyName System.Security
reject: false, $path = '${filePath}'
}) $directory = [System.IO.Path]::GetDirectoryName($path)
return { success: result.exitCode === 0 } 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 { } catch {
return { success: false } Write-Error $_.Exception.Message
exit 1
}
`
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 { delete(): boolean {
const resourceName = getSecureStorageServiceName( const filePath = escapePowerShellSingleQuoted(
CREDENTIALS_SERVICE_SUFFIX, getWindowsSecureStorageFilePath(),
).replace(/"/g, '`"') )
const username = getUsername().replace(/"/g, '`"') const removeDpapiScript = `
// PowerShell script to remove credential from vault
const script = `
Add-Type -AssemblyName System.Runtime.WindowsRuntime
$vault = New-Object Windows.Security.Credentials.PasswordVault
try { 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}") $cred = $vault.Retrieve("${resourceName}", "${username}")
$vault.Remove($cred) $vault.Remove($cred)
} catch { } catch {
exit 0 exit 0
} }
` `
try { const removeLegacyResult = runPowerShell(removeLegacyScript)
const result = execaSync('powershell.exe', ['-Command', script], {
reject: false, void removeLegacyResult
})
return result.exitCode === 0 return (removeDpapiResult?.exitCode ?? 1) === 0
} catch {
return false
}
}, },
} }