Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
110 changes: 102 additions & 8 deletions src/components/ProviderManager.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -300,14 +300,18 @@ function mockProviderManagerDependencies(
updateProviderProfile?: (...args: any[]) => unknown
setActiveProviderProfile?: (...args: any[]) => unknown
useCodexOAuthFlow?: (options: {
onAuthenticated: (tokens: {
accessToken: string
refreshToken: string
accountId?: string
idToken?: string
apiKey?: string
}, persistCredentials: (options?: { profileId?: string }) => void) =>
void | Promise<void>
onAuthenticated: (
tokens: {
accessToken: string
refreshToken: string
accountId?: string
idToken?: string
apiKey?: string
},
persistCredentials: (options?: {
profileId?: string
}) => { warning?: string } | void,
) => void | Promise<void>
}) => {
state: 'starting' | 'waiting' | 'error'
authUrl?: string
Expand Down Expand Up @@ -1384,6 +1388,96 @@ test('ProviderManager first-run Codex OAuth switches the current session after l
await mounted.dispose()
})

test('ProviderManager first-run Codex OAuth surfaces credential storage warnings', 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 () => null)
const persistCredentials = mock(() => ({
warning: 'Warning: Storing credentials in plaintext.',
}))
const setActiveProviderProfile = mock((profileId: string) => ({
id: profileId,
provider: 'openai',
name: 'Codex OAuth',
baseUrl: 'https://chatgpt.com/backend-api/codex',
model: 'codexplan',
apiKey: '',
}))
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,
setActiveProviderProfile,
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'),
)

await navigateToPreset(mounted.stdin, 'Codex OAuth')
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. OpenClaude switched to it for this session with warnings: Warning: Storing credentials in plaintext.',
}),
)

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
Expand Down
64 changes: 45 additions & 19 deletions src/components/ProviderManager.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,11 @@ type Screen =
| 'select-edit'
| 'select-delete'

type CodexOAuthPersistenceResult = { warning?: string }
type PersistCodexOAuthCredentials = (options?: {
profileId?: string
}) => CodexOAuthPersistenceResult | void

type DraftField =
| 'name'
| 'baseUrl'
Expand Down Expand Up @@ -600,23 +605,32 @@ function CodexOAuthSetup({
onConfigured,
}: {
onBack: () => void
onConfigured: (tokens: {
accessToken: string
refreshToken: string
accountId?: string
idToken?: string
apiKey?: string
}, persistCredentials: (options?: { profileId?: string }) => void) => void | Promise<void>
onConfigured: (
tokens: {
accessToken: string
refreshToken: string
accountId?: string
idToken?: string
apiKey?: string
},
persistCredentials: PersistCodexOAuthCredentials,
) => 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])
const handleAuthenticated = React.useCallback(
async (
tokens: {
accessToken: string
refreshToken: string
accountId?: string
idToken?: string
apiKey?: string
},
persistCredentials: PersistCodexOAuthCredentials,
) => {
await onConfigured(tokens, persistCredentials)
},
[onConfigured],
)
useKeybinding('confirm:no', onBack)

const status = useCodexOAuthFlow({
Expand Down Expand Up @@ -1078,17 +1092,22 @@ export function ProviderManager({ mode, onDone }: Props): React.ReactNode {
return clearStartupProviderOverrides()
}

function formatWarningsForMessage(warnings: string[]): string {
const joined = warnings.join('; ')
return /[.!?]$/.test(joined.trim()) ? joined : `${joined}.`
}

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('; ')}.`
return `${options.prefix}. Saved for next startup. Warning: ${formatWarningsForMessage(options.warnings)}`
}

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 with warnings: ${formatWarningsForMessage(options.warnings)}`
}

return `${options.prefix}. OpenClaude switched to it for this session.`
Expand Down Expand Up @@ -2470,14 +2489,21 @@ export function ProviderManager({ mode, onDone }: Props): React.ReactNode {
return
}

persistCredentials({ profileId: saved.id })
const persistenceResult = persistCredentials({
profileId: saved.id,
})
const storageWarning =
persistenceResult && typeof persistenceResult === 'object'
? persistenceResult.warning
: null
const settingsOverrideError =
clearStartupProviderOverrideFromUserSettings()
const activationWarning = await activateCodexOAuthSession(tokens)
setHasStoredCodexOAuthCredentials(true)
setStoredCodexOAuthProfileId(saved.id)
refreshProfiles()
const warnings = [
storageWarning,
activationWarning,
settingsOverrideError
? `could not clear startup provider override (${settingsOverrideError})`
Expand Down
Loading