diff --git a/README.md b/README.md index fb7e1f84..b5192abd 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ 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) [![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 - 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 - 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 | | 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 | -| 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 | | Atomic Chat | advanced setup | Local Apple Silicon backend | | Bedrock / Vertex / Foundry | env vars | Additional provider integrations for supported environments | diff --git a/docs/advanced-setup.md b/docs/advanced-setup.md index 92fac618..75c401ee 100644 --- a/docs/advanced-setup.md +++ b/docs/advanced-setup.md @@ -48,6 +48,8 @@ export OPENAI_MODEL=gpt-4o `codexplan` maps to GPT-5.4 on the Codex backend with high reasoning. `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`. ```bash diff --git a/src/commands/provider/provider.test.tsx b/src/commands/provider/provider.test.tsx index 6597fada..9c9461d7 100644 --- a/src/commands/provider/provider.test.tsx +++ b/src/commands/provider/provider.test.tsx @@ -1,20 +1,28 @@ import { PassThrough } from 'node:stream' -import { expect, test } from 'bun:test' +import { afterEach, expect, mock, test } from 'bun:test' import React from 'react' import stripAnsi from 'strip-ansi' import { createRoot, render, useApp } from '../../ink.js' import { AppStateProvider } from '../../state/AppState.js' import { + applySavedProfileToCurrentSession, + buildCodexOAuthProfileEnv, buildCurrentProviderSummary, buildProfileSaveMessage, getProviderWizardDefaults, + ProviderWizard, TextEntryDialog, } from './provider.js' +import { createProfileFile } from '../../utils/providerProfile.js' const SYNC_START = '\x1B[?2026h' const SYNC_END = '\x1B[?2026l' +const ORIGINAL_SIMPLE_ENV = process.env.CLAUDE_CODE_SIMPLE +const ORIGINAL_CODEX_API_KEY = process.env.CODEX_API_KEY +const ORIGINAL_CHATGPT_ACCOUNT_ID = process.env.CHATGPT_ACCOUNT_ID +const ORIGINAL_CODEX_ACCOUNT_ID = process.env.CODEX_ACCOUNT_ID function extractLastFrame(output: string): string { let lastFrame: string | null = null @@ -60,6 +68,51 @@ async function renderFinalFrame(node: React.ReactNode): Promise { return stripAnsi(extractLastFrame(getOutput())) } +async function waitForOutput( + getOutput: () => string, + predicate: (output: string) => boolean, + timeoutMs = 2500, +): Promise { + 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 { + 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( + + {}} /> + , + ) + + try { + return await waitForOutput( + getOutput, + output => output.includes('Set up a provider profile'), + ) + } finally { + root.unmount() + stdin.end() + stdout.end() + await Bun.sleep(0) + } +} + function createTestStreams(): { stdout: PassThrough stdin: PassThrough & { @@ -94,6 +147,34 @@ function createTestStreams(): { } } +afterEach(() => { + mock.restore() + + if (ORIGINAL_SIMPLE_ENV === undefined) { + delete process.env.CLAUDE_CODE_SIMPLE + } else { + process.env.CLAUDE_CODE_SIMPLE = ORIGINAL_SIMPLE_ENV + } + + if (ORIGINAL_CODEX_API_KEY === undefined) { + delete process.env.CODEX_API_KEY + } else { + process.env.CODEX_API_KEY = ORIGINAL_CODEX_API_KEY + } + + if (ORIGINAL_CHATGPT_ACCOUNT_ID === undefined) { + delete process.env.CHATGPT_ACCOUNT_ID + } else { + process.env.CHATGPT_ACCOUNT_ID = ORIGINAL_CHATGPT_ACCOUNT_ID + } + + if (ORIGINAL_CODEX_ACCOUNT_ID === undefined) { + delete process.env.CODEX_ACCOUNT_ID + } else { + process.env.CODEX_ACCOUNT_ID = ORIGINAL_CODEX_ACCOUNT_ID + } +}) + function StepChangeHarness(): React.ReactNode { const { exit } = useApp() const [step, setStep] = React.useState<'api' | 'model'>('api') @@ -233,6 +314,167 @@ test('buildProfileSaveMessage describes Gemini access token / ADC mode clearly', expect(message).not.toContain('AIza') }) +test('buildProfileSaveMessage reflects immediate Codex activation for existing credentials', () => { + const message = buildProfileSaveMessage( + 'codex', + { + OPENAI_MODEL: 'codexplan', + OPENAI_BASE_URL: 'https://chatgpt.com/backend-api/codex', + CHATGPT_ACCOUNT_ID: 'acct_codex', + }, + 'D:/codings/Opensource/openclaude/.openclaude-profile.json', + { + activatedInSession: true, + }, + ) + + expect(message).toContain('Saved Codex profile.') + expect(message).toContain('OpenClaude switched to it for this session.') + expect(message).not.toContain('Restart OpenClaude to use it.') +}) + +test('buildProfileSaveMessage reflects immediate Codex OAuth activation when the session switched successfully', () => { + const message = buildProfileSaveMessage( + 'codex', + { + OPENAI_MODEL: 'codexplan', + OPENAI_BASE_URL: 'https://chatgpt.com/backend-api/codex', + CHATGPT_ACCOUNT_ID: 'acct_codex', + CODEX_CREDENTIAL_SOURCE: 'oauth', + }, + 'D:/codings/Opensource/openclaude/.openclaude-profile.json', + { + activatedInSession: true, + }, + ) + + expect(message).toContain('Saved Codex profile.') + expect(message).toContain('OpenClaude switched to it for this session.') + expect(message).not.toContain('Restart OpenClaude to use it.') +}) + +test('buildCodexOAuthProfileEnv uses the fresh OAuth account id without persisting an API key', () => { + process.env.CODEX_API_KEY = 'stale-codex-key' + process.env.CHATGPT_ACCOUNT_ID = 'acct_stale' + + const env = buildCodexOAuthProfileEnv({ + accessToken: 'oauth-access-token', + accountId: 'acct_oauth', + }) + + expect(env).toEqual({ + OPENAI_BASE_URL: 'https://chatgpt.com/backend-api/codex', + OPENAI_MODEL: 'codexplan', + CHATGPT_ACCOUNT_ID: 'acct_oauth', + CODEX_CREDENTIAL_SOURCE: 'oauth', + }) + expect(env).not.toHaveProperty('CODEX_API_KEY') +}) + +test('buildCodexProfileEnv derives oauth source from secure storage when no explicit source is provided', async () => { + const actualProviderConfig = await import('../../services/api/providerConfig.js') + + mock.module('../../services/api/providerConfig.js', () => ({ + ...actualProviderConfig, + resolveCodexApiCredentials: () => ({ + apiKey: 'stored-access-token', + accountId: 'acct_secure_storage', + source: 'secure-storage' as const, + }), + })) + + // @ts-expect-error cache-busting query string for Bun module mocks + const { buildCodexProfileEnv } = await import( + '../../utils/providerProfile.js?secure-storage-codex-source' + ) + + const env = buildCodexProfileEnv({ + model: 'codexplan', + processEnv: {}, + }) + + expect(env).toEqual({ + OPENAI_BASE_URL: 'https://chatgpt.com/backend-api/codex', + OPENAI_MODEL: 'codexplan', + CHATGPT_ACCOUNT_ID: 'acct_secure_storage', + CODEX_CREDENTIAL_SOURCE: 'oauth', + }) +}) + +test('applySavedProfileToCurrentSession switches the current env to the saved Codex profile', async () => { + // @ts-expect-error cache-busting query string for Bun module mocks + const { applySavedProfileToCurrentSession } = await import( + '../../utils/providerProfile.js?apply-saved-profile-codex' + ) + const processEnv: NodeJS.ProcessEnv = { + CLAUDE_CODE_USE_OPENAI: '1', + OPENAI_MODEL: 'gpt-4o', + OPENAI_BASE_URL: 'https://api.openai.com/v1', + OPENAI_API_KEY: 'sk-openai', + CODEX_API_KEY: 'codex-live', + CHATGPT_ACCOUNT_ID: 'acct_codex', + CLAUDE_CODE_PROVIDER_PROFILE_ENV_APPLIED: '1', + CLAUDE_CODE_PROVIDER_PROFILE_ENV_APPLIED_ID: 'provider_old', + } + const profileFile = createProfileFile('codex', { + OPENAI_MODEL: 'codexplan', + OPENAI_BASE_URL: 'https://chatgpt.com/backend-api/codex', + CODEX_API_KEY: 'codex-live', + CHATGPT_ACCOUNT_ID: 'acct_codex', + }) + + const warning = await applySavedProfileToCurrentSession({ + profileFile, + processEnv, + }) + + expect(warning).toBeNull() + expect(processEnv.CLAUDE_CODE_USE_OPENAI).toBe('1') + expect(processEnv.OPENAI_MODEL).toBe('codexplan') + expect(processEnv.OPENAI_BASE_URL).toBe( + 'https://chatgpt.com/backend-api/codex', + ) + expect(processEnv.CODEX_API_KEY).toBe('codex-live') + expect(processEnv.CHATGPT_ACCOUNT_ID).toBe('acct_codex') + expect(processEnv.OPENAI_API_KEY).toBeUndefined() + expect(processEnv.CLAUDE_CODE_PROVIDER_PROFILE_ENV_APPLIED).toBeUndefined() + expect(processEnv.CLAUDE_CODE_PROVIDER_PROFILE_ENV_APPLIED_ID).toBeUndefined() +}) + +test('applySavedProfileToCurrentSession ignores stale Codex env overrides for OAuth-backed profiles', async () => { + // @ts-expect-error cache-busting query string for Bun module mocks + const { applySavedProfileToCurrentSession } = await import( + '../../utils/providerProfile.js?apply-saved-profile-codex-oauth' + ) + const processEnv: NodeJS.ProcessEnv = { + CLAUDE_CODE_USE_OPENAI: '1', + OPENAI_MODEL: 'gpt-4o', + OPENAI_BASE_URL: 'https://api.openai.com/v1', + CODEX_API_KEY: 'stale-codex-key', + CHATGPT_ACCOUNT_ID: 'acct_stale', + } + const profileFile = createProfileFile('codex', { + OPENAI_MODEL: 'codexplan', + OPENAI_BASE_URL: 'https://chatgpt.com/backend-api/codex', + CHATGPT_ACCOUNT_ID: 'acct_oauth', + CODEX_CREDENTIAL_SOURCE: 'oauth', + }) + + const warning = await applySavedProfileToCurrentSession({ + profileFile, + processEnv, + }) + + expect(warning).toBeNull() + expect(processEnv.OPENAI_MODEL).toBe('codexplan') + expect(processEnv.OPENAI_BASE_URL).toBe( + 'https://chatgpt.com/backend-api/codex', + ) + expect(processEnv.CODEX_API_KEY).toBeUndefined() + expect(processEnv.CHATGPT_ACCOUNT_ID).not.toBe('acct_stale') + expect(processEnv.CHATGPT_ACCOUNT_ID).toBeTruthy() +}) + test('buildCurrentProviderSummary redacts poisoned model and endpoint values', () => { const summary = buildCurrentProviderSummary({ processEnv: { @@ -307,3 +549,12 @@ test('getProviderWizardDefaults ignores poisoned current provider values', () => expect(defaults.openAIBaseUrl).toBe('https://api.openai.com/v1') expect(defaults.geminiModel).toBe('gemini-2.0-flash') }) + +test('ProviderWizard hides Codex OAuth while running in bare mode', async () => { + process.env.CLAUDE_CODE_SIMPLE = '1' + + const output = await renderProviderWizardFrame() + + expect(output).toContain('Set up a provider profile') + expect(output).not.toContain('Codex OAuth') +}) diff --git a/src/commands/provider/provider.tsx b/src/commands/provider/provider.tsx index 1a7491e9..0b026f7e 100644 --- a/src/commands/provider/provider.tsx +++ b/src/commands/provider/provider.tsx @@ -10,8 +10,12 @@ import { } from '../../components/CustomSelect/index.js' import { Dialog } from '../../components/design-system/Dialog.js' import { LoadingState } from '../../components/design-system/LoadingState.js' +import { useCodexOAuthFlow } from '../../components/useCodexOAuthFlow.js' import { useTerminalSize } from '../../hooks/useTerminalSize.js' import { Box, Text } from '../../ink.js' +import { + type CodexOAuthTokens, +} from '../../services/api/codexOAuth.js' import { DEFAULT_CODEX_BASE_URL, DEFAULT_OPENAI_BASE_URL, @@ -20,6 +24,8 @@ import { resolveProviderRequest, } from '../../services/api/providerConfig.js' import { + applySavedProfileToCurrentSession as applySharedProfileToCurrentSession, + buildCodexOAuthProfileEnv as buildSharedCodexOAuthProfileEnv, buildCodexProfileEnv, buildGeminiProfileEnv, buildMistralProfileEnv, @@ -49,6 +55,7 @@ import { readGeminiAccessToken, saveGeminiAccessToken, } from '../../utils/geminiCredentials.js' +import { isBareMode } from '../../utils/envUtils.js' import { getGoalDefaultOpenAIModel, normalizeRecommendationGoal, @@ -57,12 +64,13 @@ import { type RecommendationGoal, } from '../../utils/providerRecommendation.js' import { + getOllamaChatBaseUrl, getLocalOpenAICompatibleProviderLabel, hasLocalOllama, listOllamaModels, } from '../../utils/providerDiscovery.js' -type ProviderChoice = 'auto' | ProviderProfile | 'clear' +type ProviderChoice = 'auto' | ProviderProfile | 'codex-oauth' | 'clear' type Step = | { name: 'choose' } @@ -93,6 +101,7 @@ type Step = apiKey?: string authMode: 'api-key' | 'access-token' | 'adc' } + | { name: 'codex-oauth' } | { name: 'codex-check' } type CurrentProviderSummary = { @@ -131,6 +140,8 @@ type ProviderWizardDefaults = { mistralBaseUrl: string } +type SecretSourceEnv = NodeJS.ProcessEnv & Partial + function isEnvTruthy(value: string | undefined): boolean { if (!value) return false const normalized = value.trim().toLowerCase() @@ -139,7 +150,7 @@ function isEnvTruthy(value: string | undefined): boolean { function getSafeDisplayValue( value: string | undefined, - processEnv: NodeJS.ProcessEnv, + processEnv: SecretSourceEnv, profileEnv?: ProfileEnv, fallback = '(not set)', ): string { @@ -151,14 +162,15 @@ function getSafeDisplayValue( export function getProviderWizardDefaults( processEnv: NodeJS.ProcessEnv = process.env, ): ProviderWizardDefaults { + const secretSource = processEnv as SecretSourceEnv const safeOpenAIModel = - sanitizeProviderConfigValue(processEnv.OPENAI_MODEL, processEnv) || + sanitizeProviderConfigValue(processEnv.OPENAI_MODEL, secretSource) || 'gpt-4o' const safeOpenAIBaseUrl = - sanitizeProviderConfigValue(processEnv.OPENAI_BASE_URL, processEnv) || + sanitizeProviderConfigValue(processEnv.OPENAI_BASE_URL, secretSource) || DEFAULT_OPENAI_BASE_URL const safeGeminiModel = - sanitizeProviderConfigValue(processEnv.GEMINI_MODEL, processEnv) || + sanitizeProviderConfigValue(processEnv.GEMINI_MODEL, secretSource) || DEFAULT_GEMINI_MODEL const safeMistralModel = sanitizeProviderConfigValue(processEnv.MISTRAL_MODEL, processEnv) || @@ -181,6 +193,7 @@ export function buildCurrentProviderSummary(options?: { persisted?: ProfileFile | null }): CurrentProviderSummary { const processEnv = options?.processEnv ?? process.env + const secretSource = processEnv as SecretSourceEnv const persisted = options?.persisted ?? loadProfileFile() const savedProfileLabel = persisted?.profile ?? 'none' @@ -189,11 +202,11 @@ export function buildCurrentProviderSummary(options?: { providerLabel: 'Google Gemini', modelLabel: getSafeDisplayValue( processEnv.GEMINI_MODEL ?? DEFAULT_GEMINI_MODEL, - processEnv, + secretSource, ), endpointLabel: getSafeDisplayValue( processEnv.GEMINI_BASE_URL ?? DEFAULT_GEMINI_BASE_URL, - processEnv, + secretSource, ), savedProfileLabel, } @@ -219,13 +232,13 @@ export function buildCurrentProviderSummary(options?: { providerLabel: 'GitHub Models', modelLabel: getSafeDisplayValue( processEnv.OPENAI_MODEL ?? 'github:copilot', - processEnv, + secretSource, ), endpointLabel: getSafeDisplayValue( processEnv.OPENAI_BASE_URL ?? processEnv.OPENAI_API_BASE ?? 'https://models.github.ai/inference', - processEnv, + secretSource, ), savedProfileLabel, } @@ -246,8 +259,8 @@ export function buildCurrentProviderSummary(options?: { return { providerLabel, - modelLabel: getSafeDisplayValue(request.requestedModel, processEnv), - endpointLabel: getSafeDisplayValue(request.baseUrl, processEnv), + modelLabel: getSafeDisplayValue(request.requestedModel, secretSource), + endpointLabel: getSafeDisplayValue(request.baseUrl, secretSource), savedProfileLabel, } } @@ -258,11 +271,11 @@ export function buildCurrentProviderSummary(options?: { processEnv.ANTHROPIC_MODEL ?? processEnv.CLAUDE_MODEL ?? 'claude-sonnet-4-6', - processEnv, + secretSource, ), endpointLabel: getSafeDisplayValue( processEnv.ANTHROPIC_BASE_URL ?? 'https://api.anthropic.com', - processEnv, + secretSource, ), savedProfileLabel, } @@ -376,6 +389,10 @@ export function buildProfileSaveMessage( profile: ProviderProfile, env: ProfileEnv, filePath: string, + options?: { + activatedInSession?: boolean + activationWarning?: string | null + }, ): string { const summary = buildSavedProfileSummary(profile, env) const lines = [ @@ -389,13 +406,24 @@ export function buildProfileSaveMessage( } lines.push(`Profile: ${filePath}`) - lines.push('Restart OpenClaude to use it.') + if (options?.activatedInSession) { + lines.push('OpenClaude switched to it for this session.') + } else if (options?.activationWarning) { + lines.push( + `Saved for next startup. Warning: could not activate it in this session (${options.activationWarning}).`, + ) + } else { + lines.push('Restart OpenClaude to use it.') + } return lines.join('\n') } function buildUsageText(): string { const summary = buildCurrentProviderSummary() + const availableProviders = isBareMode() + ? 'Choose Auto, Ollama, OpenAI-compatible, Gemini, or Codex, then save a provider profile.' + : 'Choose Auto, Ollama, OpenAI-compatible, Gemini, Codex, or Codex OAuth, then save a provider profile.' return [ 'Usage: /provider', '', @@ -406,7 +434,7 @@ function buildUsageText(): string { `Current endpoint: ${summary.endpointLabel}`, `Saved profile: ${summary.savedProfileLabel}`, '', - 'Choose Auto, Ollama, OpenAI-compatible, Gemini, or Codex, then save a profile for the next OpenClaude restart.', + availableProviders, ].join('\n') } @@ -415,12 +443,45 @@ function finishProfileSave( profile: ProviderProfile, env: ProfileEnv, ): void { + void saveProfileAndNotify(onDone, profile, env) +} + +export function buildCodexOAuthProfileEnv( + tokens: Pick, +): ProfileEnv | null { + return buildSharedCodexOAuthProfileEnv(tokens) +} + +export async function applySavedProfileToCurrentSession(options: { + profileFile: ProfileFile + processEnv?: NodeJS.ProcessEnv +}): Promise { + return applySharedProfileToCurrentSession(options) +} + +async function saveProfileAndNotify( + onDone: LocalJSXCommandOnDone, + profile: ProviderProfile, + env: ProfileEnv, +): Promise { try { const profileFile = createProfileFile(profile, env) const filePath = saveProfileFile(profileFile) - onDone(buildProfileSaveMessage(profile, env, filePath), { - display: 'system', - }) + const shouldActivateInSession = profile === 'codex' + const activationWarning = shouldActivateInSession + ? await applySharedProfileToCurrentSession({ profileFile }) + : null + + onDone( + buildProfileSaveMessage(profile, env, filePath, { + activatedInSession: + shouldActivateInSession && activationWarning === null, + activationWarning, + }), + { + display: 'system', + }, + ) } catch (error) { const message = error instanceof Error ? error.message : String(error) onDone(`Failed to save provider profile: ${message}`, { @@ -504,6 +565,10 @@ function ProviderChooser({ onCancel: () => void }): React.ReactNode { const summary = buildCurrentProviderSummary() + const canUseCodexOAuth = !isBareMode() + const helperText = canUseCodexOAuth + ? 'Save a provider profile without editing environment variables first. Codex profiles backed by env, auth.json, or OpenClaude secure storage can switch this session immediately when validation succeeds.' + : 'Save a provider profile without editing environment variables first. Codex profiles backed by env or auth.json can switch this session immediately.' const options: OptionWithDescription[] = [ { label: 'Auto', @@ -537,6 +602,16 @@ function ProviderChooser({ value: 'codex', description: 'Use existing ChatGPT Codex CLI auth or env credentials', }, + ...(canUseCodexOAuth + ? [ + { + label: 'Codex OAuth', + value: 'codex-oauth' as const, + description: + 'Sign in with ChatGPT in your browser and store Codex tokens securely', + }, + ] + : []), ] if (summary.savedProfileLabel !== 'none') { @@ -554,10 +629,7 @@ function ProviderChooser({ onCancel={onCancel} > - - Save a provider profile for the next OpenClaude restart without - editing environment variables first. - + {helperText} Current model: {summary.modelLabel} Current endpoint: {summary.endpointLabel} @@ -709,7 +781,9 @@ function AutoRecommendationStep({ { label: 'Back', value: 'back' }, { label: 'Cancel', value: 'cancel' }, ]} - onChange={value => (value === 'back' ? onBack() : onCancel())} + onChange={(value: string) => + value === 'back' ? onBack() : onCancel() + } onCancel={onCancel} /> @@ -732,7 +806,7 @@ function AutoRecommendationStep({ { label: 'Back', value: 'back' }, { label: 'Cancel', value: 'cancel' }, ]} - onChange={value => { + onChange={(value: string) => { if (value === 'continue') { onNeedOpenAI(status.defaultModel) } else if (value === 'back') { @@ -765,7 +839,7 @@ function AutoRecommendationStep({ { label: 'Back', value: 'back' }, { label: 'Cancel', value: 'cancel' }, ]} - onChange={value => { + onChange={(value: string) => { if (value === 'save') { onSave( 'ollama', @@ -867,7 +941,9 @@ function OllamaModelStep({ { label: 'Back', value: 'back' }, { label: 'Cancel', value: 'cancel' }, ]} - onChange={value => (value === 'back' ? onBack() : onCancel())} + onChange={(value: string) => + value === 'back' ? onBack() : onCancel() + } onCancel={onCancel} /> @@ -888,7 +964,7 @@ function OllamaModelStep({ defaultFocusValue={status.defaultValue} inlineDescriptions visibleOptionCount={Math.min(8, status.options.length)} - onChange={value => { + onChange={(value: string) => { onSave( 'ollama', buildOllamaProfileEnv(value, { @@ -903,6 +979,84 @@ function OllamaModelStep({ ) } +function CodexOAuthStep({ + onSave, + onBack, + onCancel, +}: { + onSave: (profile: ProviderProfile, env: ProfileEnv) => void + onBack: () => void + onCancel: () => void +}): React.ReactNode { + const handleAuthenticated = React.useCallback(async ( + tokens: CodexOAuthTokens, + persistCredentials: (options?: { profileId?: string }) => void, + ) => { + const env = buildCodexOAuthProfileEnv(tokens) + if (!env) { + throw new Error( + 'Codex OAuth succeeded, but OpenClaude could not build a Codex profile from the stored credentials.', + ) + } + + persistCredentials() + onSave('codex', env) + }, [onSave]) + + const status = useCodexOAuthFlow({ + onAuthenticated: handleAuthenticated, + }) + + if (status.state === 'error') { + return ( + + + {status.message} + + + ) + } + + return ( + + + Codex OAuth + + + 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. + + {status.state === 'starting' ? ( + Starting local callback and preparing your browser... + ) : status.browserOpened === false ? ( + <> + + Browser did not open automatically. Visit this URL to continue: + + {status.authUrl} + + ) : status.browserOpened === true ? ( + <> + + Browser opened. Finish the ChatGPT sign-in there and this setup will + complete automatically. + + {status.authUrl} + + ) : ( + Opening your browser... + )} + Press Esc to cancel and go back. + + ) +} + export function ProviderManager({ mode, onDone }: Props): React.ReactNode { const initialGithubCredentialSource = getGithubCredentialSourceFromEnv() const initialIsGithubActive = isEnvTruthy(process.env.CLAUDE_CODE_USE_GITHUB) @@ -212,6 +338,7 @@ export function ProviderManager({ mode, onDone }: Props): React.ReactNode { const [isGithubCredentialSourceResolved, setIsGithubCredentialSourceResolved] = React.useState(() => initialHasGithubCredential || initialIsGithubActive) const githubRefreshEpochRef = React.useRef(0) + const codexRefreshEpochRef = React.useRef(0) const [screen, setScreen] = React.useState( mode === 'first-run' ? 'select-preset' : 'menu', ) @@ -226,6 +353,10 @@ export function ProviderManager({ mode, onDone }: Props): React.ReactNode { const [cursorOffset, setCursorOffset] = React.useState(0) const [statusMessage, setStatusMessage] = React.useState() const [errorMessage, setErrorMessage] = React.useState() + const [hasStoredCodexOAuthCredentials, setHasStoredCodexOAuthCredentials] = + React.useState(false) + const [storedCodexOAuthProfileId, setStoredCodexOAuthProfileId] = + React.useState() const [ollamaSelection, setOllamaSelection] = React.useState({ state: 'idle', }) @@ -263,19 +394,102 @@ export function ProviderManager({ mode, onDone }: Props): React.ReactNode { })() }, []) + const refreshCodexOAuthCredentialState = React.useCallback((): void => { + if (isBareMode()) { + codexRefreshEpochRef.current += 1 + setHasStoredCodexOAuthCredentials(false) + setStoredCodexOAuthProfileId(undefined) + return + } + + const refreshEpoch = ++codexRefreshEpochRef.current + void (async () => { + const credentials = await readCodexCredentialsAsync() + if (refreshEpoch !== codexRefreshEpochRef.current) { + return + } + + setHasStoredCodexOAuthCredentials( + Boolean( + credentials?.apiKey || + credentials?.accessToken || + credentials?.refreshToken || + credentials?.idToken, + ), + ) + setStoredCodexOAuthProfileId(credentials?.profileId) + })() + }, []) + React.useEffect(() => { refreshGithubProviderState() + refreshCodexOAuthCredentialState() return () => { githubRefreshEpochRef.current += 1 + codexRefreshEpochRef.current += 1 } - }, [refreshGithubProviderState]) + }, [refreshCodexOAuthCredentialState, refreshGithubProviderState]) + + React.useEffect(() => { + if (screen !== 'select-ollama-model') { + return + } + + let cancelled = false + setOllamaSelection({ state: 'loading' }) + + void (async () => { + const available = await hasLocalOllama(draft.baseUrl) + if (!available) { + if (!cancelled) { + setOllamaSelection({ + state: 'unavailable', + message: + 'Could not reach Ollama. Start Ollama first, or enter the endpoint manually.', + }) + } + return + } + + const models = await listOllamaModels(draft.baseUrl) + if (models.length === 0) { + if (!cancelled) { + setOllamaSelection({ + state: 'unavailable', + message: + 'Ollama is running, but no installed models were found. Pull a chat model such as qwen2.5-coder:7b or llama3.1:8b first, or enter details manually.', + }) + } + return + } + + const ranked = rankOllamaModels(models, 'balanced') + const recommended = recommendOllamaModel(models, 'balanced') + if (!cancelled) { + setOllamaSelection({ + state: 'ready', + defaultValue: recommended?.name ?? ranked[0]?.name, + options: ranked.map(model => ({ + label: model.name, + value: model.name, + description: model.summary, + })), + }) + } + })() + + return () => { + cancelled = true + } + }, [draft.baseUrl, screen]) function refreshProfiles(): void { const nextProfiles = getProviderProfiles() setProfiles(nextProfiles) setActiveProfileId(getActiveProviderProfile()?.id) refreshGithubProviderState() + refreshCodexOAuthCredentialState() } function clearStartupProviderOverrideFromUserSettings(): string | null { @@ -292,6 +506,123 @@ export function ProviderManager({ mode, onDone }: Props): React.ReactNode { return error ? error.message : null } + function buildCodexOAuthActivationMessage(options: { + prefix: string + activationWarning: string | null + warnings: string[] + }): string { + if (options.activationWarning) { + return `${options.prefix}. Saved for next startup. Warning: ${options.warnings.join('; ')}.` + } + + if (options.warnings.length > 0) { + return `${options.prefix}. OpenClaude switched to it for this session with warnings: ${options.warnings.join('; ')}.` + } + + return `${options.prefix}. OpenClaude switched to it for this session.` + } + + async function activateCodexOAuthSession(tokens?: { + accessToken: string + refreshToken?: string + accountId?: string + idToken?: string + }): Promise { + 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 { + let providerLabel = 'provider' + + try { + if (profileId === GITHUB_PROVIDER_ID) { + providerLabel = GITHUB_PROVIDER_LABEL + const githubError = activateGithubProvider() + if (githubError) { + setErrorMessage(`Could not activate GitHub provider: ${githubError}`) + setScreen('menu') + return + } + + refreshProfiles() + setStatusMessage(`Active provider: ${GITHUB_PROVIDER_LABEL}`) + setScreen('menu') + return + } + + const active = setActiveProviderProfile(profileId) + if (!active) { + setErrorMessage('Could not change active provider.') + setScreen('menu') + return + } + + providerLabel = active.name + const settingsOverrideError = + clearStartupProviderOverrideFromUserSettings() + const isActiveCodexOAuth = isCodexOAuthProfile( + active, + storedCodexOAuthProfileId, + ) + const activationWarning = isActiveCodexOAuth + ? await activateCodexOAuthSession() + : null + + refreshProfiles() + setStatusMessage( + isActiveCodexOAuth + ? buildCodexOAuthActivationMessage({ + prefix: `Active provider: ${active.name}`, + activationWarning, + warnings: [ + activationWarning, + settingsOverrideError + ? `could not clear startup provider override (${settingsOverrideError})` + : null, + ].filter((warning): warning is string => Boolean(warning)), + }) + : settingsOverrideError + ? `Active provider: ${active.name}. Warning: could not clear startup provider override (${settingsOverrideError}).` + : `Active provider: ${active.name}`, + ) + setScreen('menu') + } catch (error) { + refreshProfiles() + setStatusMessage(undefined) + const detail = error instanceof Error ? error.message : String(error) + setErrorMessage(`Could not finish activating ${providerLabel}: ${detail}`) + setScreen('menu') + } + } + function closeWithCancelled(message: string): void { onDone({ action: 'cancelled', message }) } @@ -383,59 +714,6 @@ export function ProviderManager({ mode, onDone }: Props): React.ReactNode { return null } - React.useEffect(() => { - if (screen !== 'select-ollama-model') { - return - } - - let cancelled = false - setOllamaSelection({ state: 'loading' }) - - void (async () => { - const available = await hasLocalOllama(draft.baseUrl) - if (!available) { - if (!cancelled) { - setOllamaSelection({ - state: 'unavailable', - message: - 'Could not reach Ollama. Start Ollama first, or enter the endpoint manually.', - }) - } - return - } - - const models = await listOllamaModels(draft.baseUrl) - if (models.length === 0) { - if (!cancelled) { - setOllamaSelection({ - state: 'unavailable', - message: - 'Ollama is running, but no installed models were found. Pull a chat model such as qwen2.5-coder:7b or llama3.1:8b first, or enter details manually.', - }) - } - return - } - - const ranked = rankOllamaModels(models, 'balanced') - const recommended = recommendOllamaModel(models, 'balanced') - if (!cancelled) { - setOllamaSelection({ - state: 'ready', - defaultValue: recommended?.name ?? ranked[0]?.name, - options: ranked.map(model => ({ - label: model.name, - value: model.name, - description: model.summary, - })), - }) - } - })() - - return () => { - cancelled = true - } - }, [draft.baseUrl, screen]) - function startCreateFromPreset(preset: ProviderPreset): void { const defaults = getProviderPresetDefaults(preset) const nextDraft = { @@ -557,7 +835,7 @@ export function ProviderManager({ mode, onDone }: Props): React.ReactNode { description: 'Choose another provider preset', }, ]} - onChange={value => { + onChange={(value: string) => { if (value === 'manual') { setFormStepIndex(0) setCursorOffset(draft.name.length) @@ -588,7 +866,7 @@ export function ProviderManager({ mode, onDone }: Props): React.ReactNode { defaultFocusValue={ollamaSelection.defaultValue} inlineDescriptions visibleOptionCount={Math.min(8, ollamaSelection.options.length)} - onChange={value => { + onChange={(value: string) => { const nextDraft = { ...draft, model: value, @@ -654,6 +932,7 @@ export function ProviderManager({ mode, onDone }: Props): React.ReactNode { }) function renderPresetSelection(): React.ReactNode { + const canUseCodexOAuth = !isBareMode() const options = [ { value: 'anthropic', @@ -670,6 +949,16 @@ export function ProviderManager({ mode, onDone }: Props): React.ReactNode { label: 'OpenAI', description: 'OpenAI API with API key', }, + ...(canUseCodexOAuth + ? [ + { + value: 'codex-oauth', + label: 'Codex OAuth', + description: + 'Sign in with ChatGPT in your browser and store Codex credentials securely', + }, + ] + : []), { value: 'moonshotai', label: 'Moonshot AI', @@ -741,11 +1030,15 @@ export function ProviderManager({ mode, onDone }: Props): React.ReactNode { { + onChange={(value: string) => { setErrorMessage(undefined) switch (value) { case 'add': @@ -897,6 +1199,47 @@ export function ProviderManager({ mode, onDone }: Props): React.ReactNode { setScreen('select-delete') } break + case 'logout-codex-oauth': { + const cleared = clearCodexCredentials() + if (!cleared.success) { + setErrorMessage( + cleared.warning ?? + 'Could not clear Codex OAuth credentials.', + ) + break + } + + setHasStoredCodexOAuthCredentials(false) + setStoredCodexOAuthProfileId(undefined) + const codexProfile = findCodexOAuthProfile( + getProviderProfiles(), + storedCodexOAuthProfileId, + ) + let settingsOverrideError: string | null = null + if (codexProfile) { + const result = deleteProviderProfile(codexProfile.id) + if (!result.removed) { + setErrorMessage( + 'Codex OAuth credentials were cleared, but the Codex profile could not be removed.', + ) + refreshProfiles() + break + } + + clearPersistedCodexOAuthProfile() + settingsOverrideError = result.activeProfileId + ? clearStartupProviderOverrideFromUserSettings() + : null + } + + refreshProfiles() + setStatusMessage( + settingsOverrideError + ? `Codex OAuth logged out. Warning: could not clear startup provider override (${settingsOverrideError}).` + : 'Codex OAuth logged out.', + ) + break + } default: closeWithCancelled('Provider manager closed') break @@ -975,51 +1318,100 @@ export function ProviderManager({ mode, onDone }: Props): React.ReactNode { let content: React.ReactNode - switch (screen) { - case 'select-preset': - content = renderPresetSelection() - break - case 'select-ollama-model': - content = renderOllamaSelection() - break - case 'form': - content = renderForm() - break + switch (screen) { + case 'select-preset': + content = renderPresetSelection() + break + case 'select-ollama-model': + content = renderOllamaSelection() + break + case 'codex-oauth': + content = ( + setScreen('select-preset')} + onConfigured={async (tokens, persistCredentials) => { + const payload: ProviderProfileInput = { + provider: 'openai', + name: CODEX_OAUTH_PROVIDER_NAME, + baseUrl: DEFAULT_CODEX_BASE_URL, + model: CODEX_OAUTH_PROVIDER_MODEL, + apiKey: '', + } + + const existing = findCodexOAuthProfile( + getProviderProfiles(), + storedCodexOAuthProfileId, + ) + const saved = existing + ? updateProviderProfile(existing.id, payload) + : addProviderProfile(payload, { makeActive: true }) + + if (!saved) { + setErrorMessage( + 'Codex OAuth login finished, but the provider profile could not be saved.', + ) + setScreen('menu') + return + } + + const active = + existing && activeProfileId !== saved.id + ? setActiveProviderProfile(saved.id) + : saved + if (!active) { + setErrorMessage( + 'Codex OAuth login finished, but the provider could not be set as the startup provider.', + ) + setScreen('menu') + return + } + + persistCredentials({ profileId: saved.id }) + const settingsOverrideError = + clearStartupProviderOverrideFromUserSettings() + const activationWarning = await activateCodexOAuthSession(tokens) + setHasStoredCodexOAuthCredentials(true) + setStoredCodexOAuthProfileId(saved.id) + refreshProfiles() + const warnings = [ + activationWarning, + settingsOverrideError + ? `could not clear startup provider override (${settingsOverrideError})` + : null, + ].filter((warning): warning is string => Boolean(warning)) + const message = buildCodexOAuthActivationMessage({ + prefix: 'Codex OAuth configured', + activationWarning, + warnings, + }) + + if (mode === 'first-run') { + onDone({ + action: 'saved', + activeProfileId: active.id, + message, + }) + return + } + + setStatusMessage(message) + setErrorMessage(undefined) + setScreen('menu') + }} + /> + ) + break + case 'form': + content = renderForm() + break case 'select-active': content = renderProfileSelection( 'Set active provider', 'No providers available. Add one first.', profileId => { - if (profileId === GITHUB_PROVIDER_ID) { - const githubError = activateGithubProvider() - if (githubError) { - setErrorMessage(`Could not activate GitHub provider: ${githubError}`) - setScreen('menu') - return - } - refreshProfiles() - setStatusMessage(`Active provider: ${GITHUB_PROVIDER_LABEL}`) - setScreen('menu') - return - } - - const active = setActiveProviderProfile(profileId) - if (!active) { - setErrorMessage('Could not change active provider.') - setScreen('menu') - return - } - const settingsOverrideError = - clearStartupProviderOverrideFromUserSettings() - refreshProfiles() - setStatusMessage( - settingsOverrideError - ? `Active provider: ${active.name}. Warning: could not clear startup provider override (${settingsOverrideError}).` - : `Active provider: ${active.name}`, - ) - setScreen('menu') + void activateSelectedProvider(profileId) }, - { includeGithub: true }, + { includeGithub: true }, ) break case 'select-edit': @@ -1048,10 +1440,27 @@ export function ProviderManager({ mode, onDone }: Props): React.ReactNode { return } + const deletedCodexOAuthProfile = + findCodexOAuthProfile( + profiles, + storedCodexOAuthProfileId, + )?.id === profileId const result = deleteProviderProfile(profileId) if (!result.removed) { setErrorMessage('Could not delete provider.') } else { + if (deletedCodexOAuthProfile) { + const cleared = clearCodexCredentials() + if (!cleared.success) { + setErrorMessage( + cleared.warning ?? + 'Provider deleted, but Codex OAuth credentials could not be cleared.', + ) + } else { + setStoredCodexOAuthProfileId(undefined) + } + clearPersistedCodexOAuthProfile() + } const settingsOverrideError = result.activeProfileId ? clearStartupProviderOverrideFromUserSettings() : null diff --git a/src/components/useCodexOAuthFlow.test.tsx b/src/components/useCodexOAuthFlow.test.tsx new file mode 100644 index 00000000..5d39ce0f --- /dev/null +++ b/src/components/useCodexOAuthFlow.test.tsx @@ -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 { + 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, + ) { + 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 {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 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, + ) { + 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 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_oauth', + }) + } finally { + root.unmount() + streams.stdin.end() + streams.stdout.end() + await Bun.sleep(0) + } +}) diff --git a/src/components/useCodexOAuthFlow.ts b/src/components/useCodexOAuthFlow.ts new file mode 100644 index 00000000..f0de299a --- /dev/null +++ b/src/components/useCodexOAuthFlow.ts @@ -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 + 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({ + 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 +} diff --git a/src/hooks/useApiKeyVerification.test.tsx b/src/hooks/useApiKeyVerification.test.tsx new file mode 100644 index 00000000..438cfd17 --- /dev/null +++ b/src/hooks/useApiKeyVerification.test.tsx @@ -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 { + 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 {status} + } + + 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() + + await waitForCondition(() => seenStatuses.includes('missing')) + + authState.anthropicAuthEnabled = false + root.render() + + await waitForCondition(() => seenStatuses.includes('valid')) + + root.unmount() + stdin.end() + stdout.end() + await Bun.sleep(0) + + expect(seenStatuses[0]).toBe('missing') + expect(seenStatuses).toContain('valid') +}) diff --git a/src/hooks/useApiKeyVerification.ts b/src/hooks/useApiKeyVerification.ts index d6433c57..87a5863d 100644 --- a/src/hooks/useApiKeyVerification.ts +++ b/src/hooks/useApiKeyVerification.ts @@ -1,4 +1,4 @@ -import { useCallback, useState } from 'react' +import { useCallback, useEffect, useState } from 'react' import { getIsNonInteractiveSession } from '../bootstrap/state.js' import { verifyApiKey } from '../services/api/claude.js' import { @@ -21,24 +21,43 @@ export type ApiKeyVerificationResult = { error: Error | null } -export function useApiKeyVerification(): ApiKeyVerificationResult { - const [status, setStatus] = useState(() => { - if (!isAnthropicAuthEnabled() || isClaudeAISubscriber()) { - return 'valid' - } - // Use skipRetrievingKeyFromApiKeyHelper to avoid executing apiKeyHelper - // before trust dialog is shown (security: prevents RCE via settings.json) - const { key, source } = getAnthropicApiKeyWithSource({ - skipRetrievingKeyFromApiKeyHelper: true, - }) - // If apiKeyHelper is configured, we have a key source even though we - // haven't executed it yet - return 'loading' to indicate we'll verify later - if (key || source === 'apiKeyHelper') { - return 'loading' - } - return 'missing' +function getInitialVerificationStatus(): VerificationStatus { + if (!isAnthropicAuthEnabled() || isClaudeAISubscriber()) { + return 'valid' + } + // Use skipRetrievingKeyFromApiKeyHelper to avoid executing apiKeyHelper + // before trust dialog is shown (security: prevents RCE via settings.json) + const { key, source } = getAnthropicApiKeyWithSource({ + skipRetrievingKeyFromApiKeyHelper: true, }) + // If apiKeyHelper is configured, we have a key source even though we + // haven't executed it yet - return 'loading' to indicate we'll verify later + if (key || source === 'apiKeyHelper') { + return 'loading' + } + return 'missing' +} + +export function useApiKeyVerification(): ApiKeyVerificationResult { + const [status, setStatus] = useState( + getInitialVerificationStatus, + ) const [error, setError] = useState(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 => { if (!isAnthropicAuthEnabled() || isClaudeAISubscriber()) { diff --git a/src/services/api/codexOAuth.test.ts b/src/services/api/codexOAuth.test.ts new file mode 100644 index 00000000..e98b5792 --- /dev/null +++ b/src/services/api/codexOAuth.test.ts @@ -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 { + 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 + + 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(resolve => { + resolveFetchStart = resolve + }) + + globalThis.fetch = mock((input, init) => { + const url = String(input) + if (url.startsWith('http://localhost:')) { + return originalFetch(input, init) + } + + return new Promise((_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 + + 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') +}) diff --git a/src/services/api/codexOAuth.ts b/src/services/api/codexOAuth.ts new file mode 100644 index 00000000..5604972d --- /dev/null +++ b/src/services/api/codexOAuth.ts @@ -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 ` + + + + Codex Login Complete + + + +

Codex login complete

+

You can return to OpenClaude now.

+

OpenClaude will finish activating your new Codex OAuth login.

+ +` +} + +function renderErrorPage(message: string): string { + const safeMessage = escapeHtml(message) + return ` + + + + Codex Login Failed + + + +

Codex login failed

+

${safeMessage}

+

You can close this window and try again in OpenClaude.

+ +` +} + +function renderCancelledPage(): string { + return ` + + + + Codex Login Cancelled + + + +

Codex login cancelled

+

You can close this window and retry in OpenClaude.

+ +` +} + +async function exchangeAuthorizationCode(options: { + authorizationCode: string + codeVerifier: string + port: number + signal?: AbortSignal +}): Promise { + 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, + ): Promise { + 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 + } +} diff --git a/src/services/api/codexOAuthShared.ts b/src/services/api/codexOAuthShared.ts new file mode 100644 index 00000000..f686c540 --- /dev/null +++ b/src/services/api/codexOAuthShared.ts @@ -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 | 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) + : 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) + : undefined + + return ( + asTrimmedString( + nestedAuth?.chatgpt_account_id ?? + payload?.['https://api.openai.com/auth.chatgpt_account_id'] ?? + payload?.chatgpt_account_id, + ) ?? undefined + ) +} + +export function escapeHtml(value: string): string { + return value.replace(/[&<>"']/g, char => { + switch (char) { + case '&': + return '&' + case '<': + return '<' + case '>': + return '>' + case '"': + return '"' + case '\'': + return ''' + default: + return char + } + }) +} + +export async function exchangeCodexIdTokenForApiKey( + idToken: string, +): Promise { + 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 +} diff --git a/src/services/api/codexShim.test.ts b/src/services/api/codexShim.test.ts index a135425b..d2e39aae 100644 --- a/src/services/api/codexShim.test.ts +++ b/src/services/api/codexShim.test.ts @@ -8,10 +8,6 @@ import { convertCodexResponseToAnthropicMessage, convertToolsToResponsesTools, } from './codexShim.js' -import { - resolveCodexApiCredentials, - resolveProviderRequest, -} from './providerConfig.js' const tempDirs: string[] = [] const originalEnv = { @@ -63,6 +59,10 @@ async function collectStreamEventTypes(responseText: string): Promise return events } +async function importFreshProviderConfigModule() { + return import(`./providerConfig.js?ts=${Date.now()}-${Math.random()}`) +} + describe('Codex provider config', () => { const originalOpenaiBaseUrl = process.env.OPENAI_BASE_URL const originalOpenaiApiBase = process.env.OPENAI_API_BASE @@ -79,7 +79,8 @@ describe('Codex provider config', () => { else process.env.OPENAI_API_BASE = originalOpenaiApiBase }) - test('resolves codexplan alias to Codex transport with reasoning', () => { + test('resolves codexplan alias to Codex transport with reasoning', async () => { + const { resolveProviderRequest } = await importFreshProviderConfigModule() delete process.env.OPENAI_BASE_URL delete process.env.OPENAI_API_BASE delete process.env.CLAUDE_CODE_USE_GITHUB @@ -91,7 +92,8 @@ describe('Codex provider config', () => { expect(resolved.baseUrl).toBe('https://chatgpt.com/backend-api/codex') }) - test('resolves codexspark alias to Codex transport with Codex base URL', () => { + test('resolves codexspark alias to Codex transport with Codex base URL', async () => { + const { resolveProviderRequest } = await importFreshProviderConfigModule() delete process.env.OPENAI_BASE_URL delete process.env.OPENAI_API_BASE delete process.env.CLAUDE_CODE_USE_GITHUB @@ -102,7 +104,8 @@ describe('Codex provider config', () => { expect(resolved.baseUrl).toBe('https://chatgpt.com/backend-api/codex') }) - test('does not force Codex transport when a local non-Codex base URL is explicit', () => { + test('does not force Codex transport when a local non-Codex base URL is explicit', async () => { + const { resolveProviderRequest } = await importFreshProviderConfigModule() const resolved = resolveProviderRequest({ model: 'codexplan', baseUrl: 'http://127.0.0.1:8080/v1', @@ -113,7 +116,8 @@ describe('Codex provider config', () => { expect(resolved.resolvedModel).toBe('gpt-5.4') }) - test('resolves codexplan to Codex transport even when OPENAI_BASE_URL is the string "undefined"', () => { + test('resolves codexplan to Codex transport even when OPENAI_BASE_URL is the string "undefined"', async () => { + const { resolveProviderRequest } = await importFreshProviderConfigModule() // On Windows, env vars can leak as the literal string "undefined" instead of // the JS value undefined when not properly unset (issue #336). process.env.OPENAI_BASE_URL = 'undefined' @@ -121,20 +125,23 @@ describe('Codex provider config', () => { expect(resolved.transport).toBe('codex_responses') }) - test('resolves codexplan to Codex transport even when OPENAI_BASE_URL is an empty string', () => { + test('resolves codexplan to Codex transport even when OPENAI_BASE_URL is an empty string', async () => { + const { resolveProviderRequest } = await importFreshProviderConfigModule() process.env.OPENAI_BASE_URL = '' const resolved = resolveProviderRequest({ model: 'codexplan' }) expect(resolved.transport).toBe('codex_responses') }) - test('prefers explicit baseUrl option over env var', () => { + test('prefers explicit baseUrl option over env var', async () => { + const { resolveProviderRequest } = await importFreshProviderConfigModule() process.env.OPENAI_BASE_URL = 'https://example.com/v1' const resolved = resolveProviderRequest({ model: 'codexplan', baseUrl: 'https://chatgpt.com/backend-api/codex' }) expect(resolved.transport).toBe('codex_responses') expect(resolved.baseUrl).toBe('https://chatgpt.com/backend-api/codex') }) - test('default gpt-4o uses OpenAI base URL (no regression)', () => { + test('default gpt-4o uses OpenAI base URL (no regression)', async () => { + const { resolveProviderRequest } = await importFreshProviderConfigModule() delete process.env.OPENAI_BASE_URL delete process.env.CLAUDE_CODE_USE_GITHUB @@ -144,7 +151,8 @@ describe('Codex provider config', () => { expect(resolved.resolvedModel).toBe('gpt-4o') }) - test('resolves codexplan from env var OPENAI_MODEL to Codex endpoint', () => { + test('resolves codexplan from env var OPENAI_MODEL to Codex endpoint', async () => { + const { resolveProviderRequest } = await importFreshProviderConfigModule() process.env.OPENAI_MODEL = 'codexplan' delete process.env.OPENAI_BASE_URL delete process.env.CLAUDE_CODE_USE_GITHUB @@ -155,7 +163,8 @@ describe('Codex provider config', () => { expect(resolved.resolvedModel).toBe('gpt-5.4') }) - test('does not override custom base URL for codexplan (e.g., local provider)', () => { + test('does not override custom base URL for codexplan (e.g., local provider)', async () => { + const { resolveProviderRequest } = await importFreshProviderConfigModule() process.env.OPENAI_MODEL = 'codexplan' process.env.OPENAI_BASE_URL = 'http://localhost:11434/v1' delete process.env.CLAUDE_CODE_USE_GITHUB @@ -165,7 +174,8 @@ describe('Codex provider config', () => { expect(resolved.baseUrl).toBe('http://localhost:11434/v1') }) - test('loads Codex credentials from auth.json fallback', () => { + test('loads Codex credentials from auth.json fallback', async () => { + const { resolveCodexApiCredentials } = await importFreshProviderConfigModule() const authPath = createTempAuthJson({ tokens: { access_token: 'header.payload.signature', @@ -181,6 +191,31 @@ describe('Codex provider config', () => { expect(credentials.accountId).toBe('acct_test') expect(credentials.source).toBe('auth.json') }) + + test('does not treat auth.json id_token as a Codex bearer credential', async () => { + const { resolveCodexApiCredentials } = await importFreshProviderConfigModule() + const idTokenPayload = Buffer.from( + JSON.stringify({ + 'https://api.openai.com/auth': { + chatgpt_account_id: 'acct_from_id_token', + }, + }), + 'utf8', + ).toString('base64url') + const authPath = createTempAuthJson({ + tokens: { + id_token: `header.${idTokenPayload}.signature`, + }, + }) + + const credentials = resolveCodexApiCredentials({ + CODEX_AUTH_JSON_PATH: authPath, + } as NodeJS.ProcessEnv) + + expect(credentials.apiKey).toBe('') + expect(credentials.accountId).toBe('acct_from_id_token') + expect(credentials.source).toBe('none') + }) }) describe('Codex request translation', () => { diff --git a/src/services/api/codexUsage.ts b/src/services/api/codexUsage.ts index 06a43290..76490dd3 100644 --- a/src/services/api/codexUsage.ts +++ b/src/services/api/codexUsage.ts @@ -1,7 +1,13 @@ +import { + readCodexCredentialsAsync, + refreshCodexAccessTokenIfNeeded, +} from '../../utils/codexCredentials.js' +import { logForDebugging } from '../../utils/debug.js' +import { isBareMode } from '../../utils/envUtils.js' import { DEFAULT_CODEX_BASE_URL, isCodexBaseUrl, - resolveCodexApiCredentials, + resolveRuntimeCodexCredentials, resolveProviderRequest, } from './providerConfig.js' @@ -391,6 +397,18 @@ export function getCodexUsageUrl(baseUrl = DEFAULT_CODEX_BASE_URL): string { } export async function fetchCodexUsage(): Promise { + const refreshResult = await refreshCodexAccessTokenIfNeeded().catch( + async error => { + logForDebugging( + `[codex] access token refresh failed before usage fetch: ${error instanceof Error ? error.message : String(error)}`, + { level: 'warn' }, + ) + return { + refreshed: false, + credentials: await readCodexCredentialsAsync(), + } + }, + ) const request = resolveProviderRequest({ model: process.env.OPENAI_MODEL, baseUrl: process.env.OPENAI_BASE_URL, @@ -401,16 +419,19 @@ export async function fetchCodexUsage(): Promise { ) } - const credentials = resolveCodexApiCredentials() + const credentials = resolveRuntimeCodexCredentials({ + storedCredentials: refreshResult.credentials, + }) if (!credentials.apiKey) { + const oauthHint = isBareMode() ? '' : ', choose Codex OAuth in /provider' const authHint = credentials.authPath - ? ` or place a Codex auth.json at ${credentials.authPath}` - : '' + ? `${oauthHint} or place a Codex auth.json at ${credentials.authPath}` + : oauthHint throw new Error(`Codex auth is required. Set CODEX_API_KEY${authHint}.`) } if (!credentials.accountId) { throw new Error( - 'Codex auth is missing chatgpt_account_id. Re-login with the Codex CLI or set CHATGPT_ACCOUNT_ID/CODEX_ACCOUNT_ID.', + 'Codex auth is missing chatgpt_account_id. Re-login with Codex OAuth, the Codex CLI, or set CHATGPT_ACCOUNT_ID/CODEX_ACCOUNT_ID.', ) } diff --git a/src/services/api/openaiShim.ts b/src/services/api/openaiShim.ts index 5e50ffe0..d78f6f6e 100644 --- a/src/services/api/openaiShim.ts +++ b/src/services/api/openaiShim.ts @@ -22,7 +22,12 @@ */ import { APIError } from '@anthropic-ai/sdk' -import { isEnvTruthy } from '../../utils/envUtils.js' +import { + readCodexCredentialsAsync, + refreshCodexAccessTokenIfNeeded, +} from '../../utils/codexCredentials.js' +import { logForDebugging } from '../../utils/debug.js' +import { isBareMode, isEnvTruthy } from '../../utils/envUtils.js' import { resolveGeminiCredential } from '../../utils/geminiAuth.js' import { hydrateGeminiAccessTokenFromSecureStorage } from '../../utils/geminiCredentials.js' import { hydrateGithubModelsTokenFromSecureStorage } from '../../utils/githubModelsCredentials.js' @@ -44,7 +49,7 @@ import { } from './codexShim.js' import { isLocalProviderUrl, - resolveCodexApiCredentials, + resolveRuntimeCodexCredentials, resolveProviderRequest, getGithubEndpointType, } from './providerConfig.js' @@ -1139,7 +1144,6 @@ class OpenAIShimMessages { const githubEndpointType = getGithubEndpointType(request.baseUrl) const isGithubMode = isGithubModelsMode() const isGithubWithCodexTransport = isGithubMode && request.transport === 'codex_responses' - const isGithubCopilotEndpoint = isGithubMode && githubEndpointType === 'copilot' if (isGithubWithCodexTransport) { const apiKey = this.providerOverride?.apiKey ?? process.env.OPENAI_API_KEY ?? '' @@ -1166,11 +1170,26 @@ class OpenAIShimMessages { } if (request.transport === 'codex_responses' && !isGithubMode) { - const credentials = resolveCodexApiCredentials() + const refreshResult = await refreshCodexAccessTokenIfNeeded().catch( + async error => { + logForDebugging( + `[codex] access token refresh failed before request: ${error instanceof Error ? error.message : String(error)}`, + { level: 'warn' }, + ) + return { + refreshed: false, + credentials: await readCodexCredentialsAsync(), + } + }, + ) + const credentials = resolveRuntimeCodexCredentials({ + storedCredentials: refreshResult.credentials, + }) if (!credentials.apiKey) { + const oauthHint = isBareMode() ? '' : ', choose Codex OAuth in /provider' const authHint = credentials.authPath - ? ` or place a Codex auth.json at ${credentials.authPath}` - : '' + ? `${oauthHint} or place a Codex auth.json at ${credentials.authPath}` + : oauthHint const safeModel = redactSecretValueForDisplay(request.requestedModel, process.env as SecretValueSource) ?? 'the requested model' @@ -1180,7 +1199,7 @@ class OpenAIShimMessages { } if (!credentials.accountId) { throw new Error( - 'Codex auth is missing chatgpt_account_id. Re-login with the Codex CLI or set CHATGPT_ACCOUNT_ID/CODEX_ACCOUNT_ID.', + 'Codex auth is missing chatgpt_account_id. Re-login with Codex OAuth, the Codex CLI, or set CHATGPT_ACCOUNT_ID/CODEX_ACCOUNT_ID.', ) } diff --git a/src/services/api/providerConfig.codexSecureStorage.test.ts b/src/services/api/providerConfig.codexSecureStorage.test.ts new file mode 100644 index 00000000..78cb9997 --- /dev/null +++ b/src/services/api/providerConfig.codexSecureStorage.test.ts @@ -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 { + 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 }) + } + }) +}) diff --git a/src/services/api/providerConfig.runtimeCodexCredentials.test.ts b/src/services/api/providerConfig.runtimeCodexCredentials.test.ts new file mode 100644 index 00000000..2c9a3d52 --- /dev/null +++ b/src/services/api/providerConfig.runtimeCodexCredentials.test.ts @@ -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 { + 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') +}) diff --git a/src/services/api/providerConfig.ts b/src/services/api/providerConfig.ts index d17b71a5..68c7e8fe 100644 --- a/src/services/api/providerConfig.ts +++ b/src/services/api/providerConfig.ts @@ -3,7 +3,16 @@ import { isIP } from 'node:net' import { homedir } from 'node:os' import { join } from 'node:path' +import { + isCodexRefreshFailureCoolingDown, + readCodexCredentials, + type CodexCredentialBlob, +} from '../../utils/codexCredentials.js' import { isEnvTruthy } from '../../utils/envUtils.js' +import { + asTrimmedString, + parseChatgptAccountId, +} from './codexOAuthShared.js' export const DEFAULT_OPENAI_BASE_URL = 'https://api.openai.com/v1' export const DEFAULT_CODEX_BASE_URL = 'https://chatgpt.com/backend-api/codex' @@ -78,7 +87,7 @@ export type ResolvedCodexCredentials = { apiKey: string accountId?: string authPath?: string - source: 'env' | 'auth.json' | 'none' + source: 'env' | 'secure-storage' | 'auth.json' | 'none' } type ModelDescriptor = { @@ -114,12 +123,6 @@ function isPrivateIpv6Address(hostname: string): boolean { return (prefix & 0xfe00) === 0xfc00 || (prefix & 0xffc0) === 0xfe80 } -function asTrimmedString(value: unknown): string | undefined { - if (typeof value !== 'string') return undefined - const trimmed = value.trim() - return trimmed ? trimmed : undefined -} - // Reads an env-var-style string intended as a URL or path, rejecting both // empty strings and the literal string "undefined" that Windows shells can // write when a variable is unset-then-referenced without quotes (issue #336). @@ -151,23 +154,6 @@ function readNestedString( return undefined } -function decodeJwtPayload(token: string): Record | 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) - : undefined - } catch { - return undefined - } -} - function parseReasoningEffort(value: string | undefined): ReasoningEffort | undefined { if (!value) return undefined const normalized = value.trim().toLowerCase() @@ -494,18 +480,6 @@ export function resolveCodexAuthPath( return join(homedir(), '.codex', 'auth.json') } -export function parseChatgptAccountId( - token: string | undefined, -): string | undefined { - if (!token) return undefined - const payload = decodeJwtPayload(token) - const fromClaim = asTrimmedString( - payload?.['https://api.openai.com/auth.chatgpt_account_id'], - ) - if (fromClaim) return fromClaim - return asTrimmedString(payload?.chatgpt_account_id) -} - function loadCodexAuthJson( authPath: string, ): Record | undefined { @@ -521,8 +495,97 @@ function loadCodexAuthJson( } } -export function resolveCodexApiCredentials( - env: NodeJS.ProcessEnv = process.env, +function resolveCodexAuthJsonCredentials(options: { + authJson: Record | undefined + authPath: string + envAccountId?: string + missingSource?: ResolvedCodexCredentials['source'] +}): ResolvedCodexCredentials { + const { authJson, authPath, envAccountId } = options + + if (!authJson) { + return { + apiKey: '', + authPath, + source: options.missingSource ?? 'none', + } + } + + const apiKey = readNestedString(authJson, [ + ['openai_api_key'], + ['openaiApiKey'], + ['access_token'], + ['accessToken'], + ['tokens', 'access_token'], + ['tokens', 'accessToken'], + ['auth', 'access_token'], + ['auth', 'accessToken'], + ['token', 'access_token'], + ['token', 'accessToken'], + ]) + // OIDC identity tokens can carry the ChatGPT account id, but they are not + // valid bearer credentials for Codex API requests. + const idToken = readNestedString(authJson, [ + ['id_token'], + ['idToken'], + ['tokens', 'id_token'], + ['tokens', 'idToken'], + ]) + const accountId = + envAccountId ?? + readNestedString(authJson, [ + ['account_id'], + ['accountId'], + ['tokens', 'account_id'], + ['tokens', 'accountId'], + ['auth', 'account_id'], + ['auth', 'accountId'], + ]) ?? + parseChatgptAccountId(apiKey) ?? + parseChatgptAccountId(idToken) + + if (!apiKey) { + return { + apiKey: '', + accountId, + authPath, + source: options.missingSource ?? 'none', + } + } + + return { + apiKey, + accountId, + authPath, + source: 'auth.json', + } +} + +export function resolveStoredCodexCredentials(options: { + storedCredentials: Pick< + CodexCredentialBlob, + 'apiKey' | 'accessToken' | 'idToken' | 'accountId' + > + envAccountId?: string +}): ResolvedCodexCredentials { + const { storedCredentials, envAccountId } = options + + return { + apiKey: storedCredentials.apiKey ?? storedCredentials.accessToken, + accountId: + envAccountId ?? + storedCredentials.accountId ?? + parseChatgptAccountId(storedCredentials.idToken) ?? + parseChatgptAccountId(storedCredentials.accessToken), + source: 'secure-storage', + } +} + +function resolveEnvOrAuthJsonCodexCredentials( + env: NodeJS.ProcessEnv, + options?: { + explicitAuthPathOnly?: boolean + }, ): ResolvedCodexCredentials { const envApiKey = asTrimmedString(env.CODEX_API_KEY) const envAccountId = @@ -537,55 +600,127 @@ export function resolveCodexApiCredentials( } } + const explicitAuthPathConfigured = Boolean( + asTrimmedString(env.CODEX_AUTH_JSON_PATH) ?? asTrimmedString(env.CODEX_HOME), + ) + + if (!explicitAuthPathConfigured && options?.explicitAuthPathOnly) { + return { + apiKey: '', + accountId: envAccountId, + source: 'none', + } + } + const authPath = resolveCodexAuthPath(env) const authJson = loadCodexAuthJson(authPath) - if (!authJson) { - return { - apiKey: '', - authPath, - source: 'none', - } - } - - const apiKey = readNestedString(authJson, [ - ['access_token'], - ['accessToken'], - ['tokens', 'access_token'], - ['tokens', 'accessToken'], - ['auth', 'access_token'], - ['auth', 'accessToken'], - ['token', 'access_token'], - ['token', 'accessToken'], - ['tokens', 'id_token'], - ['tokens', 'idToken'], - ]) - const accountId = - envAccountId ?? - readNestedString(authJson, [ - ['account_id'], - ['accountId'], - ['tokens', 'account_id'], - ['tokens', 'accountId'], - ['auth', 'account_id'], - ['auth', 'accountId'], - ]) ?? - parseChatgptAccountId(apiKey) - - if (!apiKey) { - return { - apiKey: '', - accountId, - authPath, - source: 'none', - } - } - - return { - apiKey, - accountId, + return resolveCodexAuthJsonCredentials({ + authJson, authPath, - source: 'auth.json', + envAccountId, + }) +} + +export function resolveRuntimeCodexCredentials(options?: { + env?: NodeJS.ProcessEnv + storedCredentials?: Pick< + CodexCredentialBlob, + 'apiKey' | 'accessToken' | 'idToken' | 'accountId' + > +}): ResolvedCodexCredentials { + const env = options?.env ?? process.env + const explicitCredentials = resolveEnvOrAuthJsonCodexCredentials(env, { + explicitAuthPathOnly: true, + }) + const explicitAuthPathConfigured = Boolean( + asTrimmedString(env.CODEX_AUTH_JSON_PATH) ?? asTrimmedString(env.CODEX_HOME), + ) + const hasStoredCredentialsOption = Boolean( + options && + Object.prototype.hasOwnProperty.call(options, 'storedCredentials'), + ) + + if ( + explicitAuthPathConfigured || + explicitCredentials.source === 'env' || + explicitCredentials.source === 'auth.json' + ) { + return explicitCredentials } + + if (options?.storedCredentials?.accessToken) { + return resolveStoredCodexCredentials({ + storedCredentials: options.storedCredentials, + envAccountId: + asTrimmedString(env.CODEX_ACCOUNT_ID) ?? + asTrimmedString(env.CHATGPT_ACCOUNT_ID), + }) + } + + if (hasStoredCredentialsOption) { + return resolveEnvOrAuthJsonCodexCredentials(env) + } + + return resolveCodexApiCredentials(env) +} + +export function resolveCodexApiCredentials( + env: NodeJS.ProcessEnv = process.env, +): ResolvedCodexCredentials { + const envAccountId = + asTrimmedString(env.CODEX_ACCOUNT_ID) ?? + asTrimmedString(env.CHATGPT_ACCOUNT_ID) + const envOrExplicitAuthJsonCredentials = resolveEnvOrAuthJsonCodexCredentials( + env, + { + explicitAuthPathOnly: true, + }, + ) + + if ( + envOrExplicitAuthJsonCredentials.source === 'env' || + envOrExplicitAuthJsonCredentials.source === 'auth.json' || + envOrExplicitAuthJsonCredentials.authPath + ) { + return envOrExplicitAuthJsonCredentials + } + + const storedCredentials = readCodexCredentials() + if (storedCredentials?.accessToken) { + const resolvedStoredCredentials = resolveStoredCodexCredentials({ + storedCredentials, + envAccountId, + }) + + const shouldCheckDefaultAuthJson = + !resolvedStoredCredentials.accountId || + isCodexRefreshFailureCoolingDown(storedCredentials) + + if (!shouldCheckDefaultAuthJson) { + return resolvedStoredCredentials + } + + const authPath = resolveCodexAuthPath(env) + const authJson = loadCodexAuthJson(authPath) + const resolvedAuthJsonCredentials = resolveCodexAuthJsonCredentials({ + authJson, + authPath, + envAccountId, + }) + + if (resolvedAuthJsonCredentials.apiKey) { + return { + ...resolvedAuthJsonCredentials, + accountId: + resolvedAuthJsonCredentials.accountId ?? + resolvedStoredCredentials.accountId, + } + } + + return resolvedStoredCredentials + } + + return resolveEnvOrAuthJsonCodexCredentials(env) } export function getReasoningEffortForModel(model: string): ReasoningEffort | undefined { @@ -595,3 +730,18 @@ export function getReasoningEffortForModel(model: string): ReasoningEffort | und const aliasConfig = CODEX_ALIAS_MODELS[alias] return aliasConfig?.reasoningEffort } + +export function supportsCodexReasoningEffort(model: string): boolean { + const normalized = model.trim().toLowerCase() + const base = normalized.split('?', 1)[0] ?? normalized + + if (base === 'gpt-5.3-codex-spark' || base === 'codexspark') { + return false + } + + if (getReasoningEffortForModel(base) !== undefined) { + return true + } + + return /^gpt-5(?:[.-]|$)/.test(base) +} diff --git a/src/services/oauth/auth-code-listener.analytics.test.ts b/src/services/oauth/auth-code-listener.analytics.test.ts new file mode 100644 index 00000000..27800596 --- /dev/null +++ b/src/services/oauth/auth-code-listener.analytics.test.ts @@ -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 + }> = [] + + mock.module('src/services/analytics/index.js', () => ({ + logEvent: ( + name: string, + metadata: Record, + ) => { + 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 + }> = [] + 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, + ) => { + 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 + }> = [] + 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, + ) => { + 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([]) +}) diff --git a/src/services/oauth/auth-code-listener.test.ts b/src/services/oauth/auth-code-listener.test.ts new file mode 100644 index 00000000..4e947755 --- /dev/null +++ b/src/services/oauth/auth-code-listener.test.ts @@ -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.', + ) +}) diff --git a/src/services/oauth/auth-code-listener.ts b/src/services/oauth/auth-code-listener.ts index a46c33ec..431679b7 100644 --- a/src/services/oauth/auth-code-listener.ts +++ b/src/services/oauth/auth-code-listener.ts @@ -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 + }): void { + if (!this.pendingResponse) return + + const response = this.pendingResponse + try { + options.handler(response) + + if (!response.writableEnded && !response.destroyed) { + response.end() + } + + logEvent(options.analyticsEvent, options.analyticsMetadata ?? {}) + } catch (error) { + logError(error) + + if (!response.headersSent && !response.destroyed) { + response.writeHead(500, { + 'Content-Type': 'text/plain; charset=utf-8', + }) + } + if (!response.writableEnded && !response.destroyed) { + response.end('Authentication redirect failed') + } + } finally { + if (this.pendingResponse === response) { + this.pendingResponse = null + } + } + } + /** * Completes the OAuth flow by redirecting the user's browser to a success page. * Different success pages are shown based on the granted scopes. @@ -85,9 +121,13 @@ export class AuthCodeListener { // If custom handler provided, use it instead of default redirect if (customHandler) { - customHandler(this.pendingResponse, scopes) - this.pendingResponse = null - logEvent('tengu_oauth_automatic_redirect', { custom_handler: true }) + this.respondToPendingRequest({ + handler: res => { + customHandler(res, scopes) + }, + analyticsEvent: 'tengu_oauth_automatic_redirect', + analyticsMetadata: { custom_handler: true }, + }) return } @@ -97,29 +137,48 @@ export class AuthCodeListener { : getOauthConfig().CONSOLE_SUCCESS_URL // Send browser to success page - this.pendingResponse.writeHead(302, { Location: successUrl }) - this.pendingResponse.end() - this.pendingResponse = null - - logEvent('tengu_oauth_automatic_redirect', {}) + this.respondToPendingRequest({ + handler: res => { + res.writeHead(302, { Location: successUrl }) + res.end() + }, + analyticsEvent: 'tengu_oauth_automatic_redirect', + }) } /** * Handles error case by sending a redirect to the appropriate success page with an error indicator, * ensuring the browser flow is completed properly. */ - handleErrorRedirect(): void { + handleErrorRedirect(customHandler?: (res: ServerResponse) => void): void { if (!this.pendingResponse) return + if (customHandler) { + this.respondToPendingRequest({ + handler: customHandler, + analyticsEvent: 'tengu_oauth_automatic_redirect_error', + analyticsMetadata: { custom_handler: true }, + }) + return + } + // TODO: swap to a different url once we have an error page const errorUrl = getOauthConfig().CLAUDEAI_SUCCESS_URL - // Send browser to error page - this.pendingResponse.writeHead(302, { Location: errorUrl }) - this.pendingResponse.end() - this.pendingResponse = null + this.respondToPendingRequest({ + handler: res => { + res.writeHead(302, { Location: errorUrl }) + res.end() + }, + analyticsEvent: 'tengu_oauth_automatic_redirect_error', + }) + } - logEvent('tengu_oauth_automatic_redirect_error', {}) + cancelPendingAuthorization( + error: Error = new Error('OAuth authorization was cancelled.'), + ): void { + this.reject(error) + this.close() } private startLocalListener(onReady: () => Promise): void { @@ -176,8 +235,7 @@ export class AuthCodeListener { private handleError(err: Error): void { logError(err) - this.close() - this.reject(err) + this.cancelPendingAuthorization(err) } private resolve(authorizationCode: string): void { @@ -185,6 +243,7 @@ export class AuthCodeListener { this.promiseResolver(authorizationCode) this.promiseResolver = null this.promiseRejecter = null + this.expectedState = null } } @@ -193,6 +252,7 @@ export class AuthCodeListener { this.promiseRejecter(error) this.promiseResolver = null this.promiseRejecter = null + this.expectedState = null } } @@ -207,5 +267,8 @@ export class AuthCodeListener { this.localServer.removeAllListeners() this.localServer.close() } + + this.expectedState = null + this.port = 0 } } diff --git a/src/utils/codexCredentials.test.ts b/src/utils/codexCredentials.test.ts new file mode 100644 index 00000000..dd5be38b --- /dev/null +++ b/src/utils/codexCredentials.test.ts @@ -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 { + 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 = { + codex: { + accessToken: expiredToken, + refreshToken: 'refresh-old', + accountId: 'acct_old', + }, + } + + mock.module('./secureStorage/index.js', () => ({ + getSecureStorage: () => ({ + read: () => storageState, + readAsync: async () => storageState, + update: (next: Record) => { + 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 = { + codex: { + accessToken: expiredToken, + refreshToken: 'refresh-old', + accountId: 'acct_old', + }, + } + + mock.module('./secureStorage/index.js', () => ({ + getSecureStorage: () => ({ + read: () => storageState, + readAsync: async () => storageState, + update: (next: Record) => { + 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 = { + 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) => { + 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 = { + codex: { + accessToken: expiredToken, + refreshToken: 'refresh-old', + accountId: 'acct_old', + }, + } + + mock.module('./secureStorage/index.js', () => ({ + getSecureStorage: () => ({ + read: () => storageState, + readAsync: async () => storageState, + update: (next: Record) => { + storageState = next + return { success: true } + }, + }), + })) + + let refreshAttempts = 0 + let releaseRefresh: (() => void) | undefined + const refreshGate = new Promise(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 = { + 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) => { + 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 = { + codex: { + accessToken: 'access-old', + refreshToken: 'refresh-old', + accountId: 'acct_old', + }, + } + + mock.module('./secureStorage/index.js', () => ({ + getSecureStorage: () => ({ + read: () => storageState, + readAsync: async () => storageState, + update: (next: Record) => { + 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 = { + 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) => { + 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 = { + 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) + }) +}) diff --git a/src/utils/codexCredentials.ts b/src/utils/codexCredentials.ts new file mode 100644 index 00000000..81c3393f --- /dev/null +++ b/src/utils/codexCredentials.ts @@ -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 + 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 + const nestedError = + parsed.error && typeof parsed.error === 'object' + ? (parsed.error as Record) + : 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, + 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), + [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) } + 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 +} diff --git a/src/utils/effort.codex.test.ts b/src/utils/effort.codex.test.ts new file mode 100644 index 00000000..2d3a3468 --- /dev/null +++ b/src/utils/effort.codex.test.ts @@ -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([]) +}) diff --git a/src/utils/effort.ts b/src/utils/effort.ts index 13fd6699..af1a525b 100644 --- a/src/utils/effort.ts +++ b/src/utils/effort.ts @@ -5,6 +5,7 @@ import { isProSubscriber, isMaxSubscriber, isTeamSubscriber } from './auth.js' import { getFeatureValue_CACHED_MAY_BE_STALE } from 'src/services/analytics/growthbook.js' import { getAPIProvider } from './model/providers.js' import { get3PModelCapabilityOverride } from './model/modelSupportOverrides.js' +import { supportsCodexReasoningEffort } from '../services/api/providerConfig.js' import { isEnvTruthy } from './envUtils.js' import type { EffortLevel } from 'src/entrypoints/sdk/runtimeTypes.js' @@ -37,6 +38,9 @@ export function modelSupportsEffort(model: string): boolean { if (supported3P !== undefined) { return supported3P } + if (modelUsesOpenAIEffort(model) && supportsCodexReasoningEffort(model)) { + return true + } // Supported by a subset of Claude 4 models if (m.includes('opus-4-6') || m.includes('sonnet-4-6')) { return true @@ -86,6 +90,9 @@ export function modelUsesOpenAIEffort(model: string): boolean { } export function getAvailableEffortLevels(model: string): EffortLevel[] | OpenAIEffortLevel[] { + if (!modelSupportsEffort(model)) { + return [] + } if (modelUsesOpenAIEffort(model)) { return [...OPENAI_EFFORT_LEVELS] as OpenAIEffortLevel[] } diff --git a/src/utils/providerProfile.test.ts b/src/utils/providerProfile.test.ts index b5014931..af6cc0be 100644 --- a/src/utils/providerProfile.test.ts +++ b/src/utils/providerProfile.test.ts @@ -4,6 +4,7 @@ import { tmpdir } from 'node:os' import { join } from 'node:path' import test from 'node:test' +import { DEFAULT_CODEX_BASE_URL } from '../services/api/providerConfig.ts' import { buildStartupEnvFromProfile, buildAtomicChatProfileEnv, @@ -12,7 +13,9 @@ import { buildLaunchEnv, buildOllamaProfileEnv, buildOpenAIProfileEnv, + clearPersistedCodexOAuthProfile, createProfileFile, + isPersistedCodexOAuthProfile, maskSecretForDisplay, loadProfileFile, PROFILE_FILE_NAME, @@ -23,6 +26,13 @@ import { type ProfileFile, } from './providerProfile.ts' +function makeJwt(payload: Record): string { + const header = Buffer.from(JSON.stringify({ alg: 'none', typ: 'JWT' })) + .toString('base64url') + const body = Buffer.from(JSON.stringify(payload)).toString('base64url') + return `${header}.${body}.signature` +} + function profile(profile: ProfileFile['profile'], env: ProfileFile['env']): ProfileFile { return { profile, @@ -330,6 +340,7 @@ test('codex profiles accept explicit codex credentials', () => { assert.deepEqual(env, { OPENAI_BASE_URL: 'https://chatgpt.com/backend-api/codex', OPENAI_MODEL: 'codexspark', + CODEX_CREDENTIAL_SOURCE: 'existing', CODEX_API_KEY: 'codex-live', CHATGPT_ACCOUNT_ID: 'acct_123', }) @@ -417,6 +428,77 @@ test('saveProfileFile writes a profile that loadProfileFile can read back', () = } }) +test('buildCodexProfileEnv tags OAuth-saved profiles so logout can remove them safely', () => { + const env = buildCodexProfileEnv({ + model: 'codexplan', + apiKey: makeJwt({ + 'https://api.openai.com/auth': { + chatgpt_account_id: 'acct_oauth', + }, + }), + credentialSource: 'oauth', + processEnv: {}, + }) + + assert.deepEqual(env, { + OPENAI_BASE_URL: DEFAULT_CODEX_BASE_URL, + OPENAI_MODEL: 'codexplan', + CODEX_CREDENTIAL_SOURCE: 'oauth', + CODEX_API_KEY: makeJwt({ + 'https://api.openai.com/auth': { + chatgpt_account_id: 'acct_oauth', + }, + }), + CHATGPT_ACCOUNT_ID: 'acct_oauth', + }) +}) + +test('clearPersistedCodexOAuthProfile removes only persisted Codex OAuth profiles', async () => { + const cwd = mkdtempSync(join(tmpdir(), 'openclaude-codex-oauth-profile-')) + + try { + const providerProfileModule = await import( + `./providerProfile.ts?ts=${Date.now()}-${Math.random()}` + ) + const { + PROFILE_FILE_NAME, + clearPersistedCodexOAuthProfile, + createProfileFile, + isPersistedCodexOAuthProfile, + loadProfileFile, + saveProfileFile, + } = providerProfileModule + const oauthProfile = createProfileFile('codex', { + OPENAI_MODEL: 'codexplan', + OPENAI_BASE_URL: DEFAULT_CODEX_BASE_URL, + CHATGPT_ACCOUNT_ID: 'acct_oauth', + CODEX_CREDENTIAL_SOURCE: 'oauth', + }) + saveProfileFile(oauthProfile, { cwd }) + + assert.equal(isPersistedCodexOAuthProfile(loadProfileFile({ cwd })), true) + assert.equal( + clearPersistedCodexOAuthProfile({ cwd }), + join(cwd, PROFILE_FILE_NAME), + ) + assert.equal(loadProfileFile({ cwd }), null) + + const existingCredentialProfile = createProfileFile('codex', { + OPENAI_MODEL: 'codexplan', + OPENAI_BASE_URL: DEFAULT_CODEX_BASE_URL, + CHATGPT_ACCOUNT_ID: 'acct_existing', + CODEX_CREDENTIAL_SOURCE: 'existing', + }) + saveProfileFile(existingCredentialProfile, { cwd }) + + assert.equal(isPersistedCodexOAuthProfile(loadProfileFile({ cwd })), false) + assert.equal(clearPersistedCodexOAuthProfile({ cwd }), null) + assert.deepEqual(loadProfileFile({ cwd }), existingCredentialProfile) + } finally { + rmSync(cwd, { recursive: true, force: true }) + } +}) + test('buildStartupEnvFromProfile applies persisted gemini settings when no provider is explicitly selected', async () => { const env = await buildStartupEnvFromProfile({ persisted: profile('gemini', { diff --git a/src/utils/providerProfile.ts b/src/utils/providerProfile.ts index a5609015..ce39e1f1 100644 --- a/src/utils/providerProfile.ts +++ b/src/utils/providerProfile.ts @@ -7,6 +7,7 @@ import { resolveCodexApiCredentials, resolveProviderRequest, } from '../services/api/providerConfig.ts' +import { parseChatgptAccountId } from '../services/api/codexOAuthShared.js' import { getGoalDefaultOpenAIModel, normalizeRecommendationGoal, @@ -14,6 +15,20 @@ import { } from './providerRecommendation.ts' import { readGeminiAccessToken } from './geminiCredentials.ts' import { getOllamaChatBaseUrl } from './providerDiscovery.ts' +import { getProviderValidationError } from './providerValidation.ts' +import { + maskSecretForDisplay, + redactSecretValueForDisplay, + sanitizeApiKey, + sanitizeProviderConfigValue, +} from './providerSecrets.ts' + +export { + maskSecretForDisplay, + redactSecretValueForDisplay, + sanitizeApiKey, + sanitizeProviderConfigValue, +} from './providerSecrets.ts' export const PROFILE_FILE_NAME = '.openclaude-profile.json' export const DEFAULT_GEMINI_BASE_URL = @@ -33,6 +48,7 @@ const PROFILE_ENV_KEYS = [ 'OPENAI_MODEL', 'OPENAI_API_KEY', 'CODEX_API_KEY', + 'CODEX_CREDENTIAL_SOURCE', 'CHATGPT_ACCOUNT_ID', 'CODEX_ACCOUNT_ID', 'GEMINI_API_KEY', @@ -46,21 +62,20 @@ const PROFILE_ENV_KEYS = [ 'MISTRAL_MODEL', ] as const -const SECRET_ENV_KEYS = [ - 'OPENAI_API_KEY', - 'CODEX_API_KEY', - 'GEMINI_API_KEY', - 'GOOGLE_API_KEY', - 'MISTRAL_API_KEY', -] as const - -export type ProviderProfile = 'openai' | 'ollama' | 'codex' | 'gemini' | 'atomic-chat' | 'mistral' +export type ProviderProfile = + | 'openai' + | 'ollama' + | 'codex' + | 'gemini' + | 'atomic-chat' + | 'mistral' export type ProfileEnv = { OPENAI_BASE_URL?: string OPENAI_MODEL?: string OPENAI_API_KEY?: string CODEX_API_KEY?: string + CODEX_CREDENTIAL_SOURCE?: 'oauth' | 'existing' CHATGPT_ACCOUNT_ID?: string CODEX_ACCOUNT_ID?: string GEMINI_API_KEY?: string @@ -78,13 +93,6 @@ export type ProfileFile = { createdAt: string } -type SecretValueSource = Partial< - Pick< - NodeJS.ProcessEnv & ProfileEnv, - (typeof SECRET_ENV_KEYS)[number] - > -> - type ProfileFileLocation = { cwd?: string filePath?: string @@ -109,102 +117,6 @@ export function isProviderProfile(value: unknown): value is ProviderProfile { ) } -export function sanitizeApiKey( - key: string | null | undefined, -): string | undefined { - if (!key || key === 'SUA_CHAVE') return undefined - return key -} - -function looksLikeSecretValue(value: string): boolean { - const trimmed = value.trim() - if (!trimmed) return false - - if (trimmed.startsWith('sk-') || trimmed.startsWith('sk-ant-')) { - return true - } - - if (trimmed.startsWith('AIza')) { - return true - } - - return false -} - -function collectSecretValues( - sources: Array, -): string[] { - const values = new Set() - - 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 -): 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 -): string | undefined { - if (!value) return undefined - - const trimmed = value.trim() - if (!trimmed) return undefined - - const secretValues = collectSecretValues(sources) - if (secretValues.includes(trimmed) || looksLikeSecretValue(trimmed)) { - return undefined - } - - return trimmed -} - export function buildOllamaProfileEnv( model: string, options: { @@ -335,6 +247,7 @@ export function buildCodexProfileEnv(options: { model?: string | null baseUrl?: string | null apiKey?: string | null + credentialSource?: 'oauth' | 'existing' processEnv?: NodeJS.ProcessEnv }): ProfileEnv | null { const processEnv = options.processEnv ?? process.env @@ -346,10 +259,14 @@ export function buildCodexProfileEnv(options: { if (!credentials.apiKey || !credentials.accountId) { return null } + const credentialSource = + options.credentialSource ?? + (credentials.source === 'secure-storage' ? 'oauth' : 'existing') const env: ProfileEnv = { OPENAI_BASE_URL: options.baseUrl || DEFAULT_CODEX_BASE_URL, OPENAI_MODEL: options.model || 'codexplan', + CODEX_CREDENTIAL_SOURCE: credentialSource, } if (key) { @@ -399,6 +316,30 @@ export function buildMistralProfileEnv(options: { return env } +export function buildCodexOAuthProfileEnv( + tokens: { + accessToken: string + idToken?: string + accountId?: string + }, +): ProfileEnv | null { + const accountId = + tokens.accountId ?? + parseChatgptAccountId(tokens.idToken) ?? + parseChatgptAccountId(tokens.accessToken) + + if (!accountId) { + return null + } + + return { + OPENAI_BASE_URL: DEFAULT_CODEX_BASE_URL, + OPENAI_MODEL: 'codexplan', + CHATGPT_ACCOUNT_ID: accountId, + CODEX_CREDENTIAL_SOURCE: 'oauth', + } +} + export function createProfileFile( profile: ProviderProfile, env: ProfileEnv, @@ -410,6 +351,26 @@ export function createProfileFile( } } +export function isPersistedCodexOAuthProfile( + persisted: ProfileFile | null, +): boolean { + return ( + persisted?.profile === 'codex' && + persisted.env.CODEX_CREDENTIAL_SOURCE === 'oauth' + ) +} + +export function clearPersistedCodexOAuthProfile( + options?: ProfileFileLocation, +): string | null { + const persisted = loadProfileFile(options) + if (!isPersistedCodexOAuthProfile(persisted)) { + return null + } + + return deleteProfileFile(options) +} + export function loadProfileFile(options?: ProfileFileLocation): ProfileFile | null { const filePath = resolveProfileFilePath(options) if (!existsSync(filePath)) { @@ -545,6 +506,7 @@ export async function buildLaunchEnv(options: { delete env.CLAUDE_CODE_USE_OPENAI delete env.CLAUDE_CODE_USE_GITHUB + delete env.CODEX_CREDENTIAL_SOURCE env.GEMINI_MODEL = shellGeminiModel || @@ -668,6 +630,7 @@ export async function buildLaunchEnv(options: { delete env.CLAUDE_CODE_USE_FOUNDRY delete env.CLAUDE_CODE_USE_GEMINI delete env.CLAUDE_CODE_USE_GITHUB + delete env.CODEX_CREDENTIAL_SOURCE delete env.GEMINI_API_KEY delete env.GEMINI_AUTH_MODE delete env.GEMINI_ACCESS_TOKEN @@ -838,3 +801,40 @@ export function applyProfileEnvToProcessEnv( Object.assign(targetEnv, nextEnv) } + +export async function applySavedProfileToCurrentSession(options: { + profileFile: ProfileFile + processEnv?: NodeJS.ProcessEnv +}): Promise { + 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 +} diff --git a/src/utils/providerSecrets.ts b/src/utils/providerSecrets.ts new file mode 100644 index 00000000..78c2c9bf --- /dev/null +++ b/src/utils/providerSecrets.ts @@ -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, +): string[] { + const values = new Set() + + 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 +): 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 +): 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 +} diff --git a/src/utils/providerValidation.ts b/src/utils/providerValidation.ts index 5f462c35..fa671388 100644 --- a/src/utils/providerValidation.ts +++ b/src/utils/providerValidation.ts @@ -6,11 +6,13 @@ import { resolveProviderRequest, } from '../services/api/providerConfig.js' import { getGlobalClaudeFile } from './env.js' +import { isBareMode } from './envUtils.js' import { type GeminiResolvedCredential, resolveGeminiCredential, } from './geminiAuth.js' -import { PROFILE_FILE_NAME, redactSecretValueForDisplay } from './providerProfile.js' +import { PROFILE_FILE_NAME } from './providerProfile.js' +import { redactSecretValueForDisplay } from './providerSecrets.js' function isEnvTruthy(value: string | undefined): boolean { if (!value) return false @@ -82,6 +84,7 @@ export async function getProviderValidationError( ) => Promise }, ): Promise { + const secretSource = env const useOpenAI = isEnvTruthy(env.CLAUDE_CODE_USE_OPENAI) const useGithub = isEnvTruthy(env.CLAUDE_CODE_USE_GITHUB) @@ -131,16 +134,17 @@ export async function getProviderValidationError( if (request.transport === 'codex_responses') { const credentials = resolveCodexApiCredentials(env) if (!credentials.apiKey) { + const oauthHint = isBareMode() ? '' : ', choose Codex OAuth in /provider' const authHint = credentials.authPath - ? ` or put auth.json at ${credentials.authPath}` - : '' + ? `${oauthHint} or put auth.json at ${credentials.authPath}` + : oauthHint const safeModel = - redactSecretValueForDisplay(request.requestedModel, env) ?? + redactSecretValueForDisplay(request.requestedModel, secretSource) ?? 'the requested model' return `Codex auth is required for ${safeModel}. Set CODEX_API_KEY${authHint}.` } if (!credentials.accountId) { - return 'Codex auth is missing chatgpt_account_id. Re-login with Codex or set CHATGPT_ACCOUNT_ID/CODEX_ACCOUNT_ID.' + return 'Codex auth is missing chatgpt_account_id. Re-login with Codex OAuth, Codex CLI, or set CHATGPT_ACCOUNT_ID/CODEX_ACCOUNT_ID.' } return null } diff --git a/src/utils/secureStorage/index.ts b/src/utils/secureStorage/index.ts index 817d2320..aea357e0 100644 --- a/src/utils/secureStorage/index.ts +++ b/src/utils/secureStorage/index.ts @@ -5,6 +5,16 @@ import { windowsCredentialStorage } from './windowsCredentialStorage.js' import { plainTextStorage } from './plainTextStorage.js' export interface SecureStorageData { + codex?: { + apiKey?: string + accessToken: string + refreshToken?: string + idToken?: string + accountId?: string + profileId?: string + lastRefreshAt?: number + lastRefreshFailureAt?: number + } mcpOAuth?: Record< string, { @@ -36,22 +46,44 @@ export interface SecureStorage { delete(): boolean } +const unavailableSecureStorage: SecureStorage = { + name: 'unavailable-secure-storage', + read: () => null, + readAsync: async () => null, + update: () => ({ + success: false, + warning: + 'Secure storage is unavailable on this platform without plaintext fallback.', + }), + delete: () => true, +} + /** * Get the appropriate secure storage implementation for the current platform. * Prefers native OS vaults (Keychain, libsecret, Credential Locker) with a plaintext fallback. */ -export function getSecureStorage(): SecureStorage { +export function getSecureStorage(options?: { + allowPlainTextFallback?: boolean +}): SecureStorage { + const allowPlainTextFallback = options?.allowPlainTextFallback ?? true + if (process.platform === 'darwin') { - return createFallbackStorage(macOsKeychainStorage, plainTextStorage) + return allowPlainTextFallback + ? createFallbackStorage(macOsKeychainStorage, plainTextStorage) + : macOsKeychainStorage } if (process.platform === 'linux') { - return createFallbackStorage(linuxSecretStorage, plainTextStorage) + return allowPlainTextFallback + ? createFallbackStorage(linuxSecretStorage, plainTextStorage) + : linuxSecretStorage } if (process.platform === 'win32') { - return createFallbackStorage(windowsCredentialStorage, plainTextStorage) + return allowPlainTextFallback + ? createFallbackStorage(windowsCredentialStorage, plainTextStorage) + : windowsCredentialStorage } - return plainTextStorage + return allowPlainTextFallback ? plainTextStorage : unavailableSecureStorage } diff --git a/src/utils/secureStorage/platformStorage.test.ts b/src/utils/secureStorage/platformStorage.test.ts index 6b835243..76b14522 100644 --- a/src/utils/secureStorage/platformStorage.test.ts +++ b/src/utils/secureStorage/platformStorage.test.ts @@ -64,8 +64,10 @@ describe("Secure Storage Platform Implementations", () => { windowsCredentialStorage.update(testData); const script = mockExecaSync.mock.calls[0][1][1]; + const options = mockExecaSync.mock.calls[0][2]; expect(script).toContain(expectedName); - expect(script).toContain("Add-Type -AssemblyName System.Runtime.WindowsRuntime"); + expect(script).toContain("ProtectedData"); + expect(options.input).toContain("secret-token"); }); }); @@ -85,32 +87,54 @@ describe("Secure Storage Platform Implementations", () => { windowsCredentialStorage.update(dataWithDollar); const script = mockExecaSync.mock.calls[0][1][1]; - // Should use single quotes for the payload - expect(script).toMatch(/'\{.*\}'/); - // Should escape ' by doubling it - expect(script).not.toContain("'token-with-$env:USERNAME'"); - // But since it's JSON, the value will be "token-with-$env:USERNAME" inside the single-quoted string - // The JSON itself shouldn't have single quotes unless the data has them. + const options = mockExecaSync.mock.calls[0][2]; + expect(script).toContain("[Console]::In.ReadToEnd()"); + expect(options.input).toContain("token-with-$env:USERNAME"); const dataWithQuote = { mcpOAuth: { "s": { accessToken: "token'quote", expiresAt: 1, serverName: "s", serverUrl: "u" } } }; windowsCredentialStorage.update(dataWithQuote); - const script2 = mockExecaSync.mock.calls[1][1][1]; - expect(script2).toContain("token''quote"); + const options2 = mockExecaSync.mock.calls[1][2]; + expect(options2.input).toContain("token'quote"); }); test("delete() includes assembly load", () => { windowsCredentialStorage.delete(); - const script = mockExecaSync.mock.calls[0][1][1]; + const script = mockExecaSync.mock.calls[1][1][1]; expect(script).toContain("Add-Type -AssemblyName System.Runtime.WindowsRuntime"); }); test("escapes double quotes in username", () => { process.env.USER = 'user"name'; windowsCredentialStorage.read(); - const script = mockExecaSync.mock.calls[0][1][1]; + const script = mockExecaSync.mock.calls[1][1][1]; expect(script).toContain('user`"name'); expect(script).not.toContain('user"name'); }); + + test("read() falls back to legacy PasswordVault when the DPAPI payload is invalid JSON", () => { + mockExecaSync + .mockImplementationOnce(() => ({ exitCode: 0, stdout: "{not-json" })) + .mockImplementationOnce(() => ({ + exitCode: 0, + stdout: JSON.stringify(testData), + })); + + const result = windowsCredentialStorage.read(); + + expect(result).toEqual(testData); + expect(mockExecaSync).toHaveBeenCalledTimes(2); + }); + + test("read() fails closed when the legacy PasswordVault payload is invalid JSON", () => { + mockExecaSync + .mockImplementationOnce(() => ({ exitCode: 1, stdout: "" })) + .mockImplementationOnce(() => ({ exitCode: 0, stdout: "{not-json" })); + + const result = windowsCredentialStorage.read(); + + expect(result).toBeNull(); + expect(mockExecaSync).toHaveBeenCalledTimes(2); + }); }); describe("Linux secret-tool Interaction", () => { diff --git a/src/utils/secureStorage/windowsCredentialStorage.ts b/src/utils/secureStorage/windowsCredentialStorage.ts index 050fe781..938a79ad 100644 --- a/src/utils/secureStorage/windowsCredentialStorage.ts +++ b/src/utils/secureStorage/windowsCredentialStorage.ts @@ -1,4 +1,6 @@ import { execaSync } from 'execa' +import { join } from 'path' +import { getClaudeConfigHomeDir } from '../envUtils.js' import { jsonParse, jsonStringify } from '../slowOperations.js' import { CREDENTIALS_SERVICE_SUFFIX, @@ -8,90 +10,216 @@ import { import type { SecureStorage, SecureStorageData } from './index.js' /** - * Windows-specific secure storage implementation using the Windows Credential Locker. - * Accessed via PowerShell's [Windows.Security.Credentials.PasswordVault]. + * Windows-specific secure storage implementation using DPAPI for new writes, + * with best-effort reads/deletes from the legacy PasswordVault path. */ -export const windowsCredentialStorage: SecureStorage = { - name: 'credential-locker', - read(): SecureStorageData | null { - const resourceName = getSecureStorageServiceName( - CREDENTIALS_SERVICE_SUFFIX, - ).replace(/"/g, '`"') - const username = getUsername().replace(/"/g, '`"') - // PowerShell script to retrieve password from vault - const script = ` - Add-Type -AssemblyName System.Runtime.WindowsRuntime +function escapePowerShellSingleQuoted(value: string): string { + return value.replace(/'/g, "''") +} + +function getLegacyResourceName(): string { + return getSecureStorageServiceName(CREDENTIALS_SERVICE_SUFFIX) +} + +function getWindowsSecureStorageEntropy(): string { + return `${getLegacyResourceName()}:${getUsername()}` +} + +function getWindowsSecureStorageFilePath(): string { + const resourceName = getLegacyResourceName().replace(/[^a-zA-Z0-9._-]/g, '_') + return join(getClaudeConfigHomeDir(), `${resourceName}.secure.dpapi`) +} + +function runPowerShell( + script: string, + options?: { input?: string }, +): ReturnType | null { + try { + return execaSync('powershell.exe', ['-Command', script], { + input: options?.input, + reject: false, + }) + } catch { + return null + } +} + +function getFailureWarning( + result: ReturnType | null, + fallback: string, +): string { + const stderr = result?.stderr?.trim() + if (stderr) { + return stderr + } + + if (typeof result?.exitCode === 'number' && result.exitCode !== 0) { + return `${fallback} (exit code ${result.exitCode}).` + } + + return fallback +} + +function readLegacyPasswordVault(): SecureStorageData | null { + const resourceName = getLegacyResourceName().replace(/"/g, '`"') + const username = getUsername().replace(/"/g, '`"') + const script = ` + Add-Type -AssemblyName System.Runtime.WindowsRuntime + try { $vault = New-Object Windows.Security.Credentials.PasswordVault + $cred = $vault.Retrieve("${resourceName}", "${username}") + $cred.FillPassword() + [Console]::Out.Write($cred.Password) + } catch { + exit 1 + } + ` + + const result = runPowerShell(script) + if (result?.exitCode === 0 && result.stdout) { + try { + return jsonParse(result.stdout) + } catch { + return null + } + } + + return null +} + +export const windowsCredentialStorage: SecureStorage = { + name: 'credential-locker-dpapi', + read(): SecureStorageData | null { + const filePath = escapePowerShellSingleQuoted( + getWindowsSecureStorageFilePath(), + ) + const entropy = escapePowerShellSingleQuoted( + getWindowsSecureStorageEntropy(), + ) + const script = ` try { - $cred = $vault.Retrieve("${resourceName}", "${username}") - $cred.FillPassword() - $cred.Password + Add-Type -AssemblyName System.Security + $path = '${filePath}' + if (!(Test-Path -LiteralPath $path)) { + exit 1 + } + + $protectedBase64 = [System.IO.File]::ReadAllText( + $path, + [System.Text.Encoding]::UTF8 + ).Trim() + if (-not $protectedBase64) { + exit 1 + } + + $protectedBytes = [Convert]::FromBase64String($protectedBase64) + $entropyBytes = [System.Text.Encoding]::UTF8.GetBytes('${entropy}') + $bytes = [System.Security.Cryptography.ProtectedData]::Unprotect( + $protectedBytes, + $entropyBytes, + [System.Security.Cryptography.DataProtectionScope]::CurrentUser + ) + [Console]::Out.Write([System.Text.Encoding]::UTF8.GetString($bytes)) } catch { exit 1 } ` - try { - const result = execaSync('powershell.exe', ['-Command', script], { - reject: false, - }) - if (result.exitCode === 0 && result.stdout) { + + const result = runPowerShell(script) + if (result?.exitCode === 0 && result.stdout) { + try { return jsonParse(result.stdout) + } catch { + return readLegacyPasswordVault() } - } catch { - // fall through } - return null + + return readLegacyPasswordVault() }, async readAsync(): Promise { return this.read() }, update(data: SecureStorageData): { success: boolean; warning?: string } { - const resourceName = getSecureStorageServiceName( - CREDENTIALS_SERVICE_SUFFIX, - ).replace(/"/g, '`"') - const username = getUsername().replace(/"/g, '`"') - // Use single quotes for the payload and escape ' by doubling it (''). - // This prevents PowerShell from expanding $... inside the string. - const payload = jsonStringify(data).replace(/'/g, "''") - // PowerShell script to add/update credential in vault + const filePath = escapePowerShellSingleQuoted( + getWindowsSecureStorageFilePath(), + ) + const entropy = escapePowerShellSingleQuoted( + getWindowsSecureStorageEntropy(), + ) + const payload = jsonStringify(data) const script = ` - Add-Type -AssemblyName System.Runtime.WindowsRuntime - $vault = New-Object Windows.Security.Credentials.PasswordVault - $cred = New-Object Windows.Security.Credentials.PasswordCredential("${resourceName}", "${username}", '${payload}') - $vault.Add($cred) + try { + Add-Type -AssemblyName System.Security + $path = '${filePath}' + $directory = [System.IO.Path]::GetDirectoryName($path) + if ($directory) { + [System.IO.Directory]::CreateDirectory($directory) | Out-Null + } + + $payload = [Console]::In.ReadToEnd() + $bytes = [System.Text.Encoding]::UTF8.GetBytes($payload) + $entropyBytes = [System.Text.Encoding]::UTF8.GetBytes('${entropy}') + $protectedBytes = [System.Security.Cryptography.ProtectedData]::Protect( + $bytes, + $entropyBytes, + [System.Security.Cryptography.DataProtectionScope]::CurrentUser + ) + $protectedBase64 = [Convert]::ToBase64String($protectedBytes) + [System.IO.File]::WriteAllText( + $path, + $protectedBase64, + [System.Text.Encoding]::UTF8 + ) + } catch { + Write-Error $_.Exception.Message + exit 1 + } ` - try { - const result = execaSync('powershell.exe', ['-Command', script], { - reject: false, - }) - return { success: result.exitCode === 0 } - } catch { - return { success: false } + const result = runPowerShell(script, { input: payload }) + if (result?.exitCode === 0) { + return { success: true } + } + + return { + success: false, + warning: getFailureWarning( + result, + 'Windows secure storage could not encrypt credentials with DPAPI', + ), } }, delete(): boolean { - const resourceName = getSecureStorageServiceName( - CREDENTIALS_SERVICE_SUFFIX, - ).replace(/"/g, '`"') - const username = getUsername().replace(/"/g, '`"') - // PowerShell script to remove credential from vault - const script = ` - Add-Type -AssemblyName System.Runtime.WindowsRuntime - $vault = New-Object Windows.Security.Credentials.PasswordVault + const filePath = escapePowerShellSingleQuoted( + getWindowsSecureStorageFilePath(), + ) + const removeDpapiScript = ` try { + $path = '${filePath}' + if (Test-Path -LiteralPath $path) { + Remove-Item -LiteralPath $path -Force + } + } catch { + exit 1 + } + ` + const removeDpapiResult = runPowerShell(removeDpapiScript) + + const resourceName = getLegacyResourceName().replace(/"/g, '`"') + const username = getUsername().replace(/"/g, '`"') + const removeLegacyScript = ` + Add-Type -AssemblyName System.Runtime.WindowsRuntime + try { + $vault = New-Object Windows.Security.Credentials.PasswordVault $cred = $vault.Retrieve("${resourceName}", "${username}") $vault.Remove($cred) } catch { exit 0 } ` - try { - const result = execaSync('powershell.exe', ['-Command', script], { - reject: false, - }) - return result.exitCode === 0 - } catch { - return false - } + const removeLegacyResult = runPowerShell(removeLegacyScript) + + void removeLegacyResult + + return (removeDpapiResult?.exitCode ?? 1) === 0 }, }