feat: enhance Codex authentication and API key management

- Introduced a new method to check Codex authentication status, allowing for better handling of API keys and OAuth tokens.
- Updated API key management to include OpenAI, enabling users to manage their keys more effectively.
- Enhanced the CodexProvider to support session ID tracking and deduplication of text blocks in assistant messages.
- Improved error handling and logging in authentication routes, providing clearer feedback to users.

These changes improve the overall user experience and security of the Codex integration, ensuring smoother authentication processes and better management of API keys.
This commit is contained in:
DhanushSantosh
2026-01-07 22:49:30 +05:30
parent 2250367ddc
commit 24ea10e818
18 changed files with 837 additions and 61 deletions

View File

@@ -32,6 +32,7 @@ import {
supportsReasoningEffort,
type CodexApprovalPolicy,
type CodexSandboxMode,
type CodexAuthStatus,
} from '@automaker/types';
import { CodexConfigManager } from './codex-config-manager.js';
import { executeCodexSdkQuery } from './codex-sdk-client.js';
@@ -56,6 +57,7 @@ const CODEX_OUTPUT_SCHEMA_FLAG = '--output-schema';
const CODEX_CONFIG_FLAG = '--config';
const CODEX_IMAGE_FLAG = '--image';
const CODEX_ADD_DIR_FLAG = '--add-dir';
const CODEX_SKIP_GIT_REPO_CHECK_FLAG = '--skip-git-repo-check';
const CODEX_RESUME_FLAG = 'resume';
const CODEX_REASONING_EFFORT_KEY = 'reasoning_effort';
const OPENAI_API_KEY_ENV = 'OPENAI_API_KEY';
@@ -742,7 +744,7 @@ export class CodexProvider extends BaseProvider {
}
const configOverrides = buildConfigOverrides(overrides);
const globalArgs = [CODEX_APPROVAL_FLAG, approvalPolicy];
const globalArgs = [CODEX_SKIP_GIT_REPO_CHECK_FLAG, CODEX_APPROVAL_FLAG, approvalPolicy];
if (searchEnabled) {
globalArgs.push(CODEX_SEARCH_FLAG);
}
@@ -782,6 +784,12 @@ export class CodexProvider extends BaseProvider {
const event = rawEvent as Record<string, unknown>;
const eventType = getEventType(event);
// Track thread/session ID from events
const threadId = event.thread_id;
if (threadId && typeof threadId === 'string') {
this._lastSessionId = threadId;
}
if (eventType === CODEX_EVENT_TYPES.error) {
const errorText = extractText(event.error ?? event.message) || 'Codex CLI error';
@@ -985,4 +993,121 @@ export class CodexProvider extends BaseProvider {
// Return all available Codex/OpenAI models
return CODEX_MODELS;
}
/**
* Check authentication status for Codex CLI
*/
async checkAuth(): Promise<CodexAuthStatus> {
const cliPath = await findCodexCliPath();
const hasApiKey = !!process.env[OPENAI_API_KEY_ENV];
const authIndicators = await getCodexAuthIndicators();
// Check for API key in environment
if (hasApiKey) {
return { authenticated: true, method: 'api_key' };
}
// Check for OAuth/token from Codex CLI
if (authIndicators.hasOAuthToken || authIndicators.hasApiKey) {
return { authenticated: true, method: 'oauth' };
}
// CLI is installed but not authenticated
if (cliPath) {
try {
const result = await spawnProcess({
command: cliPath || CODEX_COMMAND,
args: ['auth', 'status', '--json'],
cwd: process.cwd(),
});
// If auth command succeeds, we're authenticated
if (result.exitCode === 0) {
return { authenticated: true, method: 'oauth' };
}
} catch {
// Auth command failed, not authenticated
}
}
return { authenticated: false, method: 'none' };
}
/**
* Deduplicate text blocks in Codex assistant messages
*
* Codex can send:
* 1. Duplicate consecutive text blocks (same text twice in a row)
* 2. A final accumulated block containing ALL previous text
*
* This method filters out these duplicates to prevent UI stuttering.
*/
private deduplicateTextBlocks(
content: Array<{ type: string; text?: string }>,
lastTextBlock: string,
accumulatedText: string
): { content: Array<{ type: string; text?: string }>; lastBlock: string; accumulated: string } {
const filtered: Array<{ type: string; text?: string }> = [];
let newLastBlock = lastTextBlock;
let newAccumulated = accumulatedText;
for (const block of content) {
if (block.type !== 'text' || !block.text) {
filtered.push(block);
continue;
}
const text = block.text;
// Skip empty text
if (!text.trim()) continue;
// Skip duplicate consecutive text blocks
if (text === newLastBlock) {
continue;
}
// Skip final accumulated text block
// Codex sends one large block containing ALL previous text at the end
if (newAccumulated.length > 100 && text.length > newAccumulated.length * 0.8) {
const normalizedAccum = newAccumulated.replace(/\s+/g, ' ').trim();
const normalizedNew = text.replace(/\s+/g, ' ').trim();
if (normalizedNew.includes(normalizedAccum.slice(0, 100))) {
// This is the final accumulated block, skip it
continue;
}
}
// This is a valid new text block
newLastBlock = text;
newAccumulated += text;
filtered.push(block);
}
return { content: filtered, lastBlock: newLastBlock, accumulated: newAccumulated };
}
/**
* Get the detected CLI path (public accessor for status endpoints)
*/
async getCliPath(): Promise<string | null> {
const path = await findCodexCliPath();
return path || null;
}
/**
* Get the last CLI session ID (for tracking across queries)
* This can be used to resume sessions in subsequent requests
*/
getLastSessionId(): string | null {
return this._lastSessionId ?? null;
}
/**
* Set a session ID to use for CLI session resumption
*/
setSessionId(sessionId: string | null): void {
this._lastSessionId = sessionId;
}
private _lastSessionId: string | null = null;
}

View File

@@ -16,6 +16,8 @@ const TOOL_NAME_WRITE = 'Write';
const TOOL_NAME_GREP = 'Grep';
const TOOL_NAME_GLOB = 'Glob';
const TOOL_NAME_TODO = 'TodoWrite';
const TOOL_NAME_DELETE = 'Delete';
const TOOL_NAME_LS = 'Ls';
const INPUT_KEY_COMMAND = 'command';
const INPUT_KEY_FILE_PATH = 'file_path';
@@ -37,6 +39,8 @@ const WRAPPER_COMMANDS = new Set(['sudo', 'env', 'command']);
const READ_COMMANDS = new Set(['cat', 'sed', 'head', 'tail', 'less', 'more', 'bat', 'stat', 'wc']);
const SEARCH_COMMANDS = new Set(['rg', 'grep', 'ag', 'ack']);
const GLOB_COMMANDS = new Set(['ls', 'find', 'fd', 'tree']);
const DELETE_COMMANDS = new Set(['rm', 'del', 'erase', 'remove', 'unlink']);
const LIST_COMMANDS = new Set(['ls', 'dir', 'll', 'la']);
const WRITE_COMMANDS = new Set(['tee', 'touch', 'mkdir']);
const APPLY_PATCH_COMMAND = 'apply_patch';
const APPLY_PATCH_PATTERN = /\bapply_patch\b/;
@@ -193,6 +197,18 @@ function extractRedirectionTarget(command: string): string | null {
return match?.[1] ?? null;
}
function extractFilePathFromDeleteTokens(tokens: string[]): string | null {
// rm file.txt or rm /path/to/file.txt
// Skip flags and get the first non-flag argument
for (let i = 1; i < tokens.length; i++) {
const token = tokens[i];
if (token && !token.startsWith('-')) {
return token;
}
}
return null;
}
function hasSedInPlaceFlag(tokens: string[]): boolean {
return tokens.some((token) => SED_IN_PLACE_FLAGS.has(token) || token.startsWith('-i'));
}
@@ -279,6 +295,41 @@ export function resolveCodexToolCall(command: string): CodexToolResolution {
};
}
// Handle Delete commands (rm, del, erase, remove, unlink)
if (DELETE_COMMANDS.has(commandToken)) {
// Skip if -r or -rf flags (recursive delete should go to Bash)
if (
tokens.some((token) => token === '-r' || token === '-rf' || token === '-f' || token === '-rf')
) {
return {
name: TOOL_NAME_BASH,
input: { [INPUT_KEY_COMMAND]: normalized },
};
}
// Simple file deletion - extract the file path
const filePath = extractFilePathFromDeleteTokens(tokens);
if (filePath) {
return {
name: TOOL_NAME_DELETE,
input: { path: filePath },
};
}
// Fall back to bash if we can't determine the file path
return {
name: TOOL_NAME_BASH,
input: { [INPUT_KEY_COMMAND]: normalized },
};
}
// Handle simple Ls commands (just listing, not find/glob)
if (LIST_COMMANDS.has(commandToken)) {
const filePath = extractFilePathFromTokens(tokens);
return {
name: TOOL_NAME_LS,
input: { path: filePath || '.' },
};
}
if (GLOB_COMMANDS.has(commandToken)) {
return {
name: TOOL_NAME_GLOB,

View File

@@ -173,12 +173,21 @@ export class ProviderFactory {
model.id === modelId ||
model.modelString === modelId ||
model.id.endsWith(`-${modelId}`) ||
model.modelString === modelId.replace(/^(claude|cursor|codex)-/, '')
model.modelString.endsWith(`-${modelId}`) ||
model.modelString === modelId.replace(/^(claude|cursor|codex)-/, '') ||
model.modelString === modelId.replace(/-(claude|cursor|codex)$/, '')
) {
return model.supportsVision ?? true;
}
}
// Also try exact match with model string from provider's model map
for (const model of models) {
if (model.modelString === modelId || model.id === modelId) {
return model.supportsVision ?? true;
}
}
// Default to true (Claude SDK supports vision by default)
return true;
}

View File

@@ -11,6 +11,7 @@ export function createApiKeysHandler() {
res.json({
success: true,
hasAnthropicKey: !!getApiKey('anthropic') || !!process.env.ANTHROPIC_API_KEY,
hasOpenaiKey: !!getApiKey('openai') || !!process.env.OPENAI_API_KEY,
});
} catch (error) {
logError(error, 'Get API keys failed');

View File

@@ -46,13 +46,14 @@ export function createDeleteApiKeyHandler() {
// Map provider to env key name
const envKeyMap: Record<string, string> = {
anthropic: 'ANTHROPIC_API_KEY',
openai: 'OPENAI_API_KEY',
};
const envKey = envKeyMap[provider];
if (!envKey) {
res.status(400).json({
success: false,
error: `Unknown provider: ${provider}. Only anthropic is supported.`,
error: `Unknown provider: ${provider}. Only anthropic and openai are supported.`,
});
return;
}

View File

@@ -82,7 +82,10 @@ function isRateLimitError(text: string): boolean {
export function createVerifyCodexAuthHandler() {
return async (req: Request, res: Response): Promise<void> => {
const { authMethod } = req.body as { authMethod?: 'cli' | 'api_key' };
const { authMethod, apiKey } = req.body as {
authMethod?: 'cli' | 'api_key';
apiKey?: string;
};
// Create session ID for cleanup
const sessionId = `codex-auth-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
@@ -105,21 +108,32 @@ export function createVerifyCodexAuthHandler() {
try {
// Create secure environment without modifying process.env
const authEnv = createSecureAuthEnv(authMethod || 'api_key', undefined, 'openai');
const authEnv = createSecureAuthEnv(authMethod || 'api_key', apiKey, 'openai');
// For API key auth, use stored key
// For API key auth, validate and use the provided key or stored key
if (authMethod === 'api_key') {
const storedApiKey = getApiKey('openai');
if (storedApiKey) {
const validation = validateApiKey(storedApiKey, 'openai');
if (apiKey) {
// Use the provided API key
const validation = validateApiKey(apiKey, 'openai');
if (!validation.isValid) {
res.json({ success: true, authenticated: false, error: validation.error });
return;
}
authEnv[OPENAI_API_KEY_ENV] = validation.normalizedKey;
} else if (!authEnv[OPENAI_API_KEY_ENV]) {
res.json({ success: true, authenticated: false, error: ERROR_API_KEY_REQUIRED });
return;
} else {
// Try stored key
const storedApiKey = getApiKey('openai');
if (storedApiKey) {
const validation = validateApiKey(storedApiKey, 'openai');
if (!validation.isValid) {
res.json({ success: true, authenticated: false, error: validation.error });
return;
}
authEnv[OPENAI_API_KEY_ENV] = validation.normalizedKey;
} else if (!authEnv[OPENAI_API_KEY_ENV]) {
res.json({ success: true, authenticated: false, error: ERROR_API_KEY_REQUIRED });
return;
}
}
}

View File

@@ -14,8 +14,15 @@ import { useNavigate } from '@tanstack/react-router';
export function ApiKeysSection() {
const { apiKeys, setApiKeys } = useAppStore();
const { claudeAuthStatus, setClaudeAuthStatus, setSetupComplete } = useSetupStore();
const {
claudeAuthStatus,
setClaudeAuthStatus,
codexAuthStatus,
setCodexAuthStatus,
setSetupComplete,
} = useSetupStore();
const [isDeletingAnthropicKey, setIsDeletingAnthropicKey] = useState(false);
const [isDeletingOpenaiKey, setIsDeletingOpenaiKey] = useState(false);
const navigate = useNavigate();
const { providerConfigParams, handleSave, saved } = useApiKeyManagement();
@@ -51,6 +58,34 @@ export function ApiKeysSection() {
}
}, [apiKeys, setApiKeys, claudeAuthStatus, setClaudeAuthStatus]);
// Delete OpenAI API key
const deleteOpenaiKey = useCallback(async () => {
setIsDeletingOpenaiKey(true);
try {
const api = getElectronAPI();
if (!api.setup?.deleteApiKey) {
toast.error('Delete API not available');
return;
}
const result = await api.setup.deleteApiKey('openai');
if (result.success) {
setApiKeys({ ...apiKeys, openai: '' });
setCodexAuthStatus({
authenticated: false,
method: 'none',
});
toast.success('OpenAI API key deleted');
} else {
toast.error(result.error || 'Failed to delete API key');
}
} catch (error) {
toast.error('Failed to delete API key');
} finally {
setIsDeletingOpenaiKey(false);
}
}, [apiKeys, setApiKeys, setCodexAuthStatus]);
// Open setup wizard
const openSetupWizard = useCallback(() => {
setSetupComplete(false);
@@ -137,6 +172,23 @@ export function ApiKeysSection() {
Delete Anthropic Key
</Button>
)}
{apiKeys.openai && (
<Button
onClick={deleteOpenaiKey}
disabled={isDeletingOpenaiKey}
variant="outline"
className="h-10 border-red-500/30 text-red-500 hover:bg-red-500/10 hover:border-red-500/50"
data-testid="delete-openai-key"
>
{isDeletingOpenaiKey ? (
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
) : (
<Trash2 className="w-4 h-4 mr-2" />
)}
Delete OpenAI Key
</Button>
)}
</div>
</div>
</div>

View File

@@ -15,6 +15,7 @@ interface TestResult {
interface ApiKeyStatus {
hasAnthropicKey: boolean;
hasGoogleKey: boolean;
hasOpenaiKey: boolean;
}
/**
@@ -27,16 +28,20 @@ export function useApiKeyManagement() {
// API key values
const [anthropicKey, setAnthropicKey] = useState(apiKeys.anthropic);
const [googleKey, setGoogleKey] = useState(apiKeys.google);
const [openaiKey, setOpenaiKey] = useState(apiKeys.openai);
// Visibility toggles
const [showAnthropicKey, setShowAnthropicKey] = useState(false);
const [showGoogleKey, setShowGoogleKey] = useState(false);
const [showOpenaiKey, setShowOpenaiKey] = useState(false);
// Test connection states
const [testingConnection, setTestingConnection] = useState(false);
const [testResult, setTestResult] = useState<TestResult | null>(null);
const [testingGeminiConnection, setTestingGeminiConnection] = useState(false);
const [geminiTestResult, setGeminiTestResult] = useState<TestResult | null>(null);
const [testingOpenaiConnection, setTestingOpenaiConnection] = useState(false);
const [openaiTestResult, setOpenaiTestResult] = useState<TestResult | null>(null);
// API key status from environment
const [apiKeyStatus, setApiKeyStatus] = useState<ApiKeyStatus | null>(null);
@@ -48,6 +53,7 @@ export function useApiKeyManagement() {
useEffect(() => {
setAnthropicKey(apiKeys.anthropic);
setGoogleKey(apiKeys.google);
setOpenaiKey(apiKeys.openai);
}, [apiKeys]);
// Check API key status from environment on mount
@@ -61,6 +67,7 @@ export function useApiKeyManagement() {
setApiKeyStatus({
hasAnthropicKey: status.hasAnthropicKey,
hasGoogleKey: status.hasGoogleKey,
hasOpenaiKey: status.hasOpenaiKey,
});
}
} catch (error) {
@@ -136,11 +143,42 @@ export function useApiKeyManagement() {
setTestingGeminiConnection(false);
};
// Test OpenAI/Codex connection
const handleTestOpenaiConnection = async () => {
setTestingOpenaiConnection(true);
setOpenaiTestResult(null);
try {
const api = getElectronAPI();
const data = await api.setup.verifyCodexAuth('api_key', openaiKey);
if (data.success && data.authenticated) {
setOpenaiTestResult({
success: true,
message: 'Connection successful! Codex responded.',
});
} else {
setOpenaiTestResult({
success: false,
message: data.error || 'Failed to connect to OpenAI API.',
});
}
} catch {
setOpenaiTestResult({
success: false,
message: 'Network error. Please check your connection.',
});
} finally {
setTestingOpenaiConnection(false);
}
};
// Save API keys
const handleSave = () => {
setApiKeys({
anthropic: anthropicKey,
google: googleKey,
openai: openaiKey,
});
setSaved(true);
setTimeout(() => setSaved(false), 2000);
@@ -167,6 +205,15 @@ export function useApiKeyManagement() {
onTest: handleTestGeminiConnection,
result: geminiTestResult,
},
openai: {
value: openaiKey,
setValue: setOpenaiKey,
show: showOpenaiKey,
setShow: setShowOpenaiKey,
testing: testingOpenaiConnection,
onTest: handleTestOpenaiConnection,
result: openaiTestResult,
},
};
return {

View File

@@ -0,0 +1,183 @@
import { Label } from '@/components/ui/label';
import { Badge } from '@/components/ui/badge';
import { Checkbox } from '@/components/ui/checkbox';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { Cpu } from 'lucide-react';
import { cn } from '@/lib/utils';
import type { CodexModelId } from '@automaker/types';
import { CODEX_MODEL_MAP } from '@automaker/types';
import { OpenAIIcon } from '@/components/ui/provider-icon';
interface CodexModelConfigurationProps {
enabledCodexModels: CodexModelId[];
codexDefaultModel: CodexModelId;
isSaving: boolean;
onDefaultModelChange: (model: CodexModelId) => void;
onModelToggle: (model: CodexModelId, enabled: boolean) => void;
}
interface CodexModelInfo {
id: CodexModelId;
label: string;
description: string;
}
const CODEX_MODEL_INFO: Record<CodexModelId, CodexModelInfo> = {
'gpt-5.2-codex': {
id: 'gpt-5.2-codex',
label: 'GPT-5.2-Codex',
description: 'Most advanced agentic coding model for complex software engineering',
},
'gpt-5-codex': {
id: 'gpt-5-codex',
label: 'GPT-5-Codex',
description: 'Purpose-built for Codex CLI with versatile tool use',
},
'gpt-5-codex-mini': {
id: 'gpt-5-codex-mini',
label: 'GPT-5-Codex-Mini',
description: 'Faster workflows optimized for low-latency code Q&A and editing',
},
'codex-1': {
id: 'codex-1',
label: 'Codex-1',
description: 'Version of o3 optimized for software engineering',
},
'codex-mini-latest': {
id: 'codex-mini-latest',
label: 'Codex-Mini-Latest',
description: 'Version of o4-mini for Codex, optimized for faster workflows',
},
'gpt-5': {
id: 'gpt-5',
label: 'GPT-5',
description: 'GPT-5 base flagship model',
},
};
export function CodexModelConfiguration({
enabledCodexModels,
codexDefaultModel,
isSaving,
onDefaultModelChange,
onModelToggle,
}: CodexModelConfigurationProps) {
const availableModels = Object.values(CODEX_MODEL_INFO);
return (
<div
className={cn(
'rounded-2xl overflow-hidden',
'border border-border/50',
'bg-gradient-to-br from-card/90 via-card/70 to-card/80 backdrop-blur-xl',
'shadow-sm shadow-black/5'
)}
>
<div className="p-6 border-b border-border/50 bg-gradient-to-r from-transparent via-accent/5 to-transparent">
<div className="flex items-center gap-3 mb-2">
<div className="w-9 h-9 rounded-xl bg-gradient-to-br from-brand-500/20 to-brand-600/10 flex items-center justify-center border border-brand-500/20">
<OpenAIIcon className="w-5 h-5 text-brand-500" />
</div>
<h2 className="text-lg font-semibold text-foreground tracking-tight">
Model Configuration
</h2>
</div>
<p className="text-sm text-muted-foreground/80 ml-12">
Configure which Codex models are available in the feature modal
</p>
</div>
<div className="p-6 space-y-6">
<div className="space-y-2">
<Label>Default Model</Label>
<Select
value={codexDefaultModel}
onValueChange={(v) => onDefaultModelChange(v as CodexModelId)}
disabled={isSaving}
>
<SelectTrigger className="w-full">
<SelectValue />
</SelectTrigger>
<SelectContent>
{availableModels.map((model) => (
<SelectItem key={model.id} value={model.id}>
<div className="flex items-center gap-2">
<span>{model.label}</span>
{supportsReasoningEffort(model.id) && (
<Badge variant="outline" className="text-xs">
Thinking
</Badge>
)}
</div>
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-3">
<Label>Available Models</Label>
<div className="grid gap-3">
{availableModels.map((model) => {
const isEnabled = enabledCodexModels.includes(model.id);
const isDefault = model.id === codexDefaultModel;
return (
<div
key={model.id}
className="flex items-center justify-between p-3 rounded-xl border border-border/50 bg-card/50 hover:bg-accent/30 transition-colors"
>
<div className="flex items-center gap-3">
<Checkbox
checked={isEnabled}
onCheckedChange={(checked) => onModelToggle(model.id, !!checked)}
disabled={isSaving || isDefault}
/>
<div>
<div className="flex items-center gap-2">
<span className="text-sm font-medium">{model.label}</span>
{supportsReasoningEffort(model.id) && (
<Badge variant="outline" className="text-xs">
Thinking
</Badge>
)}
{isDefault && (
<Badge variant="secondary" className="text-xs">
Default
</Badge>
)}
</div>
<p className="text-xs text-muted-foreground">{model.description}</p>
</div>
</div>
</div>
);
})}
</div>
</div>
</div>
</div>
);
}
function getModelDisplayName(modelId: string): string {
const displayNames: Record<string, string> = {
'gpt-5.2-codex': 'GPT-5.2-Codex',
'gpt-5-codex': 'GPT-5-Codex',
'gpt-5-codex-mini': 'GPT-5-Codex-Mini',
'codex-1': 'Codex-1',
'codex-mini-latest': 'Codex-Mini-Latest',
'gpt-5': 'GPT-5',
};
return displayNames[modelId] || modelId;
}
function supportsReasoningEffort(modelId: string): boolean {
const reasoningModels = ['gpt-5.2-codex', 'gpt-5-codex', 'gpt-5', 'codex-1'];
return reasoningModels.includes(modelId);
}

View File

@@ -1,27 +1,35 @@
import { useState, useCallback } from 'react';
import { useState, useCallback, useEffect } from 'react';
import { useAppStore } from '@/store/app-store';
import { useSetupStore } from '@/store/setup-store';
import { CodexCliStatus } from '../cli-status/codex-cli-status';
import { CodexSettings } from '../codex/codex-settings';
import { CodexUsageSection } from '../codex/codex-usage-section';
import { Info } from 'lucide-react';
import { CodexModelConfiguration } from './codex-model-configuration';
import { getElectronAPI } from '@/lib/electron';
import { createLogger } from '@automaker/utils/logger';
import type { CliStatus as SharedCliStatus } from '../shared/types';
import type { CodexModelId } from '@automaker/types';
const logger = createLogger('CodexSettings');
export function CodexSettingsTab() {
// TODO: Add these to app-store
const [codexAutoLoadAgents, setCodexAutoLoadAgents] = useState(false);
const [codexSandboxMode, setCodexSandboxMode] = useState<
'read-only' | 'workspace-write' | 'danger-full-access'
>('read-only');
const [codexApprovalPolicy, setCodexApprovalPolicy] = useState<
'untrusted' | 'on-failure' | 'on-request' | 'never'
>('untrusted');
const [codexEnableWebSearch, setCodexEnableWebSearch] = useState(false);
const [codexEnableImages, setCodexEnableImages] = useState(false);
const {
codexAutoLoadAgents,
codexSandboxMode,
codexApprovalPolicy,
codexEnableWebSearch,
codexEnableImages,
enabledCodexModels,
codexDefaultModel,
setCodexAutoLoadAgents,
setCodexSandboxMode,
setCodexApprovalPolicy,
setCodexEnableWebSearch,
setCodexEnableImages,
setEnabledCodexModels,
setCodexDefaultModel,
toggleCodexModel,
} = useAppStore();
const {
codexAuthStatus,
@@ -32,8 +40,8 @@ export function CodexSettingsTab() {
const [isCheckingCodexCli, setIsCheckingCodexCli] = useState(false);
const [displayCliStatus, setDisplayCliStatus] = useState<SharedCliStatus | null>(null);
const [isSaving, setIsSaving] = useState(false);
// Convert setup-store CliStatus to shared/types CliStatus for display
const codexCliStatus: SharedCliStatus | null =
displayCliStatus ||
(setupCliStatus
@@ -46,28 +54,28 @@ export function CodexSettingsTab() {
}
: null);
const handleRefreshCodexCli = useCallback(async () => {
setIsCheckingCodexCli(true);
try {
// Load Codex CLI status on mount
useEffect(() => {
const checkCodexStatus = async () => {
const api = getElectronAPI();
if (api?.setup?.getCodexStatus) {
const result = await api.setup.getCodexStatus();
if (result.success) {
// Update setup store
try {
const result = await api.setup.getCodexStatus();
setDisplayCliStatus({
success: result.success,
status: result.installed ? 'installed' : 'not_installed',
method: result.auth?.method,
version: result.version,
path: result.path,
recommendation: result.recommendation,
installCommands: result.installCommands,
});
setCodexCliStatus({
installed: result.installed,
version: result.version,
path: result.path,
method: result.auth?.method || 'none',
});
// Update display status
setDisplayCliStatus({
success: true,
status: result.installed ? 'installed' : 'not_installed',
method: result.auth?.method,
version: result.version || undefined,
path: result.path || undefined,
});
if (result.auth) {
setCodexAuthStatus({
authenticated: result.auth.authenticated,
@@ -80,6 +88,42 @@ export function CodexSettingsTab() {
hasApiKey: result.auth.hasApiKey,
});
}
} catch (error) {
logger.error('Failed to check Codex CLI status:', error);
}
}
};
checkCodexStatus();
}, [setCodexCliStatus, setCodexAuthStatus]);
const handleRefreshCodexCli = useCallback(async () => {
setIsCheckingCodexCli(true);
try {
const api = getElectronAPI();
if (api?.setup?.getCodexStatus) {
const result = await api.setup.getCodexStatus();
setDisplayCliStatus({
success: result.success,
status: result.installed ? 'installed' : 'not_installed',
method: result.auth?.method,
version: result.version,
path: result.path,
recommendation: result.recommendation,
installCommands: result.installCommands,
});
setCodexCliStatus({
installed: result.installed,
version: result.version,
path: result.path,
method: result.auth?.method || 'none',
});
if (result.auth) {
setCodexAuthStatus({
authenticated: result.auth.authenticated,
method: result.auth.method as 'cli_authenticated' | 'api_key' | 'api_key_env' | 'none',
hasAuthFile: result.auth.method === 'cli_authenticated',
hasApiKey: result.auth.hasApiKey,
});
}
}
} catch (error) {
@@ -89,27 +133,50 @@ export function CodexSettingsTab() {
}
}, [setCodexCliStatus, setCodexAuthStatus]);
// Show usage tracking when CLI is authenticated
const handleDefaultModelChange = useCallback(
(model: CodexModelId) => {
setIsSaving(true);
try {
setCodexDefaultModel(model);
} finally {
setIsSaving(false);
}
},
[setCodexDefaultModel]
);
const handleModelToggle = useCallback(
(model: CodexModelId, enabled: boolean) => {
setIsSaving(true);
try {
toggleCodexModel(model, enabled);
} finally {
setIsSaving(false);
}
},
[toggleCodexModel]
);
const showUsageTracking = codexAuthStatus?.authenticated ?? false;
return (
<div className="space-y-6">
{/* Usage Info */}
<div className="flex items-start gap-3 p-4 rounded-xl bg-emerald-500/10 border border-emerald-500/20">
<Info className="w-5 h-5 text-emerald-400 shrink-0 mt-0.5" />
<div className="text-sm text-emerald-400/90">
<span className="font-medium">OpenAI via Codex CLI</span>
<p className="text-xs text-emerald-400/70 mt-1">
Access GPT models with tool support for advanced coding workflows.
</p>
</div>
</div>
<CodexCliStatus
status={codexCliStatus}
isChecking={isCheckingCodexCli}
onRefresh={handleRefreshCodexCli}
/>
{showUsageTracking && <CodexUsageSection />}
<CodexModelConfiguration
enabledCodexModels={enabledCodexModels}
codexDefaultModel={codexDefaultModel}
isSaving={isSaving}
onDefaultModelChange={handleDefaultModelChange}
onModelToggle={handleModelToggle}
/>
<CodexSettings
autoLoadCodexAgents={codexAutoLoadAgents}
codexSandboxMode={codexSandboxMode}
@@ -122,7 +189,6 @@ export function CodexSettingsTab() {
onCodexEnableWebSearchChange={setCodexEnableWebSearch}
onCodexEnableImagesChange={setCodexEnableImages}
/>
{showUsageTracking && <CodexUsageSection />}
</div>
);
}

View File

@@ -75,7 +75,10 @@ interface CliSetupConfig {
buildClearedAuthStatus: (previous: CliSetupAuthStatus | null) => CliSetupAuthStatus;
statusApi: () => Promise<any>;
installApi: () => Promise<any>;
verifyAuthApi: (method: 'cli' | 'api_key') => Promise<{
verifyAuthApi: (
method: 'cli' | 'api_key',
apiKey?: string
) => Promise<{
success: boolean;
authenticated: boolean;
error?: string;
@@ -194,7 +197,7 @@ export function CliSetupStep({ config, state, onNext, onBack, onSkip }: CliSetup
setApiKeyVerificationError(null);
try {
const result = await config.verifyAuthApi('api_key');
const result = await config.verifyAuthApi('api_key', apiKey);
const hasLimitOrBillingError =
result.error?.toLowerCase().includes('limit reached') ||

View File

@@ -31,8 +31,8 @@ export function CodexSetupStep({ onNext, onBack, onSkip }: CodexSetupStepProps)
);
const verifyAuthApi = useCallback(
(method: 'cli' | 'api_key') =>
getElectronAPI().setup?.verifyCodexAuth(method) || Promise.reject(),
(method: 'cli' | 'api_key', apiKey?: string) =>
getElectronAPI().setup?.verifyCodexAuth(method, apiKey) || Promise.reject(),
[]
);

View File

@@ -50,11 +50,21 @@ export interface ProviderConfigParams {
onTest: () => Promise<void>;
result: { success: boolean; message: string } | null;
};
openai: {
value: string;
setValue: Dispatch<SetStateAction<string>>;
show: boolean;
setShow: Dispatch<SetStateAction<boolean>>;
testing: boolean;
onTest: () => Promise<void>;
result: { success: boolean; message: string } | null;
};
}
export const buildProviderConfigs = ({
apiKeys,
anthropic,
openai,
}: ProviderConfigParams): ProviderConfig[] => [
{
key: 'anthropic',
@@ -82,6 +92,32 @@ export const buildProviderConfigs = ({
descriptionLinkText: 'console.anthropic.com',
descriptionSuffix: '.',
},
{
key: 'openai',
label: 'OpenAI API Key',
inputId: 'openai-key',
placeholder: 'sk-...',
value: openai.value,
setValue: openai.setValue,
showValue: openai.show,
setShowValue: openai.setShow,
hasStoredKey: apiKeys.openai,
inputTestId: 'openai-api-key-input',
toggleTestId: 'toggle-openai-visibility',
testButton: {
onClick: openai.onTest,
disabled: !openai.value || openai.testing,
loading: openai.testing,
testId: 'test-openai-connection',
},
result: openai.result,
resultTestId: 'openai-test-connection-result',
resultMessageTestId: 'openai-test-connection-message',
descriptionPrefix: 'Used for Codex and OpenAI features. Get your key at',
descriptionLinkHref: 'https://platform.openai.com/api-keys',
descriptionLinkText: 'platform.openai.com',
descriptionSuffix: '.',
},
// {
// key: "google",
// label: "Google API Key (Gemini)",

View File

@@ -1220,12 +1220,13 @@ export class HttpApiClient implements ElectronAPI {
}> => this.post('/api/setup/auth-codex'),
verifyCodexAuth: (
authMethod?: 'cli' | 'api_key'
authMethod: 'cli' | 'api_key',
apiKey?: string
): Promise<{
success: boolean;
authenticated: boolean;
error?: string;
}> => this.post('/api/setup/verify-codex-auth', { authMethod }),
}> => this.post('/api/setup/verify-codex-auth', { authMethod, apiKey }),
onInstallProgress: (callback: (progress: unknown) => void) => {
return this.subscribeToEvent('agent:stream', callback);

View File

@@ -11,6 +11,7 @@ import type {
ModelProvider,
AIProfile,
CursorModelId,
CodexModelId,
PhaseModelConfig,
PhaseModelKey,
PhaseModelEntry,
@@ -20,7 +21,7 @@ import type {
PipelineStep,
PromptCustomization,
} from '@automaker/types';
import { getAllCursorModelIds, DEFAULT_PHASE_MODELS } from '@automaker/types';
import { getAllCursorModelIds, getAllCodexModelIds, DEFAULT_PHASE_MODELS } from '@automaker/types';
// Re-export types for convenience
export type {
@@ -515,6 +516,15 @@ export interface AppState {
enabledCursorModels: CursorModelId[]; // Which Cursor models are available in feature modal
cursorDefaultModel: CursorModelId; // Default Cursor model selection
// Codex CLI Settings (global)
enabledCodexModels: CodexModelId[]; // Which Codex models are available in feature modal
codexDefaultModel: CodexModelId; // Default Codex model selection
codexAutoLoadAgents: boolean; // Auto-load .codex/AGENTS.md files
codexSandboxMode: 'read-only' | 'workspace-write' | 'danger-full-access'; // Sandbox policy
codexApprovalPolicy: 'untrusted' | 'on-failure' | 'on-request' | 'never'; // Approval policy
codexEnableWebSearch: boolean; // Enable web search capability
codexEnableImages: boolean; // Enable image processing
// Claude Agent SDK Settings
autoLoadClaudeMd: boolean; // Auto-load CLAUDE.md files using SDK's settingSources option
enableSandboxMode: boolean; // Enable sandbox mode for bash commands (may cause issues on some systems)
@@ -852,6 +862,20 @@ export interface AppActions {
setCursorDefaultModel: (model: CursorModelId) => void;
toggleCursorModel: (model: CursorModelId, enabled: boolean) => void;
// Codex CLI Settings actions
setEnabledCodexModels: (models: CodexModelId[]) => void;
setCodexDefaultModel: (model: CodexModelId) => void;
toggleCodexModel: (model: CodexModelId, enabled: boolean) => void;
setCodexAutoLoadAgents: (enabled: boolean) => Promise<void>;
setCodexSandboxMode: (
mode: 'read-only' | 'workspace-write' | 'danger-full-access'
) => Promise<void>;
setCodexApprovalPolicy: (
policy: 'untrusted' | 'on-failure' | 'on-request' | 'never'
) => Promise<void>;
setCodexEnableWebSearch: (enabled: boolean) => Promise<void>;
setCodexEnableImages: (enabled: boolean) => Promise<void>;
// Claude Agent SDK Settings actions
setAutoLoadClaudeMd: (enabled: boolean) => Promise<void>;
setEnableSandboxMode: (enabled: boolean) => Promise<void>;
@@ -1076,6 +1100,13 @@ const initialState: AppState = {
favoriteModels: [],
enabledCursorModels: getAllCursorModelIds(), // All Cursor models enabled by default
cursorDefaultModel: 'auto', // Default to auto selection
enabledCodexModels: getAllCodexModelIds(), // All Codex models enabled by default
codexDefaultModel: 'gpt-5.2-codex', // Default to GPT-5.2-Codex
codexAutoLoadAgents: false, // Default to disabled (user must opt-in)
codexSandboxMode: 'workspace-write', // Default to workspace-write for safety
codexApprovalPolicy: 'on-request', // Default to on-request for balanced safety
codexEnableWebSearch: false, // Default to disabled
codexEnableImages: false, // Default to disabled
autoLoadClaudeMd: false, // Default to disabled (user must opt-in)
enableSandboxMode: false, // Default to disabled (can be enabled for additional security)
skipSandboxWarning: false, // Default to disabled (show sandbox warning dialog)
@@ -1761,6 +1792,41 @@ export const useAppStore = create<AppState & AppActions>()(
: state.enabledCursorModels.filter((m) => m !== model),
})),
// Codex CLI Settings actions
setEnabledCodexModels: (models) => set({ enabledCodexModels: models }),
setCodexDefaultModel: (model) => set({ codexDefaultModel: model }),
toggleCodexModel: (model, enabled) =>
set((state) => ({
enabledCodexModels: enabled
? [...state.enabledCodexModels, model]
: state.enabledCodexModels.filter((m) => m !== model),
})),
setCodexAutoLoadAgents: async (enabled) => {
set({ codexAutoLoadAgents: enabled });
const { syncSettingsToServer } = await import('@/hooks/use-settings-migration');
await syncSettingsToServer();
},
setCodexSandboxMode: async (mode) => {
set({ codexSandboxMode: mode });
const { syncSettingsToServer } = await import('@/hooks/use-settings-migration');
await syncSettingsToServer();
},
setCodexApprovalPolicy: async (policy) => {
set({ codexApprovalPolicy: policy });
const { syncSettingsToServer } = await import('@/hooks/use-settings-migration');
await syncSettingsToServer();
},
setCodexEnableWebSearch: async (enabled) => {
set({ codexEnableWebSearch: enabled });
const { syncSettingsToServer } = await import('@/hooks/use-settings-migration');
await syncSettingsToServer();
},
setCodexEnableImages: async (enabled) => {
set({ codexEnableImages: enabled });
const { syncSettingsToServer } = await import('@/hooks/use-settings-migration');
await syncSettingsToServer();
},
// Claude Agent SDK Settings actions
setAutoLoadClaudeMd: async (enabled) => {
set({ autoLoadClaudeMd: enabled });
@@ -3073,6 +3139,13 @@ export const useAppStore = create<AppState & AppActions>()(
phaseModels: state.phaseModels,
enabledCursorModels: state.enabledCursorModels,
cursorDefaultModel: state.cursorDefaultModel,
enabledCodexModels: state.enabledCodexModels,
codexDefaultModel: state.codexDefaultModel,
codexAutoLoadAgents: state.codexAutoLoadAgents,
codexSandboxMode: state.codexSandboxMode,
codexApprovalPolicy: state.codexApprovalPolicy,
codexEnableWebSearch: state.codexEnableWebSearch,
codexEnableImages: state.codexEnableImages,
autoLoadClaudeMd: state.autoLoadClaudeMd,
enableSandboxMode: state.enableSandboxMode,
skipSandboxWarning: state.skipSandboxWarning,

View File

@@ -0,0 +1,100 @@
/**
* Codex CLI Model IDs
* Based on OpenAI Codex CLI official models
* Reference: https://developers.openai.com/codex/models/
*/
export type CodexModelId =
| 'gpt-5.2-codex' // Most advanced agentic coding model for complex software engineering
| 'gpt-5-codex' // Purpose-built for Codex CLI with versatile tool use
| 'gpt-5-codex-mini' // Faster workflows optimized for low-latency code Q&A and editing
| 'codex-1' // Version of o3 optimized for software engineering
| 'codex-mini-latest' // Version of o4-mini for Codex, optimized for faster workflows
| 'gpt-5'; // GPT-5 base flagship model
/**
* Codex model metadata
*/
export interface CodexModelConfig {
id: CodexModelId;
label: string;
description: string;
hasThinking: boolean;
/** Whether the model supports vision/image inputs */
supportsVision: boolean;
}
/**
* Complete model map for Codex CLI
*/
export const CODEX_MODEL_CONFIG_MAP: Record<CodexModelId, CodexModelConfig> = {
'gpt-5.2-codex': {
id: 'gpt-5.2-codex',
label: 'GPT-5.2-Codex',
description: 'Most advanced agentic coding model for complex software engineering',
hasThinking: true,
supportsVision: true, // GPT-5 supports vision
},
'gpt-5-codex': {
id: 'gpt-5-codex',
label: 'GPT-5-Codex',
description: 'Purpose-built for Codex CLI with versatile tool use',
hasThinking: true,
supportsVision: true,
},
'gpt-5-codex-mini': {
id: 'gpt-5-codex-mini',
label: 'GPT-5-Codex-Mini',
description: 'Faster workflows optimized for low-latency code Q&A and editing',
hasThinking: false,
supportsVision: true,
},
'codex-1': {
id: 'codex-1',
label: 'Codex-1',
description: 'Version of o3 optimized for software engineering',
hasThinking: true,
supportsVision: true,
},
'codex-mini-latest': {
id: 'codex-mini-latest',
label: 'Codex-Mini-Latest',
description: 'Version of o4-mini for Codex, optimized for faster workflows',
hasThinking: false,
supportsVision: true,
},
'gpt-5': {
id: 'gpt-5',
label: 'GPT-5',
description: 'GPT-5 base flagship model',
hasThinking: true,
supportsVision: true,
},
};
/**
* Helper: Check if model has thinking capability
*/
export function codexModelHasThinking(modelId: CodexModelId): boolean {
return CODEX_MODEL_CONFIG_MAP[modelId]?.hasThinking ?? false;
}
/**
* Helper: Get display name for model
*/
export function getCodexModelLabel(modelId: CodexModelId): string {
return CODEX_MODEL_CONFIG_MAP[modelId]?.label ?? modelId;
}
/**
* Helper: Get all Codex model IDs
*/
export function getAllCodexModelIds(): CodexModelId[] {
return Object.keys(CODEX_MODEL_CONFIG_MAP) as CodexModelId[];
}
/**
* Helper: Check if Codex model supports vision
*/
export function codexModelSupportsVision(modelId: CodexModelId): boolean {
return CODEX_MODEL_CONFIG_MAP[modelId]?.supportsVision ?? true;
}

View File

@@ -42,3 +42,11 @@ export interface CodexCliConfig {
/** List of enabled models */
models?: string[];
}
/** Codex authentication status */
export interface CodexAuthStatus {
authenticated: boolean;
method: 'oauth' | 'api_key' | 'none';
hasCredentialsFile?: boolean;
error?: string;
}

View File

@@ -21,7 +21,13 @@ export type {
} from './provider.js';
// Codex CLI types
export type { CodexSandboxMode, CodexApprovalPolicy, CodexCliConfig } from './codex.js';
export type {
CodexSandboxMode,
CodexApprovalPolicy,
CodexCliConfig,
CodexAuthStatus,
} from './codex.js';
export * from './codex-models.js';
// Feature types
export type { Feature, FeatureImagePath, FeatureTextFilePath, FeatureStatus } from './feature.js';