Merge branch 'v0.9.0rc' into remove-sandbox-as-it-is-broken

This commit is contained in:
webdevcody
2026-01-07 15:01:31 -05:00
115 changed files with 8734 additions and 401 deletions

View File

@@ -188,9 +188,10 @@ setInterval(() => {
// This helps prevent CSRF and content-type confusion attacks
app.use('/api', requireJsonContentType);
// Mount API routes - health and auth are unauthenticated
// Mount API routes - health, auth, and setup are unauthenticated
app.use('/api/health', createHealthRoutes());
app.use('/api/auth', createAuthRoutes());
app.use('/api/setup', createSetupRoutes());
// Apply authentication to all other routes
app.use('/api', authMiddleware);
@@ -206,7 +207,6 @@ app.use('/api/auto-mode', createAutoModeRoutes(autoModeService));
app.use('/api/enhance-prompt', createEnhancePromptRoutes(settingsService));
app.use('/api/worktree', createWorktreeRoutes());
app.use('/api/git', createGitRoutes());
app.use('/api/setup', createSetupRoutes());
app.use('/api/suggestions', createSuggestionsRoutes(events, settingsService));
app.use('/api/models', createModelsRoutes());
app.use('/api/spec-regeneration', createSpecRegenerationRoutes(events, settingsService));

View File

@@ -0,0 +1,263 @@
/**
* Secure authentication utilities that avoid environment variable race conditions
*/
import { spawn } from 'child_process';
import { createLogger } from '@automaker/utils';
const logger = createLogger('AuthUtils');
export interface SecureAuthEnv {
[key: string]: string | undefined;
}
export interface AuthValidationResult {
isValid: boolean;
error?: string;
normalizedKey?: string;
}
/**
* Validates API key format without modifying process.env
*/
export function validateApiKey(
key: string,
provider: 'anthropic' | 'openai' | 'cursor'
): AuthValidationResult {
if (!key || typeof key !== 'string' || key.trim().length === 0) {
return { isValid: false, error: 'API key is required' };
}
const trimmedKey = key.trim();
switch (provider) {
case 'anthropic':
if (!trimmedKey.startsWith('sk-ant-')) {
return {
isValid: false,
error: 'Invalid Anthropic API key format. Should start with "sk-ant-"',
};
}
if (trimmedKey.length < 20) {
return { isValid: false, error: 'Anthropic API key too short' };
}
break;
case 'openai':
if (!trimmedKey.startsWith('sk-')) {
return { isValid: false, error: 'Invalid OpenAI API key format. Should start with "sk-"' };
}
if (trimmedKey.length < 20) {
return { isValid: false, error: 'OpenAI API key too short' };
}
break;
case 'cursor':
// Cursor API keys might have different format
if (trimmedKey.length < 10) {
return { isValid: false, error: 'Cursor API key too short' };
}
break;
}
return { isValid: true, normalizedKey: trimmedKey };
}
/**
* Creates a secure environment object for authentication testing
* without modifying the global process.env
*/
export function createSecureAuthEnv(
authMethod: 'cli' | 'api_key',
apiKey?: string,
provider: 'anthropic' | 'openai' | 'cursor' = 'anthropic'
): SecureAuthEnv {
const env: SecureAuthEnv = { ...process.env };
if (authMethod === 'cli') {
// For CLI auth, remove the API key to force CLI authentication
const envKey = provider === 'openai' ? 'OPENAI_API_KEY' : 'ANTHROPIC_API_KEY';
delete env[envKey];
} else if (authMethod === 'api_key' && apiKey) {
// For API key auth, validate and set the provided key
const validation = validateApiKey(apiKey, provider);
if (!validation.isValid) {
throw new Error(validation.error);
}
const envKey = provider === 'openai' ? 'OPENAI_API_KEY' : 'ANTHROPIC_API_KEY';
env[envKey] = validation.normalizedKey;
}
return env;
}
/**
* Creates a temporary environment override for the current process
* WARNING: This should only be used in isolated contexts and immediately cleaned up
*/
export function createTempEnvOverride(authEnv: SecureAuthEnv): () => void {
const originalEnv = { ...process.env };
// Apply the auth environment
Object.assign(process.env, authEnv);
// Return cleanup function
return () => {
// Restore original environment
Object.keys(process.env).forEach((key) => {
if (!(key in originalEnv)) {
delete process.env[key];
}
});
Object.assign(process.env, originalEnv);
};
}
/**
* Spawns a process with secure environment isolation
*/
export function spawnSecureAuth(
command: string,
args: string[],
authEnv: SecureAuthEnv,
options: {
cwd?: string;
timeout?: number;
} = {}
): Promise<{ stdout: string; stderr: string; exitCode: number | null }> {
return new Promise((resolve, reject) => {
const { cwd = process.cwd(), timeout = 30000 } = options;
logger.debug(`Spawning secure auth process: ${command} ${args.join(' ')}`);
const child = spawn(command, args, {
cwd,
env: authEnv,
stdio: 'pipe',
shell: false,
});
let stdout = '';
let stderr = '';
let isResolved = false;
const timeoutId = setTimeout(() => {
if (!isResolved) {
child.kill('SIGTERM');
isResolved = true;
reject(new Error('Authentication process timed out'));
}
}, timeout);
child.stdout?.on('data', (data) => {
stdout += data.toString();
});
child.stderr?.on('data', (data) => {
stderr += data.toString();
});
child.on('close', (code) => {
clearTimeout(timeoutId);
if (!isResolved) {
isResolved = true;
resolve({ stdout, stderr, exitCode: code });
}
});
child.on('error', (error) => {
clearTimeout(timeoutId);
if (!isResolved) {
isResolved = true;
reject(error);
}
});
});
}
/**
* Safely extracts environment variable without race conditions
*/
export function safeGetEnv(key: string): string | undefined {
return process.env[key];
}
/**
* Checks if an environment variable would be modified without actually modifying it
*/
export function wouldModifyEnv(key: string, newValue: string): boolean {
const currentValue = safeGetEnv(key);
return currentValue !== newValue;
}
/**
* Secure auth session management
*/
export class AuthSessionManager {
private static activeSessions = new Map<string, SecureAuthEnv>();
static createSession(
sessionId: string,
authMethod: 'cli' | 'api_key',
apiKey?: string,
provider: 'anthropic' | 'openai' | 'cursor' = 'anthropic'
): SecureAuthEnv {
const env = createSecureAuthEnv(authMethod, apiKey, provider);
this.activeSessions.set(sessionId, env);
return env;
}
static getSession(sessionId: string): SecureAuthEnv | undefined {
return this.activeSessions.get(sessionId);
}
static destroySession(sessionId: string): void {
this.activeSessions.delete(sessionId);
}
static cleanup(): void {
this.activeSessions.clear();
}
}
/**
* Rate limiting for auth attempts to prevent abuse
*/
export class AuthRateLimiter {
private attempts = new Map<string, { count: number; lastAttempt: number }>();
constructor(
private maxAttempts = 5,
private windowMs = 60000
) {}
canAttempt(identifier: string): boolean {
const now = Date.now();
const record = this.attempts.get(identifier);
if (!record || now - record.lastAttempt > this.windowMs) {
this.attempts.set(identifier, { count: 1, lastAttempt: now });
return true;
}
if (record.count >= this.maxAttempts) {
return false;
}
record.count++;
record.lastAttempt = now;
return true;
}
getRemainingAttempts(identifier: string): number {
const record = this.attempts.get(identifier);
if (!record) return this.maxAttempts;
return Math.max(0, this.maxAttempts - record.count);
}
getResetTime(identifier: string): Date | null {
const record = this.attempts.get(identifier);
if (!record) return null;
return new Date(record.lastAttempt + this.windowMs);
}
}

View File

@@ -0,0 +1,447 @@
/**
* Unified CLI Detection Framework
*
* Provides consistent CLI detection and management across all providers
*/
import { spawn, execSync } from 'child_process';
import * as fs from 'fs';
import * as path from 'path';
import * as os from 'os';
import { createLogger } from '@automaker/utils';
const logger = createLogger('CliDetection');
export interface CliInfo {
name: string;
command: string;
version?: string;
path?: string;
installed: boolean;
authenticated: boolean;
authMethod: 'cli' | 'api_key' | 'none';
platform?: string;
architectures?: string[];
}
export interface CliDetectionOptions {
timeout?: number;
includeWsl?: boolean;
wslDistribution?: string;
}
export interface CliDetectionResult {
cli: CliInfo;
detected: boolean;
issues: string[];
}
export interface UnifiedCliDetection {
claude?: CliDetectionResult;
codex?: CliDetectionResult;
cursor?: CliDetectionResult;
}
/**
* CLI Configuration for different providers
*/
const CLI_CONFIGS = {
claude: {
name: 'Claude CLI',
commands: ['claude'],
versionArgs: ['--version'],
installCommands: {
darwin: 'brew install anthropics/claude/claude',
linux: 'curl -fsSL https://claude.ai/install.sh | sh',
win32: 'iwr https://claude.ai/install.ps1 -UseBasicParsing | iex',
},
},
codex: {
name: 'Codex CLI',
commands: ['codex', 'openai'],
versionArgs: ['--version'],
installCommands: {
darwin: 'npm install -g @openai/codex-cli',
linux: 'npm install -g @openai/codex-cli',
win32: 'npm install -g @openai/codex-cli',
},
},
cursor: {
name: 'Cursor CLI',
commands: ['cursor-agent', 'cursor'],
versionArgs: ['--version'],
installCommands: {
darwin: 'brew install cursor/cursor/cursor-agent',
linux: 'curl -fsSL https://cursor.sh/install.sh | sh',
win32: 'iwr https://cursor.sh/install.ps1 -UseBasicParsing | iex',
},
},
} as const;
/**
* Detect if a CLI is installed and available
*/
export async function detectCli(
provider: keyof typeof CLI_CONFIGS,
options: CliDetectionOptions = {}
): Promise<CliDetectionResult> {
const config = CLI_CONFIGS[provider];
const { timeout = 5000, includeWsl = false, wslDistribution } = options;
const issues: string[] = [];
const cliInfo: CliInfo = {
name: config.name,
command: '',
installed: false,
authenticated: false,
authMethod: 'none',
};
try {
// Find the command in PATH
const command = await findCommand([...config.commands]);
if (command) {
cliInfo.command = command;
}
if (!cliInfo.command) {
issues.push(`${config.name} not found in PATH`);
return { cli: cliInfo, detected: false, issues };
}
cliInfo.path = cliInfo.command;
cliInfo.installed = true;
// Get version
try {
cliInfo.version = await getCliVersion(cliInfo.command, [...config.versionArgs], timeout);
} catch (error) {
issues.push(`Failed to get ${config.name} version: ${error}`);
}
// Check authentication
cliInfo.authMethod = await checkCliAuth(provider, cliInfo.command);
cliInfo.authenticated = cliInfo.authMethod !== 'none';
return { cli: cliInfo, detected: true, issues };
} catch (error) {
issues.push(`Error detecting ${config.name}: ${error}`);
return { cli: cliInfo, detected: false, issues };
}
}
/**
* Detect all CLIs in the system
*/
export async function detectAllCLis(
options: CliDetectionOptions = {}
): Promise<UnifiedCliDetection> {
const results: UnifiedCliDetection = {};
// Detect all providers in parallel
const providers = Object.keys(CLI_CONFIGS) as Array<keyof typeof CLI_CONFIGS>;
const detectionPromises = providers.map(async (provider) => {
const result = await detectCli(provider, options);
return { provider, result };
});
const detections = await Promise.all(detectionPromises);
for (const { provider, result } of detections) {
results[provider] = result;
}
return results;
}
/**
* Find the first available command from a list of alternatives
*/
export async function findCommand(commands: string[]): Promise<string | null> {
for (const command of commands) {
try {
const whichCommand = process.platform === 'win32' ? 'where' : 'which';
const result = execSync(`${whichCommand} ${command}`, {
encoding: 'utf8',
timeout: 2000,
}).trim();
if (result) {
return result.split('\n')[0]; // Take first result on Windows
}
} catch {
// Command not found, try next
}
}
return null;
}
/**
* Get CLI version
*/
export async function getCliVersion(
command: string,
args: string[],
timeout: number = 5000
): Promise<string> {
return new Promise((resolve, reject) => {
const child = spawn(command, args, {
stdio: 'pipe',
timeout,
});
let stdout = '';
let stderr = '';
child.stdout?.on('data', (data) => {
stdout += data.toString();
});
child.stderr?.on('data', (data) => {
stderr += data.toString();
});
child.on('close', (code) => {
if (code === 0 && stdout) {
resolve(stdout.trim());
} else if (stderr) {
reject(stderr.trim());
} else {
reject(`Command exited with code ${code}`);
}
});
child.on('error', reject);
});
}
/**
* Check authentication status for a CLI
*/
export async function checkCliAuth(
provider: keyof typeof CLI_CONFIGS,
command: string
): Promise<'cli' | 'api_key' | 'none'> {
try {
switch (provider) {
case 'claude':
return await checkClaudeAuth(command);
case 'codex':
return await checkCodexAuth(command);
case 'cursor':
return await checkCursorAuth(command);
default:
return 'none';
}
} catch {
return 'none';
}
}
/**
* Check Claude CLI authentication
*/
async function checkClaudeAuth(command: string): Promise<'cli' | 'api_key' | 'none'> {
try {
// Check for environment variable
if (process.env.ANTHROPIC_API_KEY) {
return 'api_key';
}
// Try running a simple command to check CLI auth
const result = await getCliVersion(command, ['--version'], 3000);
if (result) {
return 'cli'; // If version works, assume CLI is authenticated
}
} catch {
// Version command might work even without auth, so we need a better check
}
// Try a more specific auth check
return new Promise((resolve) => {
const child = spawn(command, ['whoami'], {
stdio: 'pipe',
timeout: 3000,
});
let stdout = '';
let stderr = '';
child.stdout?.on('data', (data) => {
stdout += data.toString();
});
child.stderr?.on('data', (data) => {
stderr += data.toString();
});
child.on('close', (code) => {
if (code === 0 && stdout && !stderr.includes('not authenticated')) {
resolve('cli');
} else {
resolve('none');
}
});
child.on('error', () => {
resolve('none');
});
});
}
/**
* Check Codex CLI authentication
*/
async function checkCodexAuth(command: string): Promise<'cli' | 'api_key' | 'none'> {
// Check for environment variable
if (process.env.OPENAI_API_KEY) {
return 'api_key';
}
try {
// Try a simple auth check
const result = await getCliVersion(command, ['--version'], 3000);
if (result) {
return 'cli';
}
} catch {
// Version check failed
}
return 'none';
}
/**
* Check Cursor CLI authentication
*/
async function checkCursorAuth(command: string): Promise<'cli' | 'api_key' | 'none'> {
// Check for environment variable
if (process.env.CURSOR_API_KEY) {
return 'api_key';
}
// Check for credentials files
const credentialPaths = [
path.join(os.homedir(), '.cursor', 'credentials.json'),
path.join(os.homedir(), '.config', 'cursor', 'credentials.json'),
path.join(os.homedir(), '.cursor', 'auth.json'),
path.join(os.homedir(), '.config', 'cursor', 'auth.json'),
];
for (const credPath of credentialPaths) {
try {
if (fs.existsSync(credPath)) {
const content = fs.readFileSync(credPath, 'utf8');
const creds = JSON.parse(content);
if (creds.accessToken || creds.token || creds.apiKey) {
return 'cli';
}
}
} catch {
// Invalid credentials file
}
}
// Try a simple command
try {
const result = await getCliVersion(command, ['--version'], 3000);
if (result) {
return 'cli';
}
} catch {
// Version check failed
}
return 'none';
}
/**
* Get installation instructions for a provider
*/
export function getInstallInstructions(
provider: keyof typeof CLI_CONFIGS,
platform: NodeJS.Platform = process.platform
): string {
const config = CLI_CONFIGS[provider];
const command = config.installCommands[platform as keyof typeof config.installCommands];
if (!command) {
return `No installation instructions available for ${provider} on ${platform}`;
}
return command;
}
/**
* Get platform-specific CLI paths and versions
*/
export function getPlatformCliPaths(provider: keyof typeof CLI_CONFIGS): string[] {
const config = CLI_CONFIGS[provider];
const platform = process.platform;
switch (platform) {
case 'darwin':
return [
`/usr/local/bin/${config.commands[0]}`,
`/opt/homebrew/bin/${config.commands[0]}`,
path.join(os.homedir(), '.local', 'bin', config.commands[0]),
];
case 'linux':
return [
`/usr/bin/${config.commands[0]}`,
`/usr/local/bin/${config.commands[0]}`,
path.join(os.homedir(), '.local', 'bin', config.commands[0]),
path.join(os.homedir(), '.npm', 'global', 'bin', config.commands[0]),
];
case 'win32':
return [
path.join(
os.homedir(),
'AppData',
'Local',
'Programs',
config.commands[0],
`${config.commands[0]}.exe`
),
path.join(process.env.ProgramFiles || '', config.commands[0], `${config.commands[0]}.exe`),
path.join(
process.env.ProgramFiles || '',
config.commands[0],
'bin',
`${config.commands[0]}.exe`
),
];
default:
return [];
}
}
/**
* Validate CLI installation
*/
export function validateCliInstallation(cliInfo: CliInfo): {
valid: boolean;
issues: string[];
} {
const issues: string[] = [];
if (!cliInfo.installed) {
issues.push('CLI is not installed');
}
if (cliInfo.installed && !cliInfo.version) {
issues.push('Could not determine CLI version');
}
if (cliInfo.installed && cliInfo.authMethod === 'none') {
issues.push('CLI is not authenticated');
}
return {
valid: issues.length === 0,
issues,
};
}

View File

@@ -0,0 +1,414 @@
/**
* Unified Error Handling System for CLI Providers
*
* Provides consistent error classification, user-friendly messages, and debugging support
* across all AI providers (Claude, Codex, Cursor)
*/
import { createLogger } from '@automaker/utils';
const logger = createLogger('ErrorHandler');
export enum ErrorType {
AUTHENTICATION = 'authentication',
BILLING = 'billing',
RATE_LIMIT = 'rate_limit',
NETWORK = 'network',
TIMEOUT = 'timeout',
VALIDATION = 'validation',
PERMISSION = 'permission',
CLI_NOT_FOUND = 'cli_not_found',
CLI_NOT_INSTALLED = 'cli_not_installed',
MODEL_NOT_SUPPORTED = 'model_not_supported',
INVALID_REQUEST = 'invalid_request',
SERVER_ERROR = 'server_error',
UNKNOWN = 'unknown',
}
export enum ErrorSeverity {
LOW = 'low',
MEDIUM = 'medium',
HIGH = 'high',
CRITICAL = 'critical',
}
export interface ErrorClassification {
type: ErrorType;
severity: ErrorSeverity;
userMessage: string;
technicalMessage: string;
suggestedAction?: string;
retryable: boolean;
provider?: string;
context?: Record<string, any>;
}
export interface ErrorPattern {
type: ErrorType;
severity: ErrorSeverity;
patterns: RegExp[];
userMessage: string;
suggestedAction?: string;
retryable: boolean;
}
/**
* Error patterns for different types of errors
*/
const ERROR_PATTERNS: ErrorPattern[] = [
// Authentication errors
{
type: ErrorType.AUTHENTICATION,
severity: ErrorSeverity.HIGH,
patterns: [
/unauthorized/i,
/authentication.*fail/i,
/invalid_api_key/i,
/invalid api key/i,
/not authenticated/i,
/please.*log/i,
/token.*revoked/i,
/oauth.*error/i,
/credentials.*invalid/i,
],
userMessage: 'Authentication failed. Please check your API key or login credentials.',
suggestedAction:
"Verify your API key is correct and hasn't expired, or run the CLI login command.",
retryable: false,
},
// Billing errors
{
type: ErrorType.BILLING,
severity: ErrorSeverity.HIGH,
patterns: [
/credit.*balance.*low/i,
/insufficient.*credit/i,
/billing.*issue/i,
/payment.*required/i,
/usage.*exceeded/i,
/quota.*exceeded/i,
/add.*credit/i,
],
userMessage: 'Account has insufficient credits or billing issues.',
suggestedAction: 'Please add credits to your account or check your billing settings.',
retryable: false,
},
// Rate limit errors
{
type: ErrorType.RATE_LIMIT,
severity: ErrorSeverity.MEDIUM,
patterns: [
/rate.*limit/i,
/too.*many.*request/i,
/limit.*reached/i,
/try.*later/i,
/429/i,
/reset.*time/i,
/upgrade.*plan/i,
],
userMessage: 'Rate limit reached. Please wait before trying again.',
suggestedAction: 'Wait a few minutes before retrying, or consider upgrading your plan.',
retryable: true,
},
// Network errors
{
type: ErrorType.NETWORK,
severity: ErrorSeverity.MEDIUM,
patterns: [/network/i, /connection/i, /dns/i, /timeout/i, /econnrefused/i, /enotfound/i],
userMessage: 'Network connection issue.',
suggestedAction: 'Check your internet connection and try again.',
retryable: true,
},
// Timeout errors
{
type: ErrorType.TIMEOUT,
severity: ErrorSeverity.MEDIUM,
patterns: [/timeout/i, /aborted/i, /time.*out/i],
userMessage: 'Operation timed out.',
suggestedAction: 'Try again with a simpler request or check your connection.',
retryable: true,
},
// Permission errors
{
type: ErrorType.PERMISSION,
severity: ErrorSeverity.HIGH,
patterns: [/permission.*denied/i, /access.*denied/i, /forbidden/i, /403/i, /not.*authorized/i],
userMessage: 'Permission denied.',
suggestedAction: 'Check if you have the required permissions for this operation.',
retryable: false,
},
// CLI not found
{
type: ErrorType.CLI_NOT_FOUND,
severity: ErrorSeverity.HIGH,
patterns: [/command not found/i, /not recognized/i, /not.*installed/i, /ENOENT/i],
userMessage: 'CLI tool not found.',
suggestedAction: "Please install the required CLI tool and ensure it's in your PATH.",
retryable: false,
},
// Model not supported
{
type: ErrorType.MODEL_NOT_SUPPORTED,
severity: ErrorSeverity.HIGH,
patterns: [/model.*not.*support/i, /unknown.*model/i, /invalid.*model/i],
userMessage: 'Model not supported.',
suggestedAction: 'Check available models and use a supported one.',
retryable: false,
},
// Server errors
{
type: ErrorType.SERVER_ERROR,
severity: ErrorSeverity.HIGH,
patterns: [/internal.*server/i, /server.*error/i, /500/i, /502/i, /503/i, /504/i],
userMessage: 'Server error occurred.',
suggestedAction: 'Try again in a few minutes or contact support if the issue persists.',
retryable: true,
},
];
/**
* Classify an error into a specific type with user-friendly message
*/
export function classifyError(
error: unknown,
provider?: string,
context?: Record<string, any>
): ErrorClassification {
const errorText = getErrorText(error);
// Try to match against known patterns
for (const pattern of ERROR_PATTERNS) {
for (const regex of pattern.patterns) {
if (regex.test(errorText)) {
return {
type: pattern.type,
severity: pattern.severity,
userMessage: pattern.userMessage,
technicalMessage: errorText,
suggestedAction: pattern.suggestedAction,
retryable: pattern.retryable,
provider,
context,
};
}
}
}
// Unknown error
return {
type: ErrorType.UNKNOWN,
severity: ErrorSeverity.MEDIUM,
userMessage: 'An unexpected error occurred.',
technicalMessage: errorText,
suggestedAction: 'Please try again or contact support if the issue persists.',
retryable: true,
provider,
context,
};
}
/**
* Get a user-friendly error message
*/
export function getUserFriendlyErrorMessage(error: unknown, provider?: string): string {
const classification = classifyError(error, provider);
let message = classification.userMessage;
if (classification.suggestedAction) {
message += ` ${classification.suggestedAction}`;
}
// Add provider-specific context if available
if (provider) {
message = `[${provider.toUpperCase()}] ${message}`;
}
return message;
}
/**
* Check if an error is retryable
*/
export function isRetryableError(error: unknown): boolean {
const classification = classifyError(error);
return classification.retryable;
}
/**
* Check if an error is authentication-related
*/
export function isAuthenticationError(error: unknown): boolean {
const classification = classifyError(error);
return classification.type === ErrorType.AUTHENTICATION;
}
/**
* Check if an error is billing-related
*/
export function isBillingError(error: unknown): boolean {
const classification = classifyError(error);
return classification.type === ErrorType.BILLING;
}
/**
* Check if an error is rate limit related
*/
export function isRateLimitError(error: unknown): boolean {
const classification = classifyError(error);
return classification.type === ErrorType.RATE_LIMIT;
}
/**
* Get error text from various error types
*/
function getErrorText(error: unknown): string {
if (typeof error === 'string') {
return error;
}
if (error instanceof Error) {
return error.message;
}
if (typeof error === 'object' && error !== null) {
// Handle structured error objects
const errorObj = error as any;
if (errorObj.message) {
return errorObj.message;
}
if (errorObj.error?.message) {
return errorObj.error.message;
}
if (errorObj.error) {
return typeof errorObj.error === 'string' ? errorObj.error : JSON.stringify(errorObj.error);
}
return JSON.stringify(error);
}
return String(error);
}
/**
* Create a standardized error response
*/
export function createErrorResponse(
error: unknown,
provider?: string,
context?: Record<string, any>
): {
success: false;
error: string;
errorType: ErrorType;
severity: ErrorSeverity;
retryable: boolean;
suggestedAction?: string;
} {
const classification = classifyError(error, provider, context);
return {
success: false,
error: classification.userMessage,
errorType: classification.type,
severity: classification.severity,
retryable: classification.retryable,
suggestedAction: classification.suggestedAction,
};
}
/**
* Log error with full context
*/
export function logError(
error: unknown,
provider?: string,
operation?: string,
additionalContext?: Record<string, any>
): void {
const classification = classifyError(error, provider, {
operation,
...additionalContext,
});
logger.error(`Error in ${provider || 'unknown'}${operation ? ` during ${operation}` : ''}`, {
type: classification.type,
severity: classification.severity,
message: classification.userMessage,
technicalMessage: classification.technicalMessage,
retryable: classification.retryable,
suggestedAction: classification.suggestedAction,
context: classification.context,
});
}
/**
* Provider-specific error handlers
*/
export const ProviderErrorHandler = {
claude: {
classify: (error: unknown) => classifyError(error, 'claude'),
getUserMessage: (error: unknown) => getUserFriendlyErrorMessage(error, 'claude'),
isAuth: (error: unknown) => isAuthenticationError(error),
isBilling: (error: unknown) => isBillingError(error),
isRateLimit: (error: unknown) => isRateLimitError(error),
},
codex: {
classify: (error: unknown) => classifyError(error, 'codex'),
getUserMessage: (error: unknown) => getUserFriendlyErrorMessage(error, 'codex'),
isAuth: (error: unknown) => isAuthenticationError(error),
isBilling: (error: unknown) => isBillingError(error),
isRateLimit: (error: unknown) => isRateLimitError(error),
},
cursor: {
classify: (error: unknown) => classifyError(error, 'cursor'),
getUserMessage: (error: unknown) => getUserFriendlyErrorMessage(error, 'cursor'),
isAuth: (error: unknown) => isAuthenticationError(error),
isBilling: (error: unknown) => isBillingError(error),
isRateLimit: (error: unknown) => isRateLimitError(error),
},
};
/**
* Create a retry handler for retryable errors
*/
export function createRetryHandler(maxRetries: number = 3, baseDelay: number = 1000) {
return async function <T>(
operation: () => Promise<T>,
shouldRetry: (error: unknown) => boolean = isRetryableError
): Promise<T> {
let lastError: unknown;
for (let attempt = 0; attempt <= maxRetries; attempt++) {
try {
return await operation();
} catch (error) {
lastError = error;
if (attempt === maxRetries || !shouldRetry(error)) {
throw error;
}
// Exponential backoff with jitter
const delay = baseDelay * Math.pow(2, attempt) + Math.random() * 1000;
logger.debug(`Retrying operation in ${delay}ms (attempt ${attempt + 1}/${maxRetries})`);
await new Promise((resolve) => setTimeout(resolve, delay));
}
}
throw lastError;
};
}

View File

@@ -0,0 +1,173 @@
/**
* Permission enforcement utilities for Cursor provider
*/
import type { CursorCliConfigFile } from '@automaker/types';
import { createLogger } from '@automaker/utils';
const logger = createLogger('PermissionEnforcer');
export interface PermissionCheckResult {
allowed: boolean;
reason?: string;
}
/**
* Check if a tool call is allowed based on permissions
*/
export function checkToolCallPermission(
toolCall: any,
permissions: CursorCliConfigFile | null
): PermissionCheckResult {
if (!permissions || !permissions.permissions) {
// If no permissions are configured, allow everything (backward compatibility)
return { allowed: true };
}
const { allow = [], deny = [] } = permissions.permissions;
// Check shell tool calls
if (toolCall.shellToolCall?.args?.command) {
const command = toolCall.shellToolCall.args.command;
const toolName = `Shell(${extractCommandName(command)})`;
// Check deny list first (deny takes precedence)
for (const denyRule of deny) {
if (matchesRule(toolName, denyRule)) {
return {
allowed: false,
reason: `Operation blocked by permission rule: ${denyRule}`,
};
}
}
// Then check allow list
for (const allowRule of allow) {
if (matchesRule(toolName, allowRule)) {
return { allowed: true };
}
}
return {
allowed: false,
reason: `Operation not in allow list: ${toolName}`,
};
}
// Check read tool calls
if (toolCall.readToolCall?.args?.path) {
const path = toolCall.readToolCall.args.path;
const toolName = `Read(${path})`;
// Check deny list first
for (const denyRule of deny) {
if (matchesRule(toolName, denyRule)) {
return {
allowed: false,
reason: `Read operation blocked by permission rule: ${denyRule}`,
};
}
}
// Then check allow list
for (const allowRule of allow) {
if (matchesRule(toolName, allowRule)) {
return { allowed: true };
}
}
return {
allowed: false,
reason: `Read operation not in allow list: ${toolName}`,
};
}
// Check write tool calls
if (toolCall.writeToolCall?.args?.path) {
const path = toolCall.writeToolCall.args.path;
const toolName = `Write(${path})`;
// Check deny list first
for (const denyRule of deny) {
if (matchesRule(toolName, denyRule)) {
return {
allowed: false,
reason: `Write operation blocked by permission rule: ${denyRule}`,
};
}
}
// Then check allow list
for (const allowRule of allow) {
if (matchesRule(toolName, allowRule)) {
return { allowed: true };
}
}
return {
allowed: false,
reason: `Write operation not in allow list: ${toolName}`,
};
}
// For other tool types, allow by default for now
return { allowed: true };
}
/**
* Extract the base command name from a shell command
*/
function extractCommandName(command: string): string {
// Remove leading spaces and get the first word
const trimmed = command.trim();
const firstWord = trimmed.split(/\s+/)[0];
return firstWord || 'unknown';
}
/**
* Check if a tool name matches a permission rule
*/
function matchesRule(toolName: string, rule: string): boolean {
// Exact match
if (toolName === rule) {
return true;
}
// Wildcard patterns
if (rule.includes('*')) {
const regex = new RegExp(rule.replace(/\*/g, '.*'));
return regex.test(toolName);
}
// Prefix match for shell commands (e.g., "Shell(git)" matches "Shell(git status)")
if (rule.startsWith('Shell(') && toolName.startsWith('Shell(')) {
const ruleCommand = rule.slice(6, -1); // Remove "Shell(" and ")"
const toolCommand = extractCommandName(toolName.slice(6, -1)); // Remove "Shell(" and ")"
return toolCommand.startsWith(ruleCommand);
}
return false;
}
/**
* Log permission violations
*/
export function logPermissionViolation(toolCall: any, reason: string, sessionId?: string): void {
const sessionIdStr = sessionId ? ` [${sessionId}]` : '';
if (toolCall.shellToolCall?.args?.command) {
logger.warn(
`Permission violation${sessionIdStr}: Shell command blocked - ${toolCall.shellToolCall.args.command} (${reason})`
);
} else if (toolCall.readToolCall?.args?.path) {
logger.warn(
`Permission violation${sessionIdStr}: Read operation blocked - ${toolCall.readToolCall.args.path} (${reason})`
);
} else if (toolCall.writeToolCall?.args?.path) {
logger.warn(
`Permission violation${sessionIdStr}: Write operation blocked - ${toolCall.writeToolCall.args.path} (${reason})`
);
} else {
logger.warn(`Permission violation${sessionIdStr}: Tool call blocked (${reason})`, { toolCall });
}
}

View File

@@ -0,0 +1,85 @@
/**
* Codex Config Manager - Writes MCP server configuration for Codex CLI
*/
import path from 'path';
import type { McpServerConfig } from '@automaker/types';
import * as secureFs from '../lib/secure-fs.js';
const CODEX_CONFIG_DIR = '.codex';
const CODEX_CONFIG_FILENAME = 'config.toml';
const CODEX_MCP_SECTION = 'mcp_servers';
function formatTomlString(value: string): string {
return JSON.stringify(value);
}
function formatTomlArray(values: string[]): string {
const formatted = values.map((value) => formatTomlString(value)).join(', ');
return `[${formatted}]`;
}
function formatTomlInlineTable(values: Record<string, string>): string {
const entries = Object.entries(values).map(
([key, value]) => `${key} = ${formatTomlString(value)}`
);
return `{ ${entries.join(', ')} }`;
}
function formatTomlKey(key: string): string {
return `"${key.replace(/"/g, '\\"')}"`;
}
function buildServerBlock(name: string, server: McpServerConfig): string[] {
const lines: string[] = [];
const section = `${CODEX_MCP_SECTION}.${formatTomlKey(name)}`;
lines.push(`[${section}]`);
if (server.type) {
lines.push(`type = ${formatTomlString(server.type)}`);
}
if ('command' in server && server.command) {
lines.push(`command = ${formatTomlString(server.command)}`);
}
if ('args' in server && server.args && server.args.length > 0) {
lines.push(`args = ${formatTomlArray(server.args)}`);
}
if ('env' in server && server.env && Object.keys(server.env).length > 0) {
lines.push(`env = ${formatTomlInlineTable(server.env)}`);
}
if ('url' in server && server.url) {
lines.push(`url = ${formatTomlString(server.url)}`);
}
if ('headers' in server && server.headers && Object.keys(server.headers).length > 0) {
lines.push(`headers = ${formatTomlInlineTable(server.headers)}`);
}
return lines;
}
export class CodexConfigManager {
async configureMcpServers(
cwd: string,
mcpServers: Record<string, McpServerConfig>
): Promise<void> {
const configDir = path.join(cwd, CODEX_CONFIG_DIR);
const configPath = path.join(configDir, CODEX_CONFIG_FILENAME);
await secureFs.mkdir(configDir, { recursive: true });
const blocks: string[] = [];
for (const [name, server] of Object.entries(mcpServers)) {
blocks.push(...buildServerBlock(name, server), '');
}
const content = blocks.join('\n').trim();
if (content) {
await secureFs.writeFile(configPath, content + '\n', 'utf-8');
}
}
}

View File

@@ -0,0 +1,123 @@
/**
* Codex Model Definitions
*
* Official Codex CLI models as documented at https://developers.openai.com/codex/models/
*/
import { CODEX_MODEL_MAP } from '@automaker/types';
import type { ModelDefinition } from './types.js';
const CONTEXT_WINDOW_200K = 200000;
const CONTEXT_WINDOW_128K = 128000;
const MAX_OUTPUT_32K = 32000;
const MAX_OUTPUT_16K = 16000;
/**
* All available Codex models with their specifications
*/
export const CODEX_MODELS: ModelDefinition[] = [
// ========== Codex-Specific Models ==========
{
id: CODEX_MODEL_MAP.gpt52Codex,
name: 'GPT-5.2-Codex',
modelString: CODEX_MODEL_MAP.gpt52Codex,
provider: 'openai',
description:
'Most advanced agentic coding model for complex software engineering (default for ChatGPT users).',
contextWindow: CONTEXT_WINDOW_200K,
maxOutputTokens: MAX_OUTPUT_32K,
supportsVision: true,
supportsTools: true,
tier: 'premium' as const,
default: true,
hasReasoning: true,
},
{
id: CODEX_MODEL_MAP.gpt5Codex,
name: 'GPT-5-Codex',
modelString: CODEX_MODEL_MAP.gpt5Codex,
provider: 'openai',
description: 'Purpose-built for Codex CLI with versatile tool use (default for CLI users).',
contextWindow: CONTEXT_WINDOW_200K,
maxOutputTokens: MAX_OUTPUT_32K,
supportsVision: true,
supportsTools: true,
tier: 'standard' as const,
hasReasoning: true,
},
{
id: CODEX_MODEL_MAP.gpt5CodexMini,
name: 'GPT-5-Codex-Mini',
modelString: CODEX_MODEL_MAP.gpt5CodexMini,
provider: 'openai',
description: 'Faster workflows optimized for low-latency code Q&A and editing.',
contextWindow: CONTEXT_WINDOW_128K,
maxOutputTokens: MAX_OUTPUT_16K,
supportsVision: false,
supportsTools: true,
tier: 'basic' as const,
hasReasoning: false,
},
{
id: CODEX_MODEL_MAP.codex1,
name: 'Codex-1',
modelString: CODEX_MODEL_MAP.codex1,
provider: 'openai',
description: 'Version of o3 optimized for software engineering with advanced reasoning.',
contextWindow: CONTEXT_WINDOW_200K,
maxOutputTokens: MAX_OUTPUT_32K,
supportsVision: true,
supportsTools: true,
tier: 'premium' as const,
hasReasoning: true,
},
{
id: CODEX_MODEL_MAP.codexMiniLatest,
name: 'Codex-Mini-Latest',
modelString: CODEX_MODEL_MAP.codexMiniLatest,
provider: 'openai',
description: 'Version of o4-mini designed for Codex with faster workflows.',
contextWindow: CONTEXT_WINDOW_128K,
maxOutputTokens: MAX_OUTPUT_16K,
supportsVision: true,
supportsTools: true,
tier: 'standard' as const,
hasReasoning: false,
},
// ========== Base GPT-5 Model ==========
{
id: CODEX_MODEL_MAP.gpt5,
name: 'GPT-5',
modelString: CODEX_MODEL_MAP.gpt5,
provider: 'openai',
description: 'GPT-5 base flagship model with strong general-purpose capabilities.',
contextWindow: CONTEXT_WINDOW_200K,
maxOutputTokens: MAX_OUTPUT_32K,
supportsVision: true,
supportsTools: true,
tier: 'standard' as const,
hasReasoning: true,
},
];
/**
* Get model definition by ID
*/
export function getCodexModelById(modelId: string): ModelDefinition | undefined {
return CODEX_MODELS.find((m) => m.id === modelId || m.modelString === modelId);
}
/**
* Get all models that support reasoning
*/
export function getReasoningModels(): ModelDefinition[] {
return CODEX_MODELS.filter((m) => m.hasReasoning);
}
/**
* Get models by tier
*/
export function getModelsByTier(tier: 'premium' | 'standard' | 'basic'): ModelDefinition[] {
return CODEX_MODELS.filter((m) => m.tier === tier);
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,173 @@
/**
* Codex SDK client - Executes Codex queries via official @openai/codex-sdk
*
* Used for programmatic control of Codex from within the application.
* Provides cleaner integration than spawning CLI processes.
*/
import { Codex } from '@openai/codex-sdk';
import { formatHistoryAsText, classifyError, getUserFriendlyErrorMessage } from '@automaker/utils';
import { supportsReasoningEffort } from '@automaker/types';
import type { ExecuteOptions, ProviderMessage } from './types.js';
const OPENAI_API_KEY_ENV = 'OPENAI_API_KEY';
const SDK_HISTORY_HEADER = 'Current request:\n';
const DEFAULT_RESPONSE_TEXT = '';
const SDK_ERROR_DETAILS_LABEL = 'Details:';
type PromptBlock = {
type: string;
text?: string;
source?: {
type?: string;
media_type?: string;
data?: string;
};
};
function resolveApiKey(): string {
const apiKey = process.env[OPENAI_API_KEY_ENV];
if (!apiKey) {
throw new Error('OPENAI_API_KEY is not set.');
}
return apiKey;
}
function normalizePromptBlocks(prompt: ExecuteOptions['prompt']): PromptBlock[] {
if (Array.isArray(prompt)) {
return prompt as PromptBlock[];
}
return [{ type: 'text', text: prompt }];
}
function buildPromptText(options: ExecuteOptions, systemPrompt: string | null): string {
const historyText =
options.conversationHistory && options.conversationHistory.length > 0
? formatHistoryAsText(options.conversationHistory)
: '';
const promptBlocks = normalizePromptBlocks(options.prompt);
const promptTexts: string[] = [];
for (const block of promptBlocks) {
if (block.type === 'text' && typeof block.text === 'string' && block.text.trim()) {
promptTexts.push(block.text);
}
}
const promptContent = promptTexts.join('\n\n');
if (!promptContent.trim()) {
throw new Error('Codex SDK prompt is empty.');
}
const parts: string[] = [];
if (systemPrompt) {
parts.push(`System: ${systemPrompt}`);
}
if (historyText) {
parts.push(historyText);
}
parts.push(`${SDK_HISTORY_HEADER}${promptContent}`);
return parts.join('\n\n');
}
function buildSdkErrorMessage(rawMessage: string, userMessage: string): string {
if (!rawMessage) {
return userMessage;
}
if (!userMessage || rawMessage === userMessage) {
return rawMessage;
}
return `${userMessage}\n\n${SDK_ERROR_DETAILS_LABEL} ${rawMessage}`;
}
/**
* Execute a query using the official Codex SDK
*
* The SDK provides a cleaner interface than spawning CLI processes:
* - Handles authentication automatically
* - Provides TypeScript types
* - Supports thread management and resumption
* - Better error handling
*/
export async function* executeCodexSdkQuery(
options: ExecuteOptions,
systemPrompt: string | null
): AsyncGenerator<ProviderMessage> {
try {
const apiKey = resolveApiKey();
const codex = new Codex({ apiKey });
// Resume existing thread or start new one
let thread;
if (options.sdkSessionId) {
try {
thread = codex.resumeThread(options.sdkSessionId);
} catch {
// If resume fails, start a new thread
thread = codex.startThread();
}
} else {
thread = codex.startThread();
}
const promptText = buildPromptText(options, systemPrompt);
// Build run options with reasoning effort if supported
const runOptions: {
signal?: AbortSignal;
reasoning?: { effort: string };
} = {
signal: options.abortController?.signal,
};
// Add reasoning effort if model supports it and reasoningEffort is specified
if (
options.reasoningEffort &&
supportsReasoningEffort(options.model) &&
options.reasoningEffort !== 'none'
) {
runOptions.reasoning = { effort: options.reasoningEffort };
}
// Run the query
const result = await thread.run(promptText, runOptions);
// Extract response text (from finalResponse property)
const outputText = result.finalResponse ?? DEFAULT_RESPONSE_TEXT;
// Get thread ID (may be null if not populated yet)
const threadId = thread.id ?? undefined;
// Yield assistant message
yield {
type: 'assistant',
session_id: threadId,
message: {
role: 'assistant',
content: [{ type: 'text', text: outputText }],
},
};
// Yield result
yield {
type: 'result',
subtype: 'success',
session_id: threadId,
result: outputText,
};
} catch (error) {
const errorInfo = classifyError(error);
const userMessage = getUserFriendlyErrorMessage(error);
const combinedMessage = buildSdkErrorMessage(errorInfo.message, userMessage);
console.error('[CodexSDK] executeQuery() error during execution:', {
type: errorInfo.type,
message: errorInfo.message,
isRateLimit: errorInfo.isRateLimit,
retryAfter: errorInfo.retryAfter,
stack: error instanceof Error ? error.stack : undefined,
});
yield { type: 'error', error: combinedMessage };
}
}

View File

@@ -0,0 +1,436 @@
export type CodexToolResolution = {
name: string;
input: Record<string, unknown>;
};
export type CodexTodoItem = {
content: string;
status: 'pending' | 'in_progress' | 'completed';
activeForm?: string;
};
const TOOL_NAME_BASH = 'Bash';
const TOOL_NAME_READ = 'Read';
const TOOL_NAME_EDIT = 'Edit';
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';
const INPUT_KEY_PATTERN = 'pattern';
const SHELL_WRAPPER_PATTERNS = [
/^\/bin\/bash\s+-lc\s+["']([\s\S]+)["']$/,
/^bash\s+-lc\s+["']([\s\S]+)["']$/,
/^\/bin\/sh\s+-lc\s+["']([\s\S]+)["']$/,
/^sh\s+-lc\s+["']([\s\S]+)["']$/,
/^cmd\.exe\s+\/c\s+["']?([\s\S]+)["']?$/i,
/^powershell(?:\.exe)?\s+-Command\s+["']?([\s\S]+)["']?$/i,
/^pwsh(?:\.exe)?\s+-Command\s+["']?([\s\S]+)["']?$/i,
] as const;
const COMMAND_SEPARATOR_PATTERN = /\s*(?:&&|\|\||;)\s*/;
const SEGMENT_SKIP_PREFIXES = ['cd ', 'export ', 'set ', 'pushd '] as const;
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/;
const REDIRECTION_TARGET_PATTERN = /(?:>>|>)\s*([^\s]+)/;
const SED_IN_PLACE_FLAGS = new Set(['-i', '--in-place']);
const PERL_IN_PLACE_FLAG = /-.*i/;
const SEARCH_PATTERN_FLAGS = new Set(['-e', '--regexp']);
const SEARCH_VALUE_FLAGS = new Set([
'-g',
'--glob',
'--iglob',
'--type',
'--type-add',
'--type-clear',
'--encoding',
]);
const SEARCH_FILE_LIST_FLAGS = new Set(['--files']);
const TODO_LINE_PATTERN = /^[-*]\s*(?:\[(?<status>[ x~])\]\s*)?(?<content>.+)$/;
const TODO_STATUS_COMPLETED = 'completed';
const TODO_STATUS_IN_PROGRESS = 'in_progress';
const TODO_STATUS_PENDING = 'pending';
const PATCH_FILE_MARKERS = [
'*** Update File: ',
'*** Add File: ',
'*** Delete File: ',
'*** Move to: ',
] as const;
function stripShellWrapper(command: string): string {
const trimmed = command.trim();
for (const pattern of SHELL_WRAPPER_PATTERNS) {
const match = trimmed.match(pattern);
if (match && match[1]) {
return unescapeCommand(match[1].trim());
}
}
return trimmed;
}
function unescapeCommand(command: string): string {
return command.replace(/\\(["'])/g, '$1');
}
function extractPrimarySegment(command: string): string {
const segments = command
.split(COMMAND_SEPARATOR_PATTERN)
.map((segment) => segment.trim())
.filter(Boolean);
for (const segment of segments) {
const shouldSkip = SEGMENT_SKIP_PREFIXES.some((prefix) => segment.startsWith(prefix));
if (!shouldSkip) {
return segment;
}
}
return command.trim();
}
function tokenizeCommand(command: string): string[] {
const tokens: string[] = [];
let current = '';
let inSingleQuote = false;
let inDoubleQuote = false;
let isEscaped = false;
for (const char of command) {
if (isEscaped) {
current += char;
isEscaped = false;
continue;
}
if (char === '\\') {
isEscaped = true;
continue;
}
if (char === "'" && !inDoubleQuote) {
inSingleQuote = !inSingleQuote;
continue;
}
if (char === '"' && !inSingleQuote) {
inDoubleQuote = !inDoubleQuote;
continue;
}
if (!inSingleQuote && !inDoubleQuote && /\s/.test(char)) {
if (current) {
tokens.push(current);
current = '';
}
continue;
}
current += char;
}
if (current) {
tokens.push(current);
}
return tokens;
}
function stripWrapperTokens(tokens: string[]): string[] {
let index = 0;
while (index < tokens.length && WRAPPER_COMMANDS.has(tokens[index].toLowerCase())) {
index += 1;
}
return tokens.slice(index);
}
function extractFilePathFromTokens(tokens: string[]): string | null {
const candidates = tokens.slice(1).filter((token) => token && !token.startsWith('-'));
if (candidates.length === 0) return null;
return candidates[candidates.length - 1];
}
function extractSearchPattern(tokens: string[]): string | null {
const remaining = tokens.slice(1);
for (let index = 0; index < remaining.length; index += 1) {
const token = remaining[index];
if (token === '--') {
return remaining[index + 1] ?? null;
}
if (SEARCH_PATTERN_FLAGS.has(token)) {
return remaining[index + 1] ?? null;
}
if (SEARCH_VALUE_FLAGS.has(token)) {
index += 1;
continue;
}
if (token.startsWith('-')) {
continue;
}
return token;
}
return null;
}
function extractTeeTarget(tokens: string[]): string | null {
const teeIndex = tokens.findIndex((token) => token === 'tee');
if (teeIndex < 0) return null;
const candidate = tokens[teeIndex + 1];
return candidate && !candidate.startsWith('-') ? candidate : null;
}
function extractRedirectionTarget(command: string): string | null {
const match = command.match(REDIRECTION_TARGET_PATTERN);
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'));
}
function hasPerlInPlaceFlag(tokens: string[]): boolean {
return tokens.some((token) => PERL_IN_PLACE_FLAG.test(token));
}
function extractPatchFilePath(command: string): string | null {
for (const marker of PATCH_FILE_MARKERS) {
const index = command.indexOf(marker);
if (index < 0) continue;
const start = index + marker.length;
const end = command.indexOf('\n', start);
const rawPath = (end === -1 ? command.slice(start) : command.slice(start, end)).trim();
if (rawPath) return rawPath;
}
return null;
}
function buildInputWithFilePath(filePath: string | null): Record<string, unknown> {
return filePath ? { [INPUT_KEY_FILE_PATH]: filePath } : {};
}
function buildInputWithPattern(pattern: string | null): Record<string, unknown> {
return pattern ? { [INPUT_KEY_PATTERN]: pattern } : {};
}
export function resolveCodexToolCall(command: string): CodexToolResolution {
const normalized = stripShellWrapper(command);
const primarySegment = extractPrimarySegment(normalized);
const tokens = stripWrapperTokens(tokenizeCommand(primarySegment));
const commandToken = tokens[0]?.toLowerCase() ?? '';
const redirectionTarget = extractRedirectionTarget(primarySegment);
if (redirectionTarget) {
return {
name: TOOL_NAME_WRITE,
input: buildInputWithFilePath(redirectionTarget),
};
}
if (commandToken === APPLY_PATCH_COMMAND || APPLY_PATCH_PATTERN.test(primarySegment)) {
return {
name: TOOL_NAME_EDIT,
input: buildInputWithFilePath(extractPatchFilePath(primarySegment)),
};
}
if (commandToken === 'sed' && hasSedInPlaceFlag(tokens)) {
return {
name: TOOL_NAME_EDIT,
input: buildInputWithFilePath(extractFilePathFromTokens(tokens)),
};
}
if (commandToken === 'perl' && hasPerlInPlaceFlag(tokens)) {
return {
name: TOOL_NAME_EDIT,
input: buildInputWithFilePath(extractFilePathFromTokens(tokens)),
};
}
if (WRITE_COMMANDS.has(commandToken)) {
const filePath =
commandToken === 'tee' ? extractTeeTarget(tokens) : extractFilePathFromTokens(tokens);
return {
name: TOOL_NAME_WRITE,
input: buildInputWithFilePath(filePath),
};
}
if (SEARCH_COMMANDS.has(commandToken)) {
if (tokens.some((token) => SEARCH_FILE_LIST_FLAGS.has(token))) {
return {
name: TOOL_NAME_GLOB,
input: buildInputWithPattern(extractFilePathFromTokens(tokens)),
};
}
return {
name: TOOL_NAME_GREP,
input: buildInputWithPattern(extractSearchPattern(tokens)),
};
}
// 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,
input: buildInputWithPattern(extractFilePathFromTokens(tokens)),
};
}
if (READ_COMMANDS.has(commandToken)) {
return {
name: TOOL_NAME_READ,
input: buildInputWithFilePath(extractFilePathFromTokens(tokens)),
};
}
return {
name: TOOL_NAME_BASH,
input: { [INPUT_KEY_COMMAND]: normalized },
};
}
function parseTodoLines(lines: string[]): CodexTodoItem[] {
const todos: CodexTodoItem[] = [];
for (const line of lines) {
const match = line.match(TODO_LINE_PATTERN);
if (!match?.groups?.content) continue;
const statusToken = match.groups.status;
const status =
statusToken === 'x'
? TODO_STATUS_COMPLETED
: statusToken === '~'
? TODO_STATUS_IN_PROGRESS
: TODO_STATUS_PENDING;
todos.push({ content: match.groups.content.trim(), status });
}
return todos;
}
function extractTodoFromArray(value: unknown[]): CodexTodoItem[] {
return value
.map((entry) => {
if (typeof entry === 'string') {
return { content: entry, status: TODO_STATUS_PENDING };
}
if (entry && typeof entry === 'object') {
const record = entry as Record<string, unknown>;
const content =
typeof record.content === 'string'
? record.content
: typeof record.text === 'string'
? record.text
: typeof record.title === 'string'
? record.title
: null;
if (!content) return null;
const status =
record.status === TODO_STATUS_COMPLETED ||
record.status === TODO_STATUS_IN_PROGRESS ||
record.status === TODO_STATUS_PENDING
? (record.status as CodexTodoItem['status'])
: TODO_STATUS_PENDING;
const activeForm = typeof record.activeForm === 'string' ? record.activeForm : undefined;
return { content, status, activeForm };
}
return null;
})
.filter((item): item is CodexTodoItem => Boolean(item));
}
export function extractCodexTodoItems(item: Record<string, unknown>): CodexTodoItem[] | null {
const todosValue = item.todos;
if (Array.isArray(todosValue)) {
const todos = extractTodoFromArray(todosValue);
return todos.length > 0 ? todos : null;
}
const itemsValue = item.items;
if (Array.isArray(itemsValue)) {
const todos = extractTodoFromArray(itemsValue);
return todos.length > 0 ? todos : null;
}
const textValue =
typeof item.text === 'string'
? item.text
: typeof item.content === 'string'
? item.content
: null;
if (!textValue) return null;
const lines = textValue
.split('\n')
.map((line) => line.trim())
.filter(Boolean);
const todos = parseTodoLines(lines);
return todos.length > 0 ? todos : null;
}
export function getCodexTodoToolName(): string {
return TOOL_NAME_TODO;
}

View File

@@ -29,6 +29,8 @@ import type {
ContentBlock,
} from './types.js';
import { stripProviderPrefix } from '@automaker/types';
import { validateApiKey } from '../lib/auth-utils.js';
import { getEffectivePermissions } from '../services/cursor-config-service.js';
import {
type CursorStreamEvent,
type CursorSystemEvent,
@@ -321,12 +323,19 @@ export class CursorProvider extends CliProvider {
// Build CLI arguments for cursor-agent
// NOTE: Prompt is NOT included here - it's passed via stdin to avoid
// shell escaping issues when content contains $(), backticks, etc.
const cliArgs: string[] = [
const cliArgs: string[] = [];
// If using Cursor IDE (cliPath is 'cursor' not 'cursor-agent'), add 'agent' subcommand
if (this.cliPath && !this.cliPath.includes('cursor-agent')) {
cliArgs.push('agent');
}
cliArgs.push(
'-p', // Print mode (non-interactive)
'--output-format',
'stream-json',
'--stream-partial-output', // Real-time streaming
];
'--stream-partial-output' // Real-time streaming
);
// Only add --force if NOT in read-only mode
// Without --force, Cursor CLI suggests changes but doesn't apply them
@@ -472,7 +481,9 @@ export class CursorProvider extends CliProvider {
// ==========================================================================
/**
* Override CLI detection to add Cursor-specific versions directory check
* Override CLI detection to add Cursor-specific checks:
* 1. Versions directory for cursor-agent installations
* 2. Cursor IDE with 'cursor agent' subcommand support
*/
protected detectCli(): CliDetectionResult {
// First try standard detection (PATH, common paths, WSL)
@@ -507,6 +518,39 @@ export class CursorProvider extends CliProvider {
}
}
// If cursor-agent not found, try to find 'cursor' IDE and use 'cursor agent' subcommand
// The Cursor IDE includes the agent as a subcommand: cursor agent
if (process.platform !== 'win32') {
const cursorPaths = [
'/usr/bin/cursor',
'/usr/local/bin/cursor',
path.join(os.homedir(), '.local/bin/cursor'),
'/opt/cursor/cursor',
];
for (const cursorPath of cursorPaths) {
if (fs.existsSync(cursorPath)) {
// Verify cursor agent subcommand works
try {
execSync(`"${cursorPath}" agent --version`, {
encoding: 'utf8',
timeout: 5000,
stdio: 'pipe',
});
logger.debug(`Using cursor agent via Cursor IDE: ${cursorPath}`);
// Return cursor path but we'll use 'cursor agent' subcommand
return {
cliPath: cursorPath,
useWsl: false,
strategy: 'native',
};
} catch {
// cursor agent subcommand doesn't work, try next path
}
}
}
}
return result;
}
@@ -642,6 +686,9 @@ export class CursorProvider extends CliProvider {
logger.debug(`CursorProvider.executeQuery called with model: "${options.model}"`);
// Get effective permissions for this project
const effectivePermissions = await getEffectivePermissions(options.cwd || process.cwd());
// Debug: log raw events when AUTOMAKER_DEBUG_RAW_OUTPUT is enabled
const debugRawEvents =
process.env.AUTOMAKER_DEBUG_RAW_OUTPUT === 'true' ||
@@ -838,9 +885,16 @@ export class CursorProvider extends CliProvider {
});
return result;
}
const result = execSync(`"${this.cliPath}" --version`, {
// If using Cursor IDE, use 'cursor agent --version'
const versionCmd = this.cliPath.includes('cursor-agent')
? `"${this.cliPath}" --version`
: `"${this.cliPath}" agent --version`;
const result = execSync(versionCmd, {
encoding: 'utf8',
timeout: 5000,
stdio: 'pipe',
}).trim();
return result;
} catch {
@@ -857,8 +911,13 @@ export class CursorProvider extends CliProvider {
return { authenticated: false, method: 'none' };
}
// Check for API key in environment
// Check for API key in environment with validation
if (process.env.CURSOR_API_KEY) {
const validation = validateApiKey(process.env.CURSOR_API_KEY, 'cursor');
if (!validation.isValid) {
logger.warn('Cursor API key validation failed:', validation.error);
return { authenticated: false, method: 'api_key', error: validation.error };
}
return { authenticated: true, method: 'api_key' };
}

View File

@@ -7,7 +7,7 @@
import { BaseProvider } from './base-provider.js';
import type { InstallationStatus, ModelDefinition } from './types.js';
import { isCursorModel, type ModelProvider } from '@automaker/types';
import { isCursorModel, isCodexModel, type ModelProvider } from '@automaker/types';
/**
* Provider registration entry
@@ -156,6 +156,41 @@ export class ProviderFactory {
static getRegisteredProviderNames(): string[] {
return Array.from(providerRegistry.keys());
}
/**
* Check if a specific model supports vision/image input
*
* @param modelId Model identifier
* @returns Whether the model supports vision (defaults to true if model not found)
*/
static modelSupportsVision(modelId: string): boolean {
const provider = this.getProviderForModel(modelId);
const models = provider.getAvailableModels();
// Find the model in the available models list
for (const model of models) {
if (
model.id === modelId ||
model.modelString === modelId ||
model.id.endsWith(`-${modelId}`) ||
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;
}
}
// =============================================================================
@@ -165,6 +200,7 @@ export class ProviderFactory {
// Import providers for registration side-effects
import { ClaudeProvider } from './claude-provider.js';
import { CursorProvider } from './cursor-provider.js';
import { CodexProvider } from './codex-provider.js';
// Register Claude provider
registerProvider('claude', {
@@ -184,3 +220,11 @@ registerProvider('cursor', {
canHandleModel: (model: string) => isCursorModel(model),
priority: 10, // Higher priority - check Cursor models first
});
// Register Codex provider
registerProvider('codex', {
factory: () => new CodexProvider(),
aliases: ['openai'],
canHandleModel: (model: string) => isCodexModel(model),
priority: 5, // Medium priority - check after Cursor but before Claude
});

View File

@@ -11,8 +11,12 @@ import { createDeleteApiKeyHandler } from './routes/delete-api-key.js';
import { createApiKeysHandler } from './routes/api-keys.js';
import { createPlatformHandler } from './routes/platform.js';
import { createVerifyClaudeAuthHandler } from './routes/verify-claude-auth.js';
import { createVerifyCodexAuthHandler } from './routes/verify-codex-auth.js';
import { createGhStatusHandler } from './routes/gh-status.js';
import { createCursorStatusHandler } from './routes/cursor-status.js';
import { createCodexStatusHandler } from './routes/codex-status.js';
import { createInstallCodexHandler } from './routes/install-codex.js';
import { createAuthCodexHandler } from './routes/auth-codex.js';
import {
createGetCursorConfigHandler,
createSetCursorDefaultModelHandler,
@@ -35,10 +39,16 @@ export function createSetupRoutes(): Router {
router.get('/api-keys', createApiKeysHandler());
router.get('/platform', createPlatformHandler());
router.post('/verify-claude-auth', createVerifyClaudeAuthHandler());
router.post('/verify-codex-auth', createVerifyCodexAuthHandler());
router.get('/gh-status', createGhStatusHandler());
// Cursor CLI routes
router.get('/cursor-status', createCursorStatusHandler());
// Codex CLI routes
router.get('/codex-status', createCodexStatusHandler());
router.post('/install-codex', createInstallCodexHandler());
router.post('/auth-codex', createAuthCodexHandler());
router.get('/cursor-config', createGetCursorConfigHandler());
router.post('/cursor-config/default-model', createSetCursorDefaultModelHandler());
router.post('/cursor-config/models', createSetCursorModelsHandler());

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

@@ -0,0 +1,31 @@
/**
* POST /auth-codex endpoint - Authenticate Codex CLI
*/
import type { Request, Response } from 'express';
import { logError, getErrorMessage } from '../common.js';
/**
* Creates handler for POST /api/setup/auth-codex
* Returns instructions for manual Codex CLI authentication
*/
export function createAuthCodexHandler() {
return async (_req: Request, res: Response): Promise<void> => {
try {
const loginCommand = 'codex login';
res.json({
success: true,
requiresManualAuth: true,
command: loginCommand,
message: `Please authenticate Codex CLI manually by running: ${loginCommand}`,
});
} catch (error) {
logError(error, 'Auth Codex failed');
res.status(500).json({
success: false,
error: getErrorMessage(error),
});
}
};
}

View File

@@ -0,0 +1,43 @@
/**
* GET /codex-status endpoint - Get Codex CLI installation and auth status
*/
import type { Request, Response } from 'express';
import { CodexProvider } from '../../../providers/codex-provider.js';
import { getErrorMessage, logError } from '../common.js';
/**
* Creates handler for GET /api/setup/codex-status
* Returns Codex CLI installation and authentication status
*/
export function createCodexStatusHandler() {
const installCommand = 'npm install -g @openai/codex';
const loginCommand = 'codex login';
return async (_req: Request, res: Response): Promise<void> => {
try {
const provider = new CodexProvider();
const status = await provider.detectInstallation();
res.json({
success: true,
installed: status.installed,
version: status.version || null,
path: status.path || null,
auth: {
authenticated: status.authenticated || false,
method: status.method || 'cli',
hasApiKey: status.hasApiKey || false,
},
installCommand,
loginCommand,
});
} catch (error) {
logError(error, 'Get Codex status failed');
res.status(500).json({
success: false,
error: getErrorMessage(error),
});
}
};
}

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

@@ -0,0 +1,33 @@
/**
* POST /install-codex endpoint - Install Codex CLI
*/
import type { Request, Response } from 'express';
import { logError, getErrorMessage } from '../common.js';
/**
* Creates handler for POST /api/setup/install-codex
* Installs Codex CLI (currently returns instructions for manual install)
*/
export function createInstallCodexHandler() {
return async (_req: Request, res: Response): Promise<void> => {
try {
// For now, return manual installation instructions
// In the future, this could potentially trigger npm global install
const installCommand = 'npm install -g @openai/codex';
res.json({
success: true,
message: `Please install Codex CLI manually by running: ${installCommand}`,
requiresManualInstall: true,
installCommand,
});
} catch (error) {
logError(error, 'Install Codex failed');
res.status(500).json({
success: false,
error: getErrorMessage(error),
});
}
};
}

View File

@@ -7,8 +7,16 @@ import type { Request, Response } from 'express';
import { query } from '@anthropic-ai/claude-agent-sdk';
import { createLogger } from '@automaker/utils';
import { getApiKey } from '../common.js';
import {
createSecureAuthEnv,
AuthSessionManager,
AuthRateLimiter,
validateApiKey,
createTempEnvOverride,
} from '../../../lib/auth-utils.js';
const logger = createLogger('Setup');
const rateLimiter = new AuthRateLimiter();
// Known error patterns that indicate auth failure
const AUTH_ERROR_PATTERNS = [
@@ -77,6 +85,19 @@ export function createVerifyClaudeAuthHandler() {
apiKey?: string;
};
// Rate limiting to prevent abuse
const clientIp = req.ip || req.socket.remoteAddress || 'unknown';
if (!rateLimiter.canAttempt(clientIp)) {
const resetTime = rateLimiter.getResetTime(clientIp);
res.status(429).json({
success: false,
authenticated: false,
error: 'Too many authentication attempts. Please try again later.',
resetTime,
});
return;
}
logger.info(
`[Setup] Verifying Claude authentication using method: ${authMethod || 'auto'}${apiKey ? ' (with provided key)' : ''}`
);
@@ -89,37 +110,48 @@ export function createVerifyClaudeAuthHandler() {
let errorMessage = '';
let receivedAnyContent = false;
// Save original env values
const originalAnthropicKey = process.env.ANTHROPIC_API_KEY;
// Create secure auth session
const sessionId = `claude-auth-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
try {
// Configure environment based on auth method
if (authMethod === 'cli') {
// For CLI verification, remove any API key so it uses CLI credentials only
delete process.env.ANTHROPIC_API_KEY;
logger.info('[Setup] Cleared API key environment for CLI verification');
} else if (authMethod === 'api_key') {
// For API key verification, use provided key, stored key, or env var (in order of priority)
if (apiKey) {
// Use the provided API key (allows testing unsaved keys)
process.env.ANTHROPIC_API_KEY = apiKey;
logger.info('[Setup] Using provided API key for verification');
} else {
const storedApiKey = getApiKey('anthropic');
if (storedApiKey) {
process.env.ANTHROPIC_API_KEY = storedApiKey;
logger.info('[Setup] Using stored API key for verification');
} else if (!process.env.ANTHROPIC_API_KEY) {
res.json({
success: true,
authenticated: false,
error: 'No API key configured. Please enter an API key first.',
});
return;
}
// For API key verification, validate the key first
if (authMethod === 'api_key' && apiKey) {
const validation = validateApiKey(apiKey, 'anthropic');
if (!validation.isValid) {
res.json({
success: true,
authenticated: false,
error: validation.error,
});
return;
}
}
// Create secure environment without modifying process.env
const authEnv = createSecureAuthEnv(authMethod || 'api_key', apiKey, 'anthropic');
// For API key verification without provided key, use stored key or env var
if (authMethod === 'api_key' && !apiKey) {
const storedApiKey = getApiKey('anthropic');
if (storedApiKey) {
authEnv.ANTHROPIC_API_KEY = storedApiKey;
logger.info('[Setup] Using stored API key for verification');
} else if (!authEnv.ANTHROPIC_API_KEY) {
res.json({
success: true,
authenticated: false,
error: 'No API key configured. Please enter an API key first.',
});
return;
}
}
// Store the secure environment in session manager
AuthSessionManager.createSession(sessionId, authMethod || 'api_key', apiKey, 'anthropic');
// Create temporary environment override for SDK call
const cleanupEnv = createTempEnvOverride(authEnv);
// Run a minimal query to verify authentication
const stream = query({
prompt: "Reply with only the word 'ok'",
@@ -278,13 +310,8 @@ export function createVerifyClaudeAuthHandler() {
}
} finally {
clearTimeout(timeoutId);
// Restore original environment
if (originalAnthropicKey !== undefined) {
process.env.ANTHROPIC_API_KEY = originalAnthropicKey;
} else if (authMethod === 'cli') {
// If we cleared it and there was no original, keep it cleared
delete process.env.ANTHROPIC_API_KEY;
}
// Clean up the auth session
AuthSessionManager.destroySession(sessionId);
}
logger.info('[Setup] Verification result:', {

View File

@@ -0,0 +1,282 @@
/**
* POST /verify-codex-auth endpoint - Verify Codex authentication
*/
import type { Request, Response } from 'express';
import { createLogger } from '@automaker/utils';
import { CODEX_MODEL_MAP } from '@automaker/types';
import { ProviderFactory } from '../../../providers/provider-factory.js';
import { getApiKey } from '../common.js';
import { getCodexAuthIndicators } from '@automaker/platform';
import {
createSecureAuthEnv,
AuthSessionManager,
AuthRateLimiter,
validateApiKey,
createTempEnvOverride,
} from '../../../lib/auth-utils.js';
const logger = createLogger('Setup');
const rateLimiter = new AuthRateLimiter();
const OPENAI_API_KEY_ENV = 'OPENAI_API_KEY';
const AUTH_PROMPT = "Reply with only the word 'ok'";
const AUTH_TIMEOUT_MS = 30000;
const ERROR_BILLING_MESSAGE =
'Credit balance is too low. Please add credits to your OpenAI account.';
const ERROR_RATE_LIMIT_MESSAGE =
'Rate limit reached. Please wait a while before trying again or upgrade your plan.';
const ERROR_CLI_AUTH_REQUIRED =
"CLI authentication failed. Please run 'codex login' to authenticate.";
const ERROR_API_KEY_REQUIRED = 'No API key configured. Please enter an API key first.';
const AUTH_ERROR_PATTERNS = [
'authentication',
'unauthorized',
'invalid_api_key',
'invalid api key',
'api key is invalid',
'not authenticated',
'login',
'auth(',
'token refresh',
'tokenrefresh',
'failed to parse server response',
'transport channel closed',
];
const BILLING_ERROR_PATTERNS = [
'credit balance is too low',
'credit balance too low',
'insufficient credits',
'insufficient balance',
'no credits',
'out of credits',
'billing',
'payment required',
'add credits',
];
const RATE_LIMIT_PATTERNS = [
'limit reached',
'rate limit',
'rate_limit',
'too many requests',
'resets',
'429',
];
function containsAuthError(text: string): boolean {
const lowerText = text.toLowerCase();
return AUTH_ERROR_PATTERNS.some((pattern) => lowerText.includes(pattern));
}
function isBillingError(text: string): boolean {
const lowerText = text.toLowerCase();
return BILLING_ERROR_PATTERNS.some((pattern) => lowerText.includes(pattern));
}
function isRateLimitError(text: string): boolean {
if (isBillingError(text)) {
return false;
}
const lowerText = text.toLowerCase();
return RATE_LIMIT_PATTERNS.some((pattern) => lowerText.includes(pattern));
}
export function createVerifyCodexAuthHandler() {
return async (req: Request, res: Response): Promise<void> => {
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)}`;
// Rate limiting
const clientIp = req.ip || req.socket.remoteAddress || 'unknown';
if (!rateLimiter.canAttempt(clientIp)) {
const resetTime = rateLimiter.getResetTime(clientIp);
res.status(429).json({
success: false,
authenticated: false,
error: 'Too many authentication attempts. Please try again later.',
resetTime,
});
return;
}
const abortController = new AbortController();
const timeoutId = setTimeout(() => abortController.abort(), AUTH_TIMEOUT_MS);
try {
// Create secure environment without modifying process.env
const authEnv = createSecureAuthEnv(authMethod || 'api_key', apiKey, 'openai');
// For API key auth, validate and use the provided key or stored key
if (authMethod === 'api_key') {
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 {
// 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;
}
}
}
// Create session and temporary environment override
AuthSessionManager.createSession(sessionId, authMethod || 'api_key', undefined, 'openai');
const cleanupEnv = createTempEnvOverride(authEnv);
try {
if (authMethod === 'cli') {
const authIndicators = await getCodexAuthIndicators();
if (!authIndicators.hasOAuthToken && !authIndicators.hasApiKey) {
res.json({
success: true,
authenticated: false,
error: ERROR_CLI_AUTH_REQUIRED,
});
return;
}
}
// Use Codex provider explicitly (not ProviderFactory.getProviderForModel)
// because Cursor also supports GPT models and has higher priority
const provider = ProviderFactory.getProviderByName('codex');
if (!provider) {
throw new Error('Codex provider not available');
}
const stream = provider.executeQuery({
prompt: AUTH_PROMPT,
model: CODEX_MODEL_MAP.gpt52Codex,
cwd: process.cwd(),
maxTurns: 1,
allowedTools: [],
abortController,
});
let receivedAnyContent = false;
let errorMessage = '';
for await (const msg of stream) {
if (msg.type === 'error' && msg.error) {
if (isBillingError(msg.error)) {
errorMessage = ERROR_BILLING_MESSAGE;
} else if (isRateLimitError(msg.error)) {
errorMessage = ERROR_RATE_LIMIT_MESSAGE;
} else {
errorMessage = msg.error;
}
break;
}
if (msg.type === 'assistant' && msg.message?.content) {
for (const block of msg.message.content) {
if (block.type === 'text' && block.text) {
receivedAnyContent = true;
if (isBillingError(block.text)) {
errorMessage = ERROR_BILLING_MESSAGE;
break;
}
if (isRateLimitError(block.text)) {
errorMessage = ERROR_RATE_LIMIT_MESSAGE;
break;
}
if (containsAuthError(block.text)) {
errorMessage = block.text;
break;
}
}
}
}
if (msg.type === 'result' && msg.result) {
receivedAnyContent = true;
if (isBillingError(msg.result)) {
errorMessage = ERROR_BILLING_MESSAGE;
} else if (isRateLimitError(msg.result)) {
errorMessage = ERROR_RATE_LIMIT_MESSAGE;
} else if (containsAuthError(msg.result)) {
errorMessage = msg.result;
break;
}
}
}
if (errorMessage) {
// Rate limit and billing errors mean auth succeeded but usage is limited
const isUsageLimitError =
errorMessage === ERROR_BILLING_MESSAGE || errorMessage === ERROR_RATE_LIMIT_MESSAGE;
const response: {
success: boolean;
authenticated: boolean;
error: string;
details?: string;
} = {
success: true,
authenticated: isUsageLimitError ? true : false,
error: isUsageLimitError
? errorMessage
: authMethod === 'cli'
? ERROR_CLI_AUTH_REQUIRED
: 'API key is invalid or has been revoked.',
};
// Include detailed error for auth failures so users can debug
if (!isUsageLimitError && errorMessage !== response.error) {
response.details = errorMessage;
}
res.json(response);
return;
}
if (!receivedAnyContent) {
res.json({
success: true,
authenticated: false,
error: 'No response received from Codex. Please check your authentication.',
});
return;
}
res.json({ success: true, authenticated: true });
} finally {
// Clean up environment override
cleanupEnv();
}
} catch (error: unknown) {
const errMessage = error instanceof Error ? error.message : String(error);
logger.error('[Setup] Codex auth verification error:', errMessage);
const normalizedError = isBillingError(errMessage)
? ERROR_BILLING_MESSAGE
: isRateLimitError(errMessage)
? ERROR_RATE_LIMIT_MESSAGE
: errMessage;
res.json({
success: true,
authenticated: false,
error: normalizedError,
});
} finally {
clearTimeout(timeoutId);
// Clean up session
AuthSessionManager.destroySession(sessionId);
}
};
}

View File

@@ -13,6 +13,8 @@ import {
isAbortError,
loadContextFiles,
createLogger,
classifyError,
getUserFriendlyErrorMessage,
} from '@automaker/utils';
import { ProviderFactory } from '../providers/provider-factory.js';
import { createChatOptions, validateWorkingDirectory } from '../lib/sdk-options.js';
@@ -171,6 +173,18 @@ export class AgentService {
session.thinkingLevel = thinkingLevel;
}
// Validate vision support before processing images
const effectiveModel = model || session.model;
if (imagePaths && imagePaths.length > 0 && effectiveModel) {
const supportsVision = ProviderFactory.modelSupportsVision(effectiveModel);
if (!supportsVision) {
throw new Error(
`This model (${effectiveModel}) does not support image input. ` +
`Please switch to a model that supports vision, or remove the images and try again.`
);
}
}
// Read images and convert to base64
const images: Message['images'] = [];
if (imagePaths && imagePaths.length > 0) {
@@ -365,6 +379,53 @@ export class AgentService {
content: responseText,
toolUses,
});
} else if (msg.type === 'error') {
// Some providers (like Codex CLI/SaaS or Cursor CLI) surface failures as
// streamed error messages instead of throwing. Handle these here so the
// Agent Runner UX matches the Claude/Cursor behavior without changing
// their provider implementations.
const rawErrorText =
(typeof msg.error === 'string' && msg.error.trim()) ||
'Unexpected error from provider during agent execution.';
const errorInfo = classifyError(new Error(rawErrorText));
// Keep the provider-supplied text intact (Codex already includes helpful tips),
// only add a small rate-limit hint when we can detect it.
const enhancedText = errorInfo.isRateLimit
? `${rawErrorText}\n\nTip: It looks like you hit a rate limit. Try waiting a bit or reducing concurrent Agent Runner / Auto Mode tasks.`
: rawErrorText;
this.logger.error('Provider error during agent execution:', {
type: errorInfo.type,
message: errorInfo.message,
});
// Mark session as no longer running so the UI and queue stay in sync
session.isRunning = false;
session.abortController = null;
const errorMessage: Message = {
id: this.generateId(),
role: 'assistant',
content: `Error: ${enhancedText}`,
timestamp: new Date().toISOString(),
isError: true,
};
session.messages.push(errorMessage);
await this.saveSession(sessionId, session.messages);
this.emitAgentEvent(sessionId, {
type: 'error',
error: enhancedText,
message: errorMessage,
});
// Don't continue streaming after an error message
return {
success: false,
};
}
}

View File

@@ -1991,6 +1991,18 @@ This helps parse your summary correctly in the output logs.`;
const planningMode = options?.planningMode || 'skip';
const previousContent = options?.previousContent;
// Validate vision support before processing images
const effectiveModel = model || 'claude-sonnet-4-20250514';
if (imagePaths && imagePaths.length > 0) {
const supportsVision = ProviderFactory.modelSupportsVision(effectiveModel);
if (!supportsVision) {
throw new Error(
`This model (${effectiveModel}) does not support image input. ` +
`Please switch to a model that supports vision (like Claude models), or remove the images and try again.`
);
}
}
// Check if this planning mode can generate a spec/plan that needs approval
// - spec and full always generate specs
// - lite only generates approval-ready content when requirePlanApproval is true

View File

@@ -0,0 +1,373 @@
/**
* CLI Integration Tests
*
* Comprehensive tests for CLI detection, authentication, and operations
* across all providers (Claude, Codex, Cursor)
*/
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import {
detectCli,
detectAllCLis,
findCommand,
getCliVersion,
getInstallInstructions,
validateCliInstallation,
} from '../lib/cli-detection.js';
import { classifyError, getUserFriendlyErrorMessage } from '../lib/error-handler.js';
describe('CLI Detection Framework', () => {
describe('findCommand', () => {
it('should find existing command', async () => {
// Test with a command that should exist
const result = await findCommand(['node']);
expect(result).toBeTruthy();
});
it('should return null for non-existent command', async () => {
const result = await findCommand(['nonexistent-command-12345']);
expect(result).toBeNull();
});
it('should find first available command from alternatives', async () => {
const result = await findCommand(['nonexistent-command-12345', 'node']);
expect(result).toBeTruthy();
expect(result).toContain('node');
});
});
describe('getCliVersion', () => {
it('should get version for existing command', async () => {
const version = await getCliVersion('node', ['--version'], 5000);
expect(version).toBeTruthy();
expect(typeof version).toBe('string');
});
it('should timeout for non-responsive command', async () => {
await expect(getCliVersion('sleep', ['10'], 1000)).rejects.toThrow();
}, 15000); // Give extra time for test timeout
it("should handle command that doesn't exist", async () => {
await expect(
getCliVersion('nonexistent-command-12345', ['--version'], 2000)
).rejects.toThrow();
});
});
describe('getInstallInstructions', () => {
it('should return instructions for supported platforms', () => {
const claudeInstructions = getInstallInstructions('claude', 'darwin');
expect(claudeInstructions).toContain('brew install');
const codexInstructions = getInstallInstructions('codex', 'linux');
expect(codexInstructions).toContain('npm install');
});
it('should handle unsupported platform', () => {
const instructions = getInstallInstructions('claude', 'unknown-platform' as any);
expect(instructions).toContain('No installation instructions available');
});
});
describe('validateCliInstallation', () => {
it('should validate properly installed CLI', () => {
const cliInfo = {
name: 'Test CLI',
command: 'node',
version: 'v18.0.0',
path: '/usr/bin/node',
installed: true,
authenticated: true,
authMethod: 'cli' as const,
};
const result = validateCliInstallation(cliInfo);
expect(result.valid).toBe(true);
expect(result.issues).toHaveLength(0);
});
it('should detect issues with installation', () => {
const cliInfo = {
name: 'Test CLI',
command: '',
version: '',
path: '',
installed: false,
authenticated: false,
authMethod: 'none' as const,
};
const result = validateCliInstallation(cliInfo);
expect(result.valid).toBe(false);
expect(result.issues.length).toBeGreaterThan(0);
expect(result.issues).toContain('CLI is not installed');
});
});
});
describe('Error Handling System', () => {
describe('classifyError', () => {
it('should classify authentication errors', () => {
const authError = new Error('invalid_api_key: Your API key is invalid');
const result = classifyError(authError, 'claude');
expect(result.type).toBe('authentication');
expect(result.severity).toBe('high');
expect(result.userMessage).toContain('Authentication failed');
expect(result.retryable).toBe(false);
expect(result.provider).toBe('claude');
});
it('should classify billing errors', () => {
const billingError = new Error('credit balance is too low');
const result = classifyError(billingError);
expect(result.type).toBe('billing');
expect(result.severity).toBe('high');
expect(result.userMessage).toContain('insufficient credits');
expect(result.retryable).toBe(false);
});
it('should classify rate limit errors', () => {
const rateLimitError = new Error('Rate limit reached. Try again later.');
const result = classifyError(rateLimitError);
expect(result.type).toBe('rate_limit');
expect(result.severity).toBe('medium');
expect(result.userMessage).toContain('Rate limit reached');
expect(result.retryable).toBe(true);
});
it('should classify network errors', () => {
const networkError = new Error('ECONNREFUSED: Connection refused');
const result = classifyError(networkError);
expect(result.type).toBe('network');
expect(result.severity).toBe('medium');
expect(result.userMessage).toContain('Network connection issue');
expect(result.retryable).toBe(true);
});
it('should handle unknown errors', () => {
const unknownError = new Error('Something completely unexpected happened');
const result = classifyError(unknownError);
expect(result.type).toBe('unknown');
expect(result.severity).toBe('medium');
expect(result.userMessage).toContain('unexpected error');
expect(result.retryable).toBe(true);
});
});
describe('getUserFriendlyErrorMessage', () => {
it('should include provider name in message', () => {
const error = new Error('invalid_api_key');
const message = getUserFriendlyErrorMessage(error, 'claude');
expect(message).toContain('[CLAUDE]');
});
it('should include suggested action when available', () => {
const error = new Error('invalid_api_key');
const message = getUserFriendlyErrorMessage(error);
expect(message).toContain('Verify your API key');
});
});
});
describe('Provider-Specific Tests', () => {
describe('Claude CLI Detection', () => {
it('should detect Claude CLI if installed', async () => {
const result = await detectCli('claude');
if (result.detected) {
expect(result.cli.name).toBe('Claude CLI');
expect(result.cli.installed).toBe(true);
expect(result.cli.command).toBeTruthy();
}
// If not installed, that's also a valid test result
});
it('should handle missing Claude CLI gracefully', async () => {
// This test will pass regardless of whether Claude is installed
const result = await detectCli('claude');
expect(typeof result.detected).toBe('boolean');
expect(Array.isArray(result.issues)).toBe(true);
});
});
describe('Codex CLI Detection', () => {
it('should detect Codex CLI if installed', async () => {
const result = await detectCli('codex');
if (result.detected) {
expect(result.cli.name).toBe('Codex CLI');
expect(result.cli.installed).toBe(true);
expect(result.cli.command).toBeTruthy();
}
});
});
describe('Cursor CLI Detection', () => {
it('should detect Cursor CLI if installed', async () => {
const result = await detectCli('cursor');
if (result.detected) {
expect(result.cli.name).toBe('Cursor CLI');
expect(result.cli.installed).toBe(true);
expect(result.cli.command).toBeTruthy();
}
});
});
});
describe('Integration Tests', () => {
describe('detectAllCLis', () => {
it('should detect all available CLIs', async () => {
const results = await detectAllCLis();
expect(results).toHaveProperty('claude');
expect(results).toHaveProperty('codex');
expect(results).toHaveProperty('cursor');
// Each should have the expected structure
Object.values(results).forEach((result) => {
expect(result).toHaveProperty('cli');
expect(result).toHaveProperty('detected');
expect(result).toHaveProperty('issues');
expect(result.cli).toHaveProperty('name');
expect(result.cli).toHaveProperty('installed');
expect(result.cli).toHaveProperty('authenticated');
});
}, 30000); // Longer timeout for CLI detection
it('should handle concurrent CLI detection', async () => {
// Run detection multiple times concurrently
const promises = [detectAllCLis(), detectAllCLis(), detectAllCLis()];
const results = await Promise.all(promises);
// All should return consistent results
expect(results).toHaveLength(3);
results.forEach((result) => {
expect(result).toHaveProperty('claude');
expect(result).toHaveProperty('codex');
expect(result).toHaveProperty('cursor');
});
}, 45000);
});
});
describe('Error Recovery Tests', () => {
it('should handle partial CLI detection failures', async () => {
// Mock a scenario where some CLIs fail to detect
const results = await detectAllCLis();
// Should still return results for all providers
expect(results).toHaveProperty('claude');
expect(results).toHaveProperty('codex');
expect(results).toHaveProperty('cursor');
// Should provide error information for failures
Object.entries(results).forEach(([provider, result]) => {
if (!result.detected && result.issues.length > 0) {
expect(result.issues.length).toBeGreaterThan(0);
expect(result.issues[0]).toBeTruthy();
}
});
});
it('should handle timeout during CLI detection', async () => {
// Test with very short timeout
const result = await detectCli('claude', { timeout: 1 });
// Should handle gracefully without throwing
expect(typeof result.detected).toBe('boolean');
expect(Array.isArray(result.issues)).toBe(true);
});
});
describe('Security Tests', () => {
it('should not expose sensitive information in error messages', () => {
const errorWithKey = new Error('invalid_api_key: sk-ant-abc123secret456');
const message = getUserFriendlyErrorMessage(errorWithKey);
// Should not expose the actual API key
expect(message).not.toContain('sk-ant-abc123secret456');
expect(message).toContain('Authentication failed');
});
it('should sanitize file paths in error messages', () => {
const errorWithPath = new Error('Permission denied: /home/user/.ssh/id_rsa');
const message = getUserFriendlyErrorMessage(errorWithPath);
// Should not expose sensitive file paths
expect(message).not.toContain('/home/user/.ssh/id_rsa');
});
});
// Performance Tests
describe('Performance Tests', () => {
it('should detect CLIs within reasonable time', async () => {
const startTime = Date.now();
const results = await detectAllCLis();
const endTime = Date.now();
const duration = endTime - startTime;
expect(duration).toBeLessThan(10000); // Should complete in under 10 seconds
expect(results).toHaveProperty('claude');
expect(results).toHaveProperty('codex');
expect(results).toHaveProperty('cursor');
}, 15000);
it('should handle rapid repeated calls', async () => {
// Make multiple rapid calls
const promises = Array.from({ length: 10 }, () => detectAllCLis());
const results = await Promise.all(promises);
// All should complete successfully
expect(results).toHaveLength(10);
results.forEach((result) => {
expect(result).toHaveProperty('claude');
expect(result).toHaveProperty('codex');
expect(result).toHaveProperty('cursor');
});
}, 60000);
});
// Edge Cases
describe('Edge Cases', () => {
it('should handle empty CLI names', async () => {
await expect(detectCli('' as any)).rejects.toThrow();
});
it('should handle null CLI names', async () => {
await expect(detectCli(null as any)).rejects.toThrow();
});
it('should handle undefined CLI names', async () => {
await expect(detectCli(undefined as any)).rejects.toThrow();
});
it('should handle malformed error objects', () => {
const testCases = [
null,
undefined,
'',
123,
[],
{ nested: { error: { message: 'test' } } },
{ error: 'simple string error' },
];
testCases.forEach((error) => {
expect(() => {
const result = classifyError(error);
expect(result).toHaveProperty('type');
expect(result).toHaveProperty('severity');
expect(result).toHaveProperty('userMessage');
}).not.toThrow();
});
});
});