diff --git a/src/commands/provider/provider.tsx b/src/commands/provider/provider.tsx
index 3aa9ac646..db53f5335 100644
--- a/src/commands/provider/provider.tsx
+++ b/src/commands/provider/provider.tsx
@@ -13,6 +13,7 @@ import {
} from '../../components/CustomSelect/index.js'
import { Dialog } from '../../components/design-system/Dialog.js'
import { LoadingState } from '../../components/design-system/LoadingState.js'
+import { useCodexDeviceCodeFlow } from '../../components/useCodexDeviceCodeFlow.js'
import { useCodexOAuthFlow } from '../../components/useCodexOAuthFlow.js'
import { useTerminalSize } from '../../hooks/useTerminalSize.js'
import { Box, Text } from '../../ink.js'
@@ -135,7 +136,7 @@ function describeOllamaReadinessIssue(
return ''
}
-type ProviderChoice = 'auto' | ProviderProfile | 'codex-oauth' | 'clear'
+type ProviderChoice = 'auto' | ProviderProfile | 'codex-oauth' | 'codex-device-code' | 'clear'
type Step =
| { name: 'choose' }
@@ -167,6 +168,7 @@ type Step =
authMode: 'api-key' | 'access-token' | 'adc'
}
| { name: 'codex-oauth' }
+ | { name: 'codex-device-code' }
| { name: 'codex-check' }
type CurrentProviderSummary = {
@@ -713,6 +715,12 @@ function ProviderChooser({
description:
'Sign in with ChatGPT in your browser and store Codex tokens securely',
},
+ {
+ label: 'Codex Device Code',
+ value: 'codex-device-code' as const,
+ description:
+ 'Sign in with a device code and store Codex tokens securely',
+ },
]
: []),
]
@@ -1098,6 +1106,84 @@ function OllamaModelStep({
)
}
+
+function CodexDeviceCodeStep({
+ 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 device-code sign-in succeeded, but OpenClaude could not build a Codex profile from the stored credentials.',
+ )
+ }
+
+ persistCredentials()
+ onSave('codex', env)
+ }, [onSave])
+
+ const status = useCodexDeviceCodeFlow({
+ onAuthenticated: handleAuthenticated,
+ })
+
+ if (status.state === 'error') {
+ return (
+
+ )
+ }
+
+ return (
+
+ )
+}
+
function CodexOAuthStep({
onSave,
onBack,
@@ -1324,6 +1410,8 @@ export function ProviderWizard({
})
} else if (value === 'codex-oauth') {
setStep({ name: 'codex-oauth' })
+ } else if (value === 'codex-device-code') {
+ setStep({ name: 'codex-device-code' })
} else if (value === 'clear') {
const filePath = deleteProfileFile()
onDone(`Removed saved provider profile at ${filePath}. Restart OpenClaude to go back to normal startup.`, {
@@ -1778,6 +1866,15 @@ export function ProviderWizard({
/>
)
+ case 'codex-device-code':
+ return (
+ finishProfileSave(onDone, profile, env)}
+ onBack={() => setStep({ name: 'choose' })}
+ onCancel={() => onDone()}
+ />
+ )
+
case 'codex-oauth':
return (
{
expect(output).toContain('Set up provider')
expect(output).not.toContain('Codex OAuth')
+ expect(output).not.toContain('Codex Device Code')
})
diff --git a/src/components/ProviderManager.tsx b/src/components/ProviderManager.tsx
index 9b05632c8..7c4f9bda1 100644
--- a/src/components/ProviderManager.tsx
+++ b/src/components/ProviderManager.tsx
@@ -73,6 +73,7 @@ import {
} from './CustomSelect/index.js'
import { Pane } from './design-system/Pane.js'
import TextInput from './TextInput.js'
+import { useCodexDeviceCodeFlow } from './useCodexDeviceCodeFlow.js'
import { useCodexOAuthFlow } from './useCodexOAuthFlow.js'
export type ProviderManagerResult = {
@@ -94,6 +95,7 @@ type Screen =
| 'select-ollama-model'
| 'select-atomic-chat-model'
| 'codex-oauth'
+ | 'codex-device-code'
| 'form'
| 'select-active'
| 'select-edit'
@@ -199,6 +201,8 @@ const GITHUB_PROVIDER_DEFAULT_MODEL = 'github:copilot'
const GITHUB_PROVIDER_DEFAULT_BASE_URL = 'https://models.github.ai/inference'
const CODEX_OAUTH_PROVIDER_NAME = 'Codex OAuth'
const CODEX_OAUTH_PROVIDER_MODEL = 'codexplan'
+const CODEX_DEVICE_CODE_PROVIDER_NAME = 'Codex Device Code'
+const CODEX_DEVICE_CODE_PROVIDER_MODEL = 'codexplan'
type GithubCredentialSource = 'stored' | 'env' | 'none'
@@ -482,6 +486,88 @@ function CodexOAuthSetup({
)
}
+function CodexDeviceCodeSetup({
+ onBack,
+ onConfigured,
+}: {
+ onBack: () => void
+ onConfigured: (tokens: {
+ accessToken: string
+ refreshToken: string
+ accountId?: string
+ idToken?: string
+ apiKey?: string
+ }, persistCredentials: (options?: { profileId?: string }) => void) => void | Promise
+}): 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)
+
+ const status = useCodexDeviceCodeFlow({
+ onAuthenticated: handleAuthenticated,
+ })
+
+ if (status.state === 'error') {
+ return (
+
+
+ Codex device-code sign-in failed
+
+ {status.message}
+ Press Enter or Esc to go back.
+
+
+ )
+ }
+
+ return (
+
+
+ Codex Device Code
+
+
+ Sign in with the device code shown below. OpenClaude will poll until the
+ login completes and then store the resulting Codex credentials securely.
+
+ {status.state === 'starting' ? (
+ Requesting your device code...
+ ) : (
+ <>
+
+ Visit {status.verificationUriComplete ?? status.verificationUri} and enter code{' '}
+ {status.userCode}.
+
+
+ Expires in {Math.max(1, Math.ceil(status.expiresIn / 60))} minutes.
+
+ {status.browserOpened === false ? (
+ Could not open the verification URL automatically.
+ ) : status.browserOpened === true ? (
+ Verification page opened in your browser.
+ ) : (
+ Opening the verification page...
+ )}
+ {status.verificationUriComplete ?? status.verificationUri}
+ >
+ )}
+ Press Esc to cancel and go back.
+
+ )
+}
+
+
export function ProviderManager({ mode, onDone }: Props): React.ReactNode {
const setAppState = useSetAppState()
const initialGithubCredentialSource = getGithubCredentialSourceFromEnv()
@@ -1456,17 +1542,28 @@ export function ProviderManager({ mode, onDone }: Props): React.ReactNode {
})
if (canUseCodexOAuth) {
- options.splice(6, 0, {
- value: 'codex-oauth',
- label: (
-
- Codex OAuth
- ★ Recommended
-
- ),
- description:
- 'Sign in with ChatGPT in your browser and store Codex credentials securely',
- })
+ const codexInsertIndex = options.findIndex(option => option.value === 'gemini')
+ options.splice(
+ codexInsertIndex === -1 ? 6 : codexInsertIndex,
+ 0,
+ {
+ value: 'codex-oauth',
+ label: (
+
+ Codex OAuth
+ ★ Recommended
+
+ ),
+ description:
+ 'Sign in with ChatGPT in your browser and store Codex credentials securely',
+ },
+ {
+ value: 'codex-device-code',
+ label: 'Codex Device Code',
+ description:
+ 'Sign in with a device code and store Codex credentials securely',
+ },
+ )
}
if (mode === 'first-run') {
@@ -1496,6 +1593,10 @@ export function ProviderManager({ mode, onDone }: Props): React.ReactNode {
setScreen('codex-oauth')
return
}
+ if (value === 'codex-device-code') {
+ setScreen('codex-device-code')
+ return
+ }
startCreateFromPreset(value as ProviderPreset)
}}
onCancel={() => {
@@ -1783,6 +1884,73 @@ export function ProviderManager({ mode, onDone }: Props): React.ReactNode {
case 'select-atomic-chat-model':
content = renderAtomicChatSelection()
break
+ case 'codex-device-code':
+ content = (
+ setScreen('select-preset')}
+ onConfigured={async (tokens, persistCredentials) => {
+ const payload: ProviderProfileInput = {
+ provider: 'openai',
+ name: CODEX_DEVICE_CODE_PROVIDER_NAME,
+ baseUrl: DEFAULT_CODEX_BASE_URL,
+ model: CODEX_DEVICE_CODE_PROVIDER_MODEL,
+ apiKey: '',
+ }
+
+ const saved = addProviderProfile(payload, { makeActive: false })
+ if (!saved) {
+ setErrorMessage(
+ 'Codex device-code login finished, but the provider profile could not be saved.',
+ )
+ returnToMenu()
+ return
+ }
+
+ const active = setActiveProviderProfile(saved.id)
+ if (!active) {
+ setErrorMessage(
+ 'Codex device-code login finished, but the provider could not be set as the startup provider.',
+ )
+ returnToMenu()
+ 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 Device Code configured',
+ activationWarning,
+ warnings,
+ })
+
+ if (mode === 'first-run') {
+ onDone({
+ action: 'saved',
+ activeProfileId: active.id,
+ message,
+ })
+ return
+ }
+
+ setStatusMessage(message)
+ setErrorMessage(undefined)
+ returnToMenu()
+ }}
+ />
+ )
+ break
+
case 'codex-oauth':
content = (
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 {
+ 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 useCodexDeviceCodeFlow test condition')
+}
+
+afterEach(() => {
+ mock.restore()
+})
+
+test('reports bare-mode error without starting device flow', async () => {
+ const requestCodexDeviceCode = mock(async () => DEVICE_CODE)
+ const pollCodexDeviceToken = mock(async () => TOKENS)
+ const openVerificationUri = mock(async () => true)
+ const saveCodexCredentials = mock(() => ({ success: true }))
+ const onAuthenticated = mock(async () => {})
+ const deps = {
+ requestCodexDeviceCode,
+ pollCodexDeviceToken,
+ openVerificationUri,
+ saveCodexCredentials,
+ isBareMode: () => true,
+ }
+
+ const { useCodexDeviceCodeFlow } = await import(
+ `./useCodexDeviceCodeFlow.js?bare-${Date.now()}-${Math.random()}`
+ )
+
+ function Harness(): React.ReactNode {
+ const handleAuthenticated = React.useCallback(onAuthenticated, [onAuthenticated])
+ const status = useCodexDeviceCodeFlow({
+ onAuthenticated: handleAuthenticated,
+ deps,
+ })
+
+ return {status.state === 'error' ? status.message : status.state}
+ }
+
+ 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()
+
+ try {
+ await waitForCondition(() => streams.getOutput().includes('unavailable in --bare'))
+ expect(requestCodexDeviceCode).not.toHaveBeenCalled()
+ expect(openVerificationUri).not.toHaveBeenCalled()
+ expect(pollCodexDeviceToken).not.toHaveBeenCalled()
+ expect(saveCodexCredentials).not.toHaveBeenCalled()
+ expect(onAuthenticated).not.toHaveBeenCalled()
+ } finally {
+ root.unmount()
+ streams.stdin.end()
+ streams.stdout.end()
+ await Bun.sleep(0)
+ }
+})
+
+test('opens complete verification uri and polls with device code timing', async () => {
+ const requestCodexDeviceCode = mock(async () => DEVICE_CODE)
+ const pollCodexDeviceToken = mock(async () => TOKENS)
+ const openVerificationUri = mock(async () => true)
+ const saveCodexCredentials = mock(() => ({ success: true }))
+ const onAuthenticated = mock(async () => {})
+ const deps = {
+ requestCodexDeviceCode,
+ pollCodexDeviceToken,
+ openVerificationUri,
+ saveCodexCredentials,
+ isBareMode: () => false,
+ }
+
+ const { useCodexDeviceCodeFlow } = await import(
+ `./useCodexDeviceCodeFlow.js?poll-${Date.now()}-${Math.random()}`
+ )
+
+ function Harness(): React.ReactNode {
+ const handleAuthenticated = React.useCallback(onAuthenticated, [onAuthenticated])
+ const status = useCodexDeviceCodeFlow({
+ onAuthenticated: handleAuthenticated,
+ deps,
+ })
+
+ if (status.state !== 'waiting') return {status.state}
+ return {`${status.userCode} ${status.verificationUri}`}
+ }
+
+ 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()
+
+ try {
+ await waitForCondition(() => pollCodexDeviceToken.mock.calls.length === 1)
+ expect(openVerificationUri).toHaveBeenCalledWith(
+ DEVICE_CODE.verificationUriComplete,
+ )
+ expect(pollCodexDeviceToken).toHaveBeenCalledWith(DEVICE_CODE.deviceCode, {
+ initialInterval: DEVICE_CODE.interval,
+ timeoutSeconds: DEVICE_CODE.expiresIn,
+ signal: expect.any(AbortSignal),
+ })
+ } finally {
+ root.unmount()
+ streams.stdin.end()
+ streams.stdout.end()
+ await Bun.sleep(0)
+ }
+})
+
+test('does not persist credentials when downstream setup rejects', async () => {
+ const requestCodexDeviceCode = mock(async () => DEVICE_CODE)
+ const pollCodexDeviceToken = mock(async () => TOKENS)
+ const openVerificationUri = mock(async () => true)
+ const saveCodexCredentials = mock(() => ({ success: true }))
+ const onAuthenticated = mock(async () => {
+ throw new Error('profile save failed')
+ })
+ const deps = {
+ requestCodexDeviceCode,
+ pollCodexDeviceToken,
+ openVerificationUri,
+ saveCodexCredentials,
+ isBareMode: () => false,
+ }
+
+ const { useCodexDeviceCodeFlow } = await import(
+ `./useCodexDeviceCodeFlow.js?reject-${Date.now()}-${Math.random()}`
+ )
+
+ function Harness(): React.ReactNode {
+ const handleAuthenticated = React.useCallback(onAuthenticated, [onAuthenticated])
+ const status = useCodexDeviceCodeFlow({
+ onAuthenticated: handleAuthenticated,
+ deps,
+ })
+
+ return {status.state === 'error' ? status.message : status.state}
+ }
+
+ 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()
+
+ 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 requestCodexDeviceCode = mock(async () => DEVICE_CODE)
+ const pollCodexDeviceToken = mock(async () => TOKENS)
+ const openVerificationUri = mock(async () => true)
+ const saveCodexCredentials = mock(() => ({ success: true }))
+ const onAuthenticated = mock(
+ async (
+ _tokens: typeof TOKENS,
+ persistCredentials: (options?: { profileId?: string }) => void,
+ ) => {
+ persistCredentials({ profileId: 'profile_codex_device' })
+ },
+ )
+ const deps = {
+ requestCodexDeviceCode,
+ pollCodexDeviceToken,
+ openVerificationUri,
+ saveCodexCredentials,
+ isBareMode: () => false,
+ }
+
+ const { useCodexDeviceCodeFlow } = await import(
+ `./useCodexDeviceCodeFlow.js?persist-${Date.now()}-${Math.random()}`
+ )
+
+ function Harness(): React.ReactNode {
+ const handleAuthenticated = React.useCallback(onAuthenticated, [onAuthenticated])
+ useCodexDeviceCodeFlow({
+ onAuthenticated: handleAuthenticated,
+ deps,
+ })
+ return waiting
+ }
+
+ 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()
+
+ 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_device',
+ })
+ } finally {
+ root.unmount()
+ streams.stdin.end()
+ streams.stdout.end()
+ await Bun.sleep(0)
+ }
+})
+
+test('aborts polling on unmount', async () => {
+ let pollingSignal: AbortSignal | undefined
+ const requestCodexDeviceCode = mock(async () => DEVICE_CODE)
+ const pollCodexDeviceToken = mock(
+ async (
+ _deviceCode: string,
+ options?: { signal?: AbortSignal },
+ ): Promise => {
+ pollingSignal = options?.signal
+ await new Promise(() => {})
+ return TOKENS
+ },
+ )
+ const openVerificationUri = mock(async () => true)
+ const saveCodexCredentials = mock(() => ({ success: true }))
+ const onAuthenticated = mock(async () => {})
+ const deps = {
+ requestCodexDeviceCode,
+ pollCodexDeviceToken,
+ openVerificationUri,
+ saveCodexCredentials,
+ isBareMode: () => false,
+ }
+
+ const { useCodexDeviceCodeFlow } = await import(
+ `./useCodexDeviceCodeFlow.js?abort-${Date.now()}-${Math.random()}`
+ )
+
+ function Harness(): React.ReactNode {
+ const handleAuthenticated = React.useCallback(onAuthenticated, [onAuthenticated])
+ useCodexDeviceCodeFlow({
+ onAuthenticated: handleAuthenticated,
+ deps,
+ })
+ return waiting
+ }
+
+ 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()
+
+ try {
+ await waitForCondition(() => pollingSignal !== undefined)
+ expect(pollingSignal?.aborted).toBe(false)
+ root.unmount()
+ await Bun.sleep(0)
+ expect(pollingSignal?.aborted).toBe(true)
+ } finally {
+ streams.stdin.end()
+ streams.stdout.end()
+ await Bun.sleep(0)
+ }
+})
diff --git a/src/components/useCodexDeviceCodeFlow.ts b/src/components/useCodexDeviceCodeFlow.ts
new file mode 100644
index 000000000..3a7fcfb01
--- /dev/null
+++ b/src/components/useCodexDeviceCodeFlow.ts
@@ -0,0 +1,152 @@
+import * as React from 'react'
+
+import {
+ openVerificationUri,
+ pollCodexDeviceToken,
+ requestCodexDeviceCode,
+} from '../services/api/codexDeviceFlow.js'
+import type { CodexOAuthTokens } from '../services/api/codexOAuth.js'
+import { saveCodexCredentials } from '../utils/codexCredentials.js'
+import { isBareMode } from '../utils/envUtils.js'
+
+export type CodexDeviceCodeFlowStatus =
+ | { state: 'starting' }
+ | {
+ state: 'waiting'
+ userCode: string
+ verificationUri: string
+ verificationUriComplete?: string
+ expiresIn: number
+ interval: number
+ browserOpened: boolean | null
+ }
+ | {
+ state: 'error'
+ message: string
+ }
+
+type PersistCodexCredentials = (options?: {
+ profileId?: string
+}) => void
+
+type CodexDeviceCodeFlowDependencies = {
+ requestCodexDeviceCode?: typeof requestCodexDeviceCode
+ pollCodexDeviceToken?: typeof pollCodexDeviceToken
+ openVerificationUri?: typeof openVerificationUri
+ saveCodexCredentials?: typeof saveCodexCredentials
+ isBareMode?: typeof isBareMode
+}
+
+export function useCodexDeviceCodeFlow(options: {
+ onAuthenticated: (
+ tokens: CodexOAuthTokens,
+ persistCredentials: PersistCodexCredentials,
+ ) => void | Promise
+ deps?: CodexDeviceCodeFlowDependencies
+}): CodexDeviceCodeFlowStatus {
+ const { onAuthenticated } = options
+ const requestDeviceCode =
+ options.deps?.requestCodexDeviceCode ?? requestCodexDeviceCode
+ const pollDeviceToken =
+ options.deps?.pollCodexDeviceToken ?? pollCodexDeviceToken
+ const openVerificationUriFn =
+ options.deps?.openVerificationUri ?? openVerificationUri
+ const saveCredentials =
+ options.deps?.saveCodexCredentials ?? saveCodexCredentials
+ const isBareModeFn = options.deps?.isBareMode ?? isBareMode
+ const [status, setStatus] = React.useState({
+ state: 'starting',
+ })
+
+ React.useEffect(() => {
+ if (isBareModeFn()) {
+ setStatus({
+ state: 'error',
+ message:
+ 'Codex device-code sign-in is unavailable in --bare because secure storage is disabled.',
+ })
+ return
+ }
+
+ let cancelled = false
+ const controller = new AbortController()
+
+ void (async () => {
+ try {
+ const deviceCode = await requestDeviceCode()
+ if (cancelled) return
+
+ setStatus({
+ state: 'waiting',
+ userCode: deviceCode.userCode,
+ verificationUri: deviceCode.verificationUri,
+ verificationUriComplete: deviceCode.verificationUriComplete,
+ expiresIn: deviceCode.expiresIn,
+ interval: deviceCode.interval,
+ browserOpened: null,
+ })
+
+ const browserOpened = await openVerificationUriFn(
+ deviceCode.verificationUriComplete ?? deviceCode.verificationUri,
+ )
+ if (cancelled) return
+
+ setStatus({
+ state: 'waiting',
+ userCode: deviceCode.userCode,
+ verificationUri: deviceCode.verificationUri,
+ verificationUriComplete: deviceCode.verificationUriComplete,
+ expiresIn: deviceCode.expiresIn,
+ interval: deviceCode.interval,
+ browserOpened,
+ })
+
+ const tokens = await pollDeviceToken(deviceCode.deviceCode, {
+ initialInterval: deviceCode.interval,
+ timeoutSeconds: deviceCode.expiresIn,
+ signal: controller.signal,
+ })
+ if (cancelled) return
+
+ const persistCredentials: PersistCodexCredentials = 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 device-code sign-in 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
+ controller.abort()
+ }
+ }, [
+ isBareModeFn,
+ onAuthenticated,
+ openVerificationUriFn,
+ pollDeviceToken,
+ requestDeviceCode,
+ saveCredentials,
+ ])
+
+ return status
+}
diff --git a/src/services/api/codexDeviceFlow.test.ts b/src/services/api/codexDeviceFlow.test.ts
new file mode 100644
index 000000000..81d36337a
--- /dev/null
+++ b/src/services/api/codexDeviceFlow.test.ts
@@ -0,0 +1,209 @@
+import { describe, expect, mock, test } from 'bun:test'
+
+import {
+ CODEX_DEVICE_CODE_URL,
+ CODEX_DEVICE_REFRESH_URL,
+ CODEX_DEVICE_TOKEN_GRANT,
+ pollCodexDeviceToken,
+ requestCodexDeviceCode,
+} from './codexDeviceFlow.js'
+
+function jsonResponse(payload: unknown, init?: ResponseInit): Response {
+ return new Response(JSON.stringify(payload), {
+ headers: { 'Content-Type': 'application/json' },
+ ...init,
+ })
+}
+
+describe('requestCodexDeviceCode', () => {
+ test('parses successful device-code response and sends expected form fields', async () => {
+ let requestInit: RequestInit | undefined
+ const fetchImpl = mock(async (_input: RequestInfo | URL, init?: RequestInit) => {
+ requestInit = init
+ return jsonResponse({
+ device_code: 'device-code',
+ user_code: 'USER-CODE',
+ verification_uri: 'https://auth.openai.com/activate',
+ verification_uri_complete:
+ 'https://auth.openai.com/activate?user_code=USER-CODE',
+ expires_in: 900,
+ interval: 5,
+ })
+ })
+
+ const result = await requestCodexDeviceCode({
+ clientId: 'client-id',
+ scope: 'scope-a scope-b',
+ fetchImpl,
+ })
+
+ expect(fetchImpl).toHaveBeenCalledWith(CODEX_DEVICE_CODE_URL, {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/x-www-form-urlencoded',
+ },
+ body: expect.any(URLSearchParams),
+ signal: expect.any(AbortSignal),
+ })
+ expect((requestInit?.body as URLSearchParams).get('client_id')).toBe(
+ 'client-id',
+ )
+ expect((requestInit?.body as URLSearchParams).get('scope')).toBe(
+ 'scope-a scope-b',
+ )
+ expect((requestInit?.body as URLSearchParams).get('originator')).toBe(
+ 'codex_cli_rs',
+ )
+ expect(
+ (requestInit?.body as URLSearchParams).get('id_token_add_organizations'),
+ ).toBe('true')
+ expect(result).toEqual({
+ deviceCode: 'device-code',
+ userCode: 'USER-CODE',
+ verificationUri: 'https://auth.openai.com/activate',
+ verificationUriComplete:
+ 'https://auth.openai.com/activate?user_code=USER-CODE',
+ expiresIn: 900,
+ interval: 5,
+ })
+ })
+
+ test('aborts a hanging request after the per-request timeout', async () => {
+ const fetchImpl = mock(
+ async (_input: RequestInfo | URL, init?: RequestInit): Promise => {
+ await new Promise((_resolve, reject) => {
+ init?.signal?.addEventListener('abort', () => reject(init.signal?.reason), {
+ once: true,
+ })
+ })
+ throw new Error('unreachable')
+ },
+ )
+
+ await expect(
+ requestCodexDeviceCode({
+ fetchImpl,
+ requestTimeoutMs: 1,
+ }),
+ ).rejects.toThrow()
+ })
+
+ test('honors caller abort signal', async () => {
+ let requestSignal: AbortSignal | undefined
+ const controller = new AbortController()
+ const fetchImpl = mock(
+ async (_input: RequestInfo | URL, init?: RequestInit): Promise => {
+ requestSignal = init?.signal ?? undefined
+ await new Promise((_resolve, reject) => {
+ init?.signal?.addEventListener('abort', () => reject(init.signal?.reason), {
+ once: true,
+ })
+ })
+ throw new Error('unreachable')
+ },
+ )
+
+ const result = requestCodexDeviceCode({
+ fetchImpl,
+ signal: controller.signal,
+ requestTimeoutMs: 60_000,
+ })
+ await Bun.sleep(0)
+ controller.abort(new Error('cancelled'))
+
+ await expect(result).rejects.toThrow('cancelled')
+ expect(requestSignal?.aborted).toBe(true)
+ })
+})
+
+describe('pollCodexDeviceToken', () => {
+ test('polls with request signal and returns tokens', async () => {
+ let requestInit: RequestInit | undefined
+ const fetchImpl = mock(async (_input: RequestInfo | URL, init?: RequestInit) => {
+ requestInit = init
+ return jsonResponse({
+ access_token: 'access-token',
+ refresh_token: 'refresh-token',
+ chatgpt_account_id: 'account-id',
+ })
+ })
+
+ const result = await pollCodexDeviceToken('device-code', {
+ clientId: 'client-id',
+ fetchImpl,
+ requestTimeoutMs: 1_000,
+ })
+
+ expect(fetchImpl).toHaveBeenCalledWith(CODEX_DEVICE_REFRESH_URL, {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/x-www-form-urlencoded',
+ },
+ body: expect.any(URLSearchParams),
+ signal: expect.any(AbortSignal),
+ })
+ expect((requestInit?.body as URLSearchParams).get('client_id')).toBe(
+ 'client-id',
+ )
+ expect((requestInit?.body as URLSearchParams).get('grant_type')).toBe(
+ CODEX_DEVICE_TOKEN_GRANT,
+ )
+ expect((requestInit?.body as URLSearchParams).get('device_code')).toBe(
+ 'device-code',
+ )
+ expect(result).toEqual({
+ accessToken: 'access-token',
+ refreshToken: 'refresh-token',
+ idToken: undefined,
+ apiKey: undefined,
+ accountId: 'account-id',
+ })
+ })
+
+ test('aborts a hanging polling request after the per-request timeout', async () => {
+ const fetchImpl = mock(
+ async (_input: RequestInfo | URL, init?: RequestInit): Promise => {
+ await new Promise((_resolve, reject) => {
+ init?.signal?.addEventListener('abort', () => reject(init.signal?.reason), {
+ once: true,
+ })
+ })
+ throw new Error('unreachable')
+ },
+ )
+
+ await expect(
+ pollCodexDeviceToken('device-code', {
+ fetchImpl,
+ requestTimeoutMs: 1,
+ }),
+ ).rejects.toThrow()
+ })
+
+ test('honors caller abort signal while polling', async () => {
+ let requestSignal: AbortSignal | undefined
+ const controller = new AbortController()
+ const fetchImpl = mock(
+ async (_input: RequestInfo | URL, init?: RequestInit): Promise => {
+ requestSignal = init?.signal ?? undefined
+ await new Promise((_resolve, reject) => {
+ init?.signal?.addEventListener('abort', () => reject(init.signal?.reason), {
+ once: true,
+ })
+ })
+ throw new Error('unreachable')
+ },
+ )
+
+ const result = pollCodexDeviceToken('device-code', {
+ fetchImpl,
+ signal: controller.signal,
+ requestTimeoutMs: 60_000,
+ })
+ await Bun.sleep(0)
+ controller.abort(new Error('cancelled'))
+
+ await expect(result).rejects.toThrow('cancelled')
+ expect(requestSignal?.aborted).toBe(true)
+ })
+})
diff --git a/src/services/api/codexDeviceFlow.ts b/src/services/api/codexDeviceFlow.ts
new file mode 100644
index 000000000..8b6c885b9
--- /dev/null
+++ b/src/services/api/codexDeviceFlow.ts
@@ -0,0 +1,307 @@
+import { openBrowser } from '../../utils/browser.js'
+import {
+ asTrimmedString,
+ CODEX_OAUTH_ISSUER,
+ CODEX_OAUTH_ORIGINATOR,
+ CODEX_OAUTH_SCOPE,
+ CODEX_REFRESH_URL as SHARED_CODEX_REFRESH_URL,
+ exchangeCodexIdTokenForApiKey,
+ getCodexOAuthClientId,
+ parseChatgptAccountId,
+} from './codexOAuthShared.js'
+import type { CodexOAuthTokens } from './codexOAuth.js'
+
+export const CODEX_DEVICE_REFRESH_URL = SHARED_CODEX_REFRESH_URL
+export const CODEX_DEVICE_CODE_URL = `${CODEX_OAUTH_ISSUER}/oauth/device/code`
+export const CODEX_DEVICE_TOKEN_GRANT =
+ 'urn:ietf:params:oauth:grant-type:device_code'
+const CODEX_DEVICE_FLOW_REQUEST_TIMEOUT_MS = 15_000
+
+export class CodexDeviceFlowError extends Error {
+ constructor(message: string) {
+ super(message)
+ this.name = 'CodexDeviceFlowError'
+ }
+}
+
+export type CodexDeviceCodeResult = {
+ deviceCode: string
+ userCode: string
+ verificationUri: string
+ verificationUriComplete?: string
+ expiresIn: number
+ interval: number
+}
+
+type FetchLike = (input: RequestInfo | URL, init?: RequestInit) => Promise
+
+function createRequestSignal(
+ signal?: AbortSignal,
+ timeoutMs = CODEX_DEVICE_FLOW_REQUEST_TIMEOUT_MS,
+): AbortSignal {
+ const timeoutSignal = AbortSignal.timeout(Math.max(1, Math.floor(timeoutMs)))
+ return signal ? AbortSignal.any([signal, timeoutSignal]) : timeoutSignal
+}
+
+function sleep(ms: number, signal?: AbortSignal): Promise {
+ return new Promise((resolve, reject) => {
+ const timeout = setTimeout(() => {
+ cleanup()
+ resolve()
+ }, ms)
+
+ const onAbort = (): void => {
+ cleanup()
+ reject(new DOMException('The operation was aborted.', 'AbortError'))
+ }
+
+ const cleanup = (): void => {
+ clearTimeout(timeout)
+ signal?.removeEventListener('abort', onAbort)
+ }
+
+ if (signal) {
+ if (signal.aborted) {
+ cleanup()
+ reject(new DOMException('The operation was aborted.', 'AbortError'))
+ return
+ }
+ signal.addEventListener('abort', onAbort, { once: true })
+ }
+ })
+}
+
+function readString(value: unknown): string | undefined {
+ return asTrimmedString(value)
+}
+
+function parseDeviceCodeResponse(payload: Record): CodexDeviceCodeResult {
+ const deviceCode = readString(payload.device_code)
+ const userCode = readString(payload.user_code)
+ const verificationUri =
+ readString(payload.verification_uri) ?? readString(payload.verification_url)
+ const verificationUriComplete =
+ readString(payload.verification_uri_complete) ??
+ readString(payload.verification_url_complete)
+ const expiresIn = Number(payload.expires_in)
+ const interval = Number(payload.interval)
+
+ if (
+ !deviceCode ||
+ !userCode ||
+ !verificationUri ||
+ !Number.isFinite(expiresIn) ||
+ !Number.isFinite(interval)
+ ) {
+ throw new CodexDeviceFlowError(
+ 'Codex device-code response was missing required fields.',
+ )
+ }
+
+ return {
+ deviceCode,
+ userCode,
+ verificationUri,
+ verificationUriComplete,
+ expiresIn,
+ interval: interval > 0 ? interval : 5,
+ }
+}
+
+async function readJsonResponse(response: Response): Promise> {
+ try {
+ const payload = (await response.json()) as unknown
+ return payload && typeof payload === 'object'
+ ? (payload as Record)
+ : {}
+ } catch {
+ return {}
+ }
+}
+
+function getErrorMessage(response: Response, payload: Record): string {
+ const error = readString(payload.error)
+ const description = readString(payload.error_description)
+ const detail = description ?? error
+ if (detail) {
+ return `Codex device-code request failed (${response.status}): ${detail}`
+ }
+ return `Codex device-code request failed with status ${response.status}.`
+}
+
+export async function requestCodexDeviceCode(options?: {
+ clientId?: string
+ scope?: string
+ fetchImpl?: FetchLike
+ signal?: AbortSignal
+ requestTimeoutMs?: number
+}): Promise {
+ const fetchFn = options?.fetchImpl ?? fetch
+ const body = new URLSearchParams({
+ client_id: options?.clientId ?? getCodexOAuthClientId(),
+ scope: options?.scope ?? CODEX_OAUTH_SCOPE,
+ originator: CODEX_OAUTH_ORIGINATOR,
+ id_token_add_organizations: 'true',
+ })
+
+ const response = await fetchFn(CODEX_DEVICE_CODE_URL, {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/x-www-form-urlencoded',
+ },
+ body,
+ signal: createRequestSignal(options?.signal, options?.requestTimeoutMs),
+ })
+
+ const payload = await readJsonResponse(response)
+ if (!response.ok) {
+ throw new CodexDeviceFlowError(getErrorMessage(response, payload))
+ }
+
+ return parseDeviceCodeResponse(payload)
+}
+
+async function pollTokenOnce(options: {
+ deviceCode: string
+ clientId: string
+ fetchImpl: FetchLike
+ signal?: AbortSignal
+ requestTimeoutMs?: number
+}): Promise<
+ | { state: 'pending'; interval?: number }
+ | { state: 'denied' }
+ | { state: 'expired' }
+ | { state: 'slow_down'; interval?: number }
+ | { state: 'success'; tokens: CodexOAuthTokens }
+ | { state: 'error'; message: string }
+> {
+ const body = new URLSearchParams({
+ client_id: options.clientId,
+ grant_type: CODEX_DEVICE_TOKEN_GRANT,
+ device_code: options.deviceCode,
+ })
+
+ const response = await options.fetchImpl(CODEX_DEVICE_REFRESH_URL, {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/x-www-form-urlencoded',
+ },
+ body,
+ signal: createRequestSignal(options.signal, options.requestTimeoutMs),
+ })
+
+ const payload = await readJsonResponse(response)
+ if (response.ok) {
+ const accessToken = readString(payload.access_token)
+ const refreshToken = readString(payload.refresh_token)
+ const idToken = readString(payload.id_token)
+ const accountId =
+ readString(payload.chatgpt_account_id) ??
+ parseChatgptAccountId(idToken) ??
+ parseChatgptAccountId(accessToken)
+
+ if (!accessToken || !refreshToken) {
+ return {
+ state: 'error',
+ message:
+ 'Codex device-code sign-in completed, but no usable tokens were returned.',
+ }
+ }
+
+ const apiKey = idToken
+ ? await exchangeCodexIdTokenForApiKey(idToken).catch(() => undefined)
+ : undefined
+
+ return {
+ state: 'success',
+ tokens: {
+ apiKey,
+ accessToken,
+ refreshToken,
+ idToken,
+ accountId,
+ },
+ }
+ }
+
+ const error = readString(payload.error)
+ const interval = Number(payload.interval)
+
+ switch (error) {
+ case 'authorization_pending':
+ return {
+ state: 'pending',
+ interval: Number.isFinite(interval) && interval > 0 ? interval : undefined,
+ }
+ case 'slow_down':
+ return {
+ state: 'slow_down',
+ interval: Number.isFinite(interval) && interval > 0 ? interval : undefined,
+ }
+ case 'access_denied':
+ case 'authorization_declined':
+ return { state: 'denied' }
+ case 'expired_token':
+ return { state: 'expired' }
+ default:
+ return {
+ state: 'error',
+ message: getErrorMessage(response, payload),
+ }
+ }
+}
+
+export async function pollCodexDeviceToken(
+ deviceCode: string,
+ options?: {
+ clientId?: string
+ initialInterval?: number
+ timeoutSeconds?: number
+ fetchImpl?: FetchLike
+ signal?: AbortSignal
+ requestTimeoutMs?: number
+ },
+): Promise {
+ const fetchFn = options?.fetchImpl ?? fetch
+ const clientId = options?.clientId ?? getCodexOAuthClientId()
+ let interval = Math.max(1, Math.floor(options?.initialInterval ?? 5))
+ const timeoutMs = Math.max(1, Math.floor(options?.timeoutSeconds ?? 15 * 60)) * 1000
+ const startedAt = Date.now()
+
+ while (Date.now() - startedAt < timeoutMs) {
+ const result = await pollTokenOnce({
+ deviceCode,
+ clientId,
+ fetchImpl: fetchFn,
+ signal: options?.signal,
+ requestTimeoutMs: options?.requestTimeoutMs,
+ })
+
+ if (result.state === 'success') {
+ return result.tokens
+ }
+ if (result.state === 'error') {
+ throw new CodexDeviceFlowError(result.message)
+ }
+ if (result.state === 'denied') {
+ throw new CodexDeviceFlowError('Authorization was denied or cancelled.')
+ }
+ if (result.state === 'expired') {
+ throw new CodexDeviceFlowError('Device code expired. Start the login flow again.')
+ }
+
+ if (result.state === 'slow_down' && result.interval) {
+ interval = Math.max(interval + 5, result.interval)
+ } else if (result.interval) {
+ interval = Math.max(1, result.interval)
+ }
+
+ await sleep(interval * 1000, options?.signal)
+ }
+
+ throw new CodexDeviceFlowError('Device code expired. Start the login flow again.')
+}
+
+export async function openVerificationUri(uri: string): Promise {
+ return openBrowser(uri)
+}
diff --git a/src/utils/providerValidation.ts b/src/utils/providerValidation.ts
index 831cfda83..925a133ba 100644
--- a/src/utils/providerValidation.ts
+++ b/src/utils/providerValidation.ts
@@ -413,7 +413,7 @@ export async function getProviderValidationError(
if (hasExplicitCodexIntent) {
const credentials = resolveCodexApiCredentials(env)
if (!credentials.apiKey) {
- const oauthHint = isBareMode() ? '' : ', choose Codex OAuth in /provider'
+ const oauthHint = isBareMode() ? '' : ', choose Codex OAuth or device code in /provider'
const authHint = credentials.authPath
? `${oauthHint} or put auth.json at ${credentials.authPath}`
: oauthHint
@@ -424,7 +424,7 @@ export async function getProviderValidationError(
return `Codex auth is required for ${safeModel}. Set CODEX_API_KEY${authHint}.`
}
if (!credentials.accountId) {
- return 'Codex auth is missing chatgpt_account_id. Re-login with Codex OAuth, Codex CLI, or set CHATGPT_ACCOUNT_ID/CODEX_ACCOUNT_ID.'
+ return 'Codex auth is missing chatgpt_account_id. Re-login with Codex OAuth, device code, Codex CLI, or set CHATGPT_ACCOUNT_ID/CODEX_ACCOUNT_ID.'
}
return null
}