mirror of
https://github.com/AutoMaker-Org/automaker.git
synced 2026-02-01 20:23:36 +00:00
feat: enhance authentication handling and API key validation
- Added optional API keys for OpenAI and Cursor to the .env.example file. - Implemented API key validation in CursorProvider to ensure valid keys are used. - Introduced rate limiting in Claude and Codex authentication routes to prevent abuse. - Created secure environment handling for authentication without modifying process.env. - Improved error handling and logging for authentication processes, enhancing user feedback. These changes improve the security and reliability of the authentication mechanisms across the application.
This commit is contained in:
@@ -8,6 +8,20 @@
|
||||
# Your Anthropic API key for Claude models
|
||||
ANTHROPIC_API_KEY=sk-ant-...
|
||||
|
||||
# ============================================
|
||||
# OPTIONAL - Additional API Keys
|
||||
# ============================================
|
||||
|
||||
# OpenAI API key for Codex/GPT models
|
||||
OPENAI_API_KEY=sk-...
|
||||
|
||||
# Cursor API key for Cursor models
|
||||
CURSOR_API_KEY=...
|
||||
|
||||
# OAuth credentials for CLI authentication (extracted automatically)
|
||||
CLAUDE_OAUTH_CREDENTIALS=
|
||||
CURSOR_AUTH_TOKEN=
|
||||
|
||||
# ============================================
|
||||
# OPTIONAL - Security
|
||||
# ============================================
|
||||
|
||||
263
apps/server/src/lib/auth-utils.ts
Normal file
263
apps/server/src/lib/auth-utils.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
447
apps/server/src/lib/cli-detection.ts
Normal file
447
apps/server/src/lib/cli-detection.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
414
apps/server/src/lib/error-handler.ts
Normal file
414
apps/server/src/lib/error-handler.ts
Normal 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;
|
||||
};
|
||||
}
|
||||
173
apps/server/src/lib/permission-enforcer.ts
Normal file
173
apps/server/src/lib/permission-enforcer.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
@@ -684,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' ||
|
||||
@@ -906,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' };
|
||||
}
|
||||
|
||||
|
||||
@@ -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:', {
|
||||
|
||||
@@ -8,8 +8,16 @@ 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;
|
||||
@@ -75,138 +83,169 @@ function isRateLimitError(text: string): boolean {
|
||||
export function createVerifyCodexAuthHandler() {
|
||||
return async (req: Request, res: Response): Promise<void> => {
|
||||
const { authMethod } = req.body as { authMethod?: 'cli' | 'api_key' };
|
||||
|
||||
// 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);
|
||||
|
||||
const originalKey = process.env[OPENAI_API_KEY_ENV];
|
||||
|
||||
try {
|
||||
if (authMethod === 'cli') {
|
||||
delete process.env[OPENAI_API_KEY_ENV];
|
||||
} else if (authMethod === 'api_key') {
|
||||
// Create secure environment without modifying process.env
|
||||
const authEnv = createSecureAuthEnv(authMethod || 'api_key', undefined, 'openai');
|
||||
|
||||
// For API key auth, use stored key
|
||||
if (authMethod === 'api_key') {
|
||||
const storedApiKey = getApiKey('openai');
|
||||
if (storedApiKey) {
|
||||
process.env[OPENAI_API_KEY_ENV] = storedApiKey;
|
||||
} else if (!process.env[OPENAI_API_KEY_ENV]) {
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
if (authMethod === 'cli') {
|
||||
const authIndicators = await getCodexAuthIndicators();
|
||||
if (!authIndicators.hasOAuthToken && !authIndicators.hasApiKey) {
|
||||
res.json({
|
||||
success: true,
|
||||
authenticated: false,
|
||||
error: ERROR_CLI_AUTH_REQUIRED,
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
// Create session and temporary environment override
|
||||
AuthSessionManager.createSession(sessionId, authMethod || 'api_key', undefined, 'openai');
|
||||
const cleanupEnv = createTempEnvOverride(authEnv);
|
||||
|
||||
// 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;
|
||||
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;
|
||||
}
|
||||
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;
|
||||
// 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 (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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
res.json(response);
|
||||
return;
|
||||
}
|
||||
if (!receivedAnyContent) {
|
||||
res.json({
|
||||
success: true,
|
||||
authenticated: false,
|
||||
error: 'No response received from Codex. Please check your authentication.',
|
||||
});
|
||||
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();
|
||||
}
|
||||
|
||||
res.json({ success: true, authenticated: true });
|
||||
} catch (error: unknown) {
|
||||
const errMessage = error instanceof Error ? error.message : String(error);
|
||||
logger.error('[Setup] Codex auth verification error:', errMessage);
|
||||
@@ -222,11 +261,8 @@ export function createVerifyCodexAuthHandler() {
|
||||
});
|
||||
} finally {
|
||||
clearTimeout(timeoutId);
|
||||
if (originalKey !== undefined) {
|
||||
process.env[OPENAI_API_KEY_ENV] = originalKey;
|
||||
} else {
|
||||
delete process.env[OPENAI_API_KEY_ENV];
|
||||
}
|
||||
// Clean up session
|
||||
AuthSessionManager.destroySession(sessionId);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
373
apps/server/src/tests/cli-integration.test.ts
Normal file
373
apps/server/src/tests/cli-integration.test.ts
Normal 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');
|
||||
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();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -71,28 +71,110 @@ export function getClaudeCliPaths(): string[] {
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get NVM-installed Node.js bin paths for CLI tools
|
||||
*/
|
||||
function getNvmBinPaths(): string[] {
|
||||
const nvmDir = process.env.NVM_DIR || path.join(os.homedir(), '.nvm');
|
||||
const versionsDir = path.join(nvmDir, 'versions', 'node');
|
||||
|
||||
try {
|
||||
if (!fsSync.existsSync(versionsDir)) {
|
||||
return [];
|
||||
}
|
||||
const versions = fsSync.readdirSync(versionsDir);
|
||||
return versions.map((version) => path.join(versionsDir, version, 'bin'));
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get fnm (Fast Node Manager) installed Node.js bin paths
|
||||
*/
|
||||
function getFnmBinPaths(): string[] {
|
||||
const homeDir = os.homedir();
|
||||
const possibleFnmDirs = [
|
||||
path.join(homeDir, '.local', 'share', 'fnm', 'node-versions'),
|
||||
path.join(homeDir, '.fnm', 'node-versions'),
|
||||
// macOS
|
||||
path.join(homeDir, 'Library', 'Application Support', 'fnm', 'node-versions'),
|
||||
];
|
||||
|
||||
const binPaths: string[] = [];
|
||||
|
||||
for (const fnmDir of possibleFnmDirs) {
|
||||
try {
|
||||
if (!fsSync.existsSync(fnmDir)) {
|
||||
continue;
|
||||
}
|
||||
const versions = fsSync.readdirSync(fnmDir);
|
||||
for (const version of versions) {
|
||||
binPaths.push(path.join(fnmDir, version, 'installation', 'bin'));
|
||||
}
|
||||
} catch {
|
||||
// Ignore errors for this directory
|
||||
}
|
||||
}
|
||||
|
||||
return binPaths;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get common paths where Codex CLI might be installed
|
||||
*/
|
||||
export function getCodexCliPaths(): string[] {
|
||||
const isWindows = process.platform === 'win32';
|
||||
const homeDir = os.homedir();
|
||||
|
||||
if (isWindows) {
|
||||
const appData = process.env.APPDATA || path.join(os.homedir(), 'AppData', 'Roaming');
|
||||
const appData = process.env.APPDATA || path.join(homeDir, 'AppData', 'Roaming');
|
||||
const localAppData = process.env.LOCALAPPDATA || path.join(homeDir, 'AppData', 'Local');
|
||||
return [
|
||||
path.join(os.homedir(), '.local', 'bin', 'codex.exe'),
|
||||
path.join(homeDir, '.local', 'bin', 'codex.exe'),
|
||||
path.join(appData, 'npm', 'codex.cmd'),
|
||||
path.join(appData, 'npm', 'codex'),
|
||||
path.join(appData, '.npm-global', 'bin', 'codex.cmd'),
|
||||
path.join(appData, '.npm-global', 'bin', 'codex'),
|
||||
// Volta on Windows
|
||||
path.join(homeDir, '.volta', 'bin', 'codex.exe'),
|
||||
// pnpm on Windows
|
||||
path.join(localAppData, 'pnpm', 'codex.cmd'),
|
||||
path.join(localAppData, 'pnpm', 'codex'),
|
||||
];
|
||||
}
|
||||
|
||||
// Include NVM bin paths for codex installed via npm global under NVM
|
||||
const nvmBinPaths = getNvmBinPaths().map((binPath) => path.join(binPath, 'codex'));
|
||||
|
||||
// Include fnm bin paths
|
||||
const fnmBinPaths = getFnmBinPaths().map((binPath) => path.join(binPath, 'codex'));
|
||||
|
||||
// pnpm global bin path
|
||||
const pnpmHome = process.env.PNPM_HOME || path.join(homeDir, '.local', 'share', 'pnpm');
|
||||
|
||||
return [
|
||||
path.join(os.homedir(), '.local', 'bin', 'codex'),
|
||||
// Standard locations
|
||||
path.join(homeDir, '.local', 'bin', 'codex'),
|
||||
'/opt/homebrew/bin/codex',
|
||||
'/usr/local/bin/codex',
|
||||
path.join(os.homedir(), '.npm-global', 'bin', 'codex'),
|
||||
'/usr/bin/codex',
|
||||
path.join(homeDir, '.npm-global', 'bin', 'codex'),
|
||||
// Linuxbrew
|
||||
'/home/linuxbrew/.linuxbrew/bin/codex',
|
||||
// Volta
|
||||
path.join(homeDir, '.volta', 'bin', 'codex'),
|
||||
// pnpm global
|
||||
path.join(pnpmHome, 'codex'),
|
||||
// Yarn global
|
||||
path.join(homeDir, '.yarn', 'bin', 'codex'),
|
||||
path.join(homeDir, '.config', 'yarn', 'global', 'node_modules', '.bin', 'codex'),
|
||||
// Snap packages
|
||||
'/snap/bin/codex',
|
||||
// NVM paths
|
||||
...nvmBinPaths,
|
||||
// fnm paths
|
||||
...fnmBinPaths,
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
@@ -217,6 +217,7 @@ export interface CursorAuthStatus {
|
||||
authenticated: boolean;
|
||||
method: 'login' | 'api_key' | 'none';
|
||||
hasCredentialsFile?: boolean;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user