feat: implement Codex CLI authentication check and integrate with provider

- Added a new utility for checking Codex CLI authentication status using the 'codex login status' command.
- Integrated the authentication check into the CodexProvider's installation detection and authentication methods.
- Updated Codex CLI status display in the UI to reflect authentication status and method.
- Enhanced error handling and logging for better debugging during authentication checks.
- Refactored related components to ensure consistent handling of authentication across the application.
This commit is contained in:
webdevcody
2026-01-07 21:06:39 -05:00
parent 47c2d795e0
commit 8c68c24716
16 changed files with 718 additions and 169 deletions

View File

@@ -0,0 +1,98 @@
/**
* Shared utility for checking Codex CLI authentication status
*
* Uses 'codex login status' command to verify authentication.
* Never assumes authenticated - only returns true if CLI confirms.
*/
import { spawnProcess, getCodexAuthPath } from '@automaker/platform';
import { findCodexCliPath } from '@automaker/platform';
import * as fs from 'fs';
const CODEX_COMMAND = 'codex';
const OPENAI_API_KEY_ENV = 'OPENAI_API_KEY';
export interface CodexAuthCheckResult {
authenticated: boolean;
method: 'api_key_env' | 'cli_authenticated' | 'none';
}
/**
* Check Codex authentication status using 'codex login status' command
*
* @param cliPath Optional CLI path. If not provided, will attempt to find it.
* @returns Authentication status and method
*/
export async function checkCodexAuthentication(
cliPath?: string | null
): Promise<CodexAuthCheckResult> {
console.log('[CodexAuth] checkCodexAuthentication called with cliPath:', cliPath);
const resolvedCliPath = cliPath || (await findCodexCliPath());
const hasApiKey = !!process.env[OPENAI_API_KEY_ENV];
console.log('[CodexAuth] resolvedCliPath:', resolvedCliPath);
console.log('[CodexAuth] hasApiKey:', hasApiKey);
// Debug: Check auth file
const authFilePath = getCodexAuthPath();
console.log('[CodexAuth] Auth file path:', authFilePath);
try {
const authFileExists = fs.existsSync(authFilePath);
console.log('[CodexAuth] Auth file exists:', authFileExists);
if (authFileExists) {
const authContent = fs.readFileSync(authFilePath, 'utf-8');
console.log('[CodexAuth] Auth file content:', authContent.substring(0, 500)); // First 500 chars
}
} catch (error) {
console.log('[CodexAuth] Error reading auth file:', error);
}
// If CLI is not installed, cannot be authenticated
if (!resolvedCliPath) {
console.log('[CodexAuth] No CLI path found, returning not authenticated');
return { authenticated: false, method: 'none' };
}
try {
console.log('[CodexAuth] Running: ' + resolvedCliPath + ' login status');
const result = await spawnProcess({
command: resolvedCliPath || CODEX_COMMAND,
args: ['login', 'status'],
cwd: process.cwd(),
env: {
...process.env,
TERM: 'dumb', // Avoid interactive output
},
});
console.log('[CodexAuth] Command result:');
console.log('[CodexAuth] exitCode:', result.exitCode);
console.log('[CodexAuth] stdout:', JSON.stringify(result.stdout));
console.log('[CodexAuth] stderr:', JSON.stringify(result.stderr));
// Check both stdout and stderr for "logged in" - Codex CLI outputs to stderr
const combinedOutput = (result.stdout + result.stderr).toLowerCase();
const isLoggedIn = combinedOutput.includes('logged in');
console.log('[CodexAuth] isLoggedIn (contains "logged in" in stdout or stderr):', isLoggedIn);
if (result.exitCode === 0 && isLoggedIn) {
// Determine auth method based on what we know
const method = hasApiKey ? 'api_key_env' : 'cli_authenticated';
console.log('[CodexAuth] Authenticated! method:', method);
return { authenticated: true, method };
}
console.log(
'[CodexAuth] Not authenticated. exitCode:',
result.exitCode,
'isLoggedIn:',
isLoggedIn
);
} catch (error) {
console.log('[CodexAuth] Error running command:', error);
}
console.log('[CodexAuth] Returning not authenticated');
return { authenticated: false, method: 'none' };
}

View File

@@ -15,6 +15,7 @@ import {
getDataDirectory,
getCodexConfigDir,
} from '@automaker/platform';
import { checkCodexAuthentication } from '../lib/codex-auth.js';
import {
formatHistoryAsText,
extractTextFromContent,
@@ -963,11 +964,21 @@ export class CodexProvider extends BaseProvider {
}
async detectInstallation(): Promise<InstallationStatus> {
console.log('[CodexProvider.detectInstallation] Starting...');
const cliPath = await findCodexCliPath();
const hasApiKey = !!process.env[OPENAI_API_KEY_ENV];
const authIndicators = await getCodexAuthIndicators();
const installed = !!cliPath;
console.log('[CodexProvider.detectInstallation] cliPath:', cliPath);
console.log('[CodexProvider.detectInstallation] hasApiKey:', hasApiKey);
console.log(
'[CodexProvider.detectInstallation] authIndicators:',
JSON.stringify(authIndicators)
);
console.log('[CodexProvider.detectInstallation] installed:', installed);
let version = '';
if (installed) {
try {
@@ -977,19 +988,29 @@ export class CodexProvider extends BaseProvider {
cwd: process.cwd(),
});
version = result.stdout.trim();
} catch {
console.log('[CodexProvider.detectInstallation] version:', version);
} catch (error) {
console.log('[CodexProvider.detectInstallation] Error getting version:', error);
version = '';
}
}
return {
// Determine auth status - always verify with CLI, never assume authenticated
console.log('[CodexProvider.detectInstallation] Calling checkCodexAuthentication...');
const authCheck = await checkCodexAuthentication(cliPath);
console.log('[CodexProvider.detectInstallation] authCheck result:', JSON.stringify(authCheck));
const authenticated = authCheck.authenticated;
const result = {
installed,
path: cliPath || undefined,
version: version || undefined,
method: 'cli',
method: 'cli' as const, // Installation method
hasApiKey,
authenticated: authIndicators.hasOAuthToken || authIndicators.hasApiKey || hasApiKey,
authenticated,
};
console.log('[CodexProvider.detectInstallation] Final result:', JSON.stringify(result));
return result;
}
getAvailableModels(): ModelDefinition[] {
@@ -1001,94 +1022,68 @@ export class CodexProvider extends BaseProvider {
* Check authentication status for Codex CLI
*/
async checkAuth(): Promise<CodexAuthStatus> {
console.log('[CodexProvider.checkAuth] Starting auth check...');
const cliPath = await findCodexCliPath();
const hasApiKey = !!process.env[OPENAI_API_KEY_ENV];
const authIndicators = await getCodexAuthIndicators();
console.log('[CodexProvider.checkAuth] cliPath:', cliPath);
console.log('[CodexProvider.checkAuth] hasApiKey:', hasApiKey);
console.log('[CodexProvider.checkAuth] authIndicators:', JSON.stringify(authIndicators));
// Check for API key in environment
if (hasApiKey) {
console.log('[CodexProvider.checkAuth] Has API key, returning authenticated');
return { authenticated: true, method: 'api_key' };
}
// Check for OAuth/token from Codex CLI
if (authIndicators.hasOAuthToken || authIndicators.hasApiKey) {
console.log(
'[CodexProvider.checkAuth] Has OAuth token or API key in auth file, returning authenticated'
);
return { authenticated: true, method: 'oauth' };
}
// CLI is installed but not authenticated
// CLI is installed but not authenticated via indicators - try CLI command
console.log('[CodexProvider.checkAuth] No indicators found, trying CLI command...');
if (cliPath) {
try {
// Try 'codex login status' first (same as checkCodexAuthentication)
console.log('[CodexProvider.checkAuth] Running: ' + cliPath + ' login status');
const result = await spawnProcess({
command: cliPath || CODEX_COMMAND,
args: ['auth', 'status', '--json'],
args: ['login', 'status'],
cwd: process.cwd(),
env: {
...process.env,
TERM: 'dumb',
},
});
// If auth command succeeds, we're authenticated
if (result.exitCode === 0) {
console.log('[CodexProvider.checkAuth] login status result:');
console.log('[CodexProvider.checkAuth] exitCode:', result.exitCode);
console.log('[CodexProvider.checkAuth] stdout:', JSON.stringify(result.stdout));
console.log('[CodexProvider.checkAuth] stderr:', JSON.stringify(result.stderr));
// Check both stdout and stderr - Codex CLI outputs to stderr
const combinedOutput = (result.stdout + result.stderr).toLowerCase();
const isLoggedIn = combinedOutput.includes('logged in');
console.log('[CodexProvider.checkAuth] isLoggedIn:', isLoggedIn);
if (result.exitCode === 0 && isLoggedIn) {
console.log('[CodexProvider.checkAuth] CLI says logged in, returning authenticated');
return { authenticated: true, method: 'oauth' };
}
} catch {
// Auth command failed, not authenticated
} catch (error) {
console.log('[CodexProvider.checkAuth] Error running login status:', error);
}
}
console.log('[CodexProvider.checkAuth] 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)
*/

View File

@@ -13,7 +13,10 @@ export function createClaudeRoutes(service: ClaudeUsageService): Router {
// Check if Claude CLI is available first
const isAvailable = await service.isAvailable();
if (!isAvailable) {
res.status(503).json({
// IMPORTANT: This endpoint is behind Automaker session auth already.
// Use a 200 + error payload for Claude CLI issues so the UI doesn't
// interpret it as an invalid Automaker session (401/403 triggers logout).
res.status(200).json({
error: 'Claude CLI not found',
message: "Please install Claude Code CLI and run 'claude login' to authenticate",
});
@@ -26,12 +29,13 @@ export function createClaudeRoutes(service: ClaudeUsageService): Router {
const message = error instanceof Error ? error.message : 'Unknown error';
if (message.includes('Authentication required') || message.includes('token_expired')) {
res.status(401).json({
// Do NOT use 401/403 here: that status code is reserved for Automaker session auth.
res.status(200).json({
error: 'Authentication required',
message: "Please run 'claude login' to authenticate",
});
} else if (message.includes('timed out')) {
res.status(504).json({
res.status(200).json({
error: 'Command timed out',
message: 'The Claude CLI took too long to respond',
});

View File

@@ -13,7 +13,10 @@ export function createCodexRoutes(service: CodexUsageService): Router {
// Check if Codex CLI is available first
const isAvailable = await service.isAvailable();
if (!isAvailable) {
res.status(503).json({
// IMPORTANT: This endpoint is behind Automaker session auth already.
// Use a 200 + error payload for Codex CLI issues so the UI doesn't
// interpret it as an invalid Automaker session (401/403 triggers logout).
res.status(200).json({
error: 'Codex CLI not found',
message: "Please install Codex CLI and run 'codex login' to authenticate",
});
@@ -26,18 +29,19 @@ export function createCodexRoutes(service: CodexUsageService): Router {
const message = error instanceof Error ? error.message : 'Unknown error';
if (message.includes('not authenticated') || message.includes('login')) {
res.status(401).json({
// Do NOT use 401/403 here: that status code is reserved for Automaker session auth.
res.status(200).json({
error: 'Authentication required',
message: "Please run 'codex login' to authenticate",
});
} else if (message.includes('not available') || message.includes('does not provide')) {
// This is the expected case - Codex doesn't provide usage stats
res.status(503).json({
res.status(200).json({
error: 'Usage statistics not available',
message: message,
});
} else if (message.includes('timed out')) {
res.status(504).json({
res.status(200).json({
error: 'Command timed out',
message: 'The Codex CLI took too long to respond',
});

View File

@@ -19,6 +19,12 @@ export function createCodexStatusHandler() {
const provider = new CodexProvider();
const status = await provider.detectInstallation();
// Derive auth method from authenticated status and API key presence
let authMethod = 'none';
if (status.authenticated) {
authMethod = status.hasApiKey ? 'api_key_env' : 'cli_authenticated';
}
res.json({
success: true,
installed: status.installed,
@@ -26,7 +32,7 @@ export function createCodexStatusHandler() {
path: status.path || null,
auth: {
authenticated: status.authenticated || false,
method: status.method || 'cli',
method: authMethod,
hasApiKey: status.hasApiKey || false,
},
installCommand,

View File

@@ -1,5 +1,6 @@
import { spawn } from 'child_process';
import * as os from 'os';
import { findCodexCliPath } from '@automaker/platform';
import { checkCodexAuthentication } from '../lib/codex-auth.js';
export interface CodexRateLimitWindow {
limit: number;
@@ -40,21 +41,16 @@ export interface CodexUsageData {
export class CodexUsageService {
private codexBinary = 'codex';
private isWindows = os.platform() === 'win32';
private cachedCliPath: string | null = null;
/**
* Check if Codex CLI is available on the system
*/
async isAvailable(): Promise<boolean> {
return new Promise((resolve) => {
const checkCmd = this.isWindows ? 'where' : 'which';
const proc = spawn(checkCmd, [this.codexBinary]);
proc.on('close', (code) => {
resolve(code === 0);
});
proc.on('error', () => {
resolve(false);
});
});
// Prefer our platform-aware resolver over `which/where` because the server
// process PATH may not include npm global bins (nvm/fnm/volta/pnpm).
this.cachedCliPath = await findCodexCliPath();
return Boolean(this.cachedCliPath);
}
/**
@@ -84,29 +80,9 @@ export class CodexUsageService {
* Check if Codex is authenticated
*/
private async checkAuthentication(): Promise<boolean> {
return new Promise((resolve) => {
const proc = spawn(this.codexBinary, ['login', 'status'], {
env: {
...process.env,
TERM: 'dumb', // Avoid interactive output
},
});
let output = '';
proc.stdout.on('data', (data) => {
output += data.toString();
});
proc.on('close', (code) => {
// Check if output indicates logged in
const isLoggedIn = output.toLowerCase().includes('logged in');
resolve(code === 0 && isLoggedIn);
});
proc.on('error', () => {
resolve(false);
});
});
// Use the cached CLI path if available, otherwise fall back to finding it
const cliPath = this.cachedCliPath || (await findCodexCliPath());
const authCheck = await checkCodexAuthentication(cliPath);
return authCheck.authenticated;
}
}