263 lines · 10.1 KB · TypeScript
RawDownload
import { spawn } from 'child_process';import { promisify } from 'util';import { execFile as execFileCallback } from 'child_process';import { writeFileSync, unlinkSync, readFileSync } from 'fs';import { join } from 'path';import { tmpdir } from 'os';import type { Runner, RunnerResult, RunnerOptions, RunnerStatus, RunnerErrorType } from './types.js';import { debug, debugPrompt, debugResponse } from '../logger.js';import { isValidModelName } from '../config.js'; const execFile = promisify(execFileCallback); // Validate model name to prevent injection (defense in depth)// Claude Code CLI binary namesconst CLAUDE_BINARIES = ['claude', 'claude-code']; // Permission error patterns to detect// These indicate claude-code is blocked from writing and we should bail out immediatelyconst PERMISSION_ERROR_PATTERNS = [  /requested permissions? to write/i,  /haven't granted it yet/i,  /permission.*denied/i,  /Unable to write.*permission/i,  /persistent permission error/i,]; /** * Check if output contains permission errors * Returns true if claude-code is blocked from making changes due to permissions */function hasPermissionError(output: string): boolean {  return PERMISSION_ERROR_PATTERNS.some(pattern => pattern.test(output));} /** * Check if running as root/sudo */function isRunningAsRoot(): boolean {  return process.getuid?.() === 0;} /** * Check if --dangerously-skip-permissions should be used *  * Always returns true for prr because: * - prr is an automated fixer tool that needs to write files * - Without this flag, Claude Code waits for interactive permission approval * - Non-interactive execution makes Claude Code useless without this flag *  * Can be disabled via PRR_CLAUDE_SKIP_PERMISSIONS=0 if needed *  * NOTE: Claude Code refuses this flag when running as root for security reasons. * We detect this early and fail with a clear message rather than looping. */function shouldSkipPermissions(): boolean {  const envValue = process.env.PRR_CLAUDE_SKIP_PERMISSIONS;  // Default to true (enabled), only disable if explicitly set to '0' or 'false'  return envValue !== '0' && envValue !== 'false';} export class ClaudeCodeRunner implements Runner {  name = 'claude-code';  displayName = 'Claude Code';  installHint = 'npm install -g @anthropic-ai/claude-code';  private binaryPath: string = 'claude';   async isAvailable(): Promise<boolean> {    for (const binary of CLAUDE_BINARIES) {      try {        await execFile('which', [binary]);        this.binaryPath = binary;        debug(`Found Claude Code CLI at: ${binary}`);        return true;      } catch {        // Try next binary      }    }    debug('Claude Code CLI not found', { tried: CLAUDE_BINARIES });    return false;  }   async checkStatus(): Promise<RunnerStatus> {    const installed = await this.isAvailable();    if (!installed) {      return { installed: false, ready: false, error: 'Not installed (install: npm i -g @anthropic-ai/claude-code)' };    }     // Claude Code refuses --dangerously-skip-permissions when running as root.    // Treat it as not ready so auto-rotation skips it.    if (shouldSkipPermissions() && isRunningAsRoot()) {      return {        installed: true,        ready: false,        error: 'Claude Code refuses --dangerously-skip-permissions when running as root. Run prr as a non-root user, or use a different fixer tool (e.g., --tool=aider).',      };    }     // Check version    let version: string | undefined;    try {      const { stdout, stderr } = await execFile(this.binaryPath, ['--version']);      version = (stdout || stderr).trim();    } catch {      // Version check might fail    }     // Check if authenticated    try {      const { stdout, stderr } = await execFile(this.binaryPath, ['--help']);      const output = stdout + stderr;      if (output.includes('login') || output.includes('ANTHROPIC_API_KEY')) {        // Check if API key is set        if (!process.env.ANTHROPIC_API_KEY) {          return { installed: true, ready: false, version, error: 'ANTHROPIC_API_KEY not set' };        }      }      return { installed: true, ready: true, version };    } catch (e) {      const error = e instanceof Error ? e.message : String(e);      return { installed: true, ready: false, version, error };    }  }   async run(workdir: string, prompt: string, options?: RunnerOptions): Promise<RunnerResult> {    // Guard: Don't run with empty prompt    if (!prompt || prompt.trim().length === 0) {      debug('Empty prompt - skipping claude-code run');      return { success: false, output: '', error: 'No prompt provided (nothing to fix)' };    }     // Guard: Claude Code refuses --dangerously-skip-permissions when running as root    // Detect this early and fail with a clear message rather than looping    const skipPermissions = shouldSkipPermissions();    if (skipPermissions && isRunningAsRoot()) {      debug('Running as root with skip-permissions - Claude Code will refuse');      return {        success: false,        output: '',        error: 'Claude Code refuses --dangerously-skip-permissions when running as root. Run prr as a non-root user, or use a different fixer tool (e.g., --tool=aider).',        errorType: 'permission',      };    }     if (options?.model && !isValidModelName(options.model)) {      return { success: false, output: '', error: `Invalid model name: ${options.model}` };    }     // Guard: Reject non-Anthropic models — Claude Code only supports Anthropic models.    if (options?.model && /^(gpt|o[34]|codex|davinci|gemini)/i.test(options.model)) {      const msg = `Model "${options.model}" is not an Anthropic model — skipping for Claude Code`;      debug(msg);      return { success: false, output: '', error: msg, errorType: 'model' };    }     const promptFile = join(tmpdir(), `prr-prompt.${process.pid}.${Date.now()}.txt`);    writeFileSync(promptFile, prompt, { encoding: 'utf-8', mode: 0o600 });    debug('Wrote prompt to file', { promptFile, length: prompt.length });    const promptSlug = debugPrompt('claude-code', prompt, { workdir, model: options?.model, skipPermissions });    const cleanupPromptFile = () => {      try {        unlinkSync(promptFile);      } catch {        // Ignore cleanup errors      }    };     return new Promise((resolve) => {      // Build args array safely (no shell interpolation)      const args: string[] = ['--print'];       // Add --dangerously-skip-permissions if enabled      // This allows fully non-interactive operation (CI/CD, automated workflows)      if (skipPermissions) {        args.push('--dangerously-skip-permissions');        debug('Using --dangerously-skip-permissions mode');      }       // Add model if specified      if (options?.model) {        args.push('--model', options.model);      }       const modelInfo = options?.model ? ` (model: ${options.model})` : '';      const permInfo = skipPermissions ? ' [skip-permissions]' : '';      console.log(`\nRunning: ${this.binaryPath}${modelInfo}${permInfo} [prompt]\n`);      debug('Claude Code command', { binary: this.binaryPath, workdir, model: options?.model, skipPermissions, promptLength: prompt.length });       // Pass prompt via stdin to avoid E2BIG error with large prompts      const child = spawn(this.binaryPath, args, {        cwd: workdir,        stdio: ['pipe', 'pipe', 'pipe'],        env: { ...process.env },      });       // Write prompt to stdin      const promptContent = readFileSync(promptFile, 'utf-8');      child.stdin?.write(promptContent);      child.stdin?.end();       let stdout = '';      let stderr = '';       child.stdout?.on('data', (data) => {        const str = data.toString();        stdout += str;        process.stdout.write(str);      });       child.stderr?.on('data', (data) => {        const str = data.toString();        stderr += str;        process.stderr.write(str);      });       child.on('close', (code) => {        cleanupPromptFile();                // Log response to debug file        debugResponse(promptSlug, 'claude-code', stdout, { exitCode: code, stderrLength: stderr.length });         // Check for permission errors in output (even on success exit code)        // This catches cases where claude-code "succeeded" but couldn't actually write        const combinedOutput = stdout + stderr;        if (hasPermissionError(combinedOutput)) {          debug('Permission error detected in output', { exitCode: code });          const permissionMessage = skipPermissions            ? 'Permission denied - claude-code cannot write to files (--dangerously-skip-permissions was used but still failed).'            : 'Permission denied - claude-code cannot write to files. Set PRR_CLAUDE_SKIP_PERMISSIONS=1 to bypass permission prompts.';          resolve({            success: false,            output: stdout,            error: permissionMessage,            errorType: 'permission',          });          return;        }         if (code === 0) {          resolve({ success: true, output: stdout });        } else {          // Determine error type for non-permission failures          let errorType: RunnerErrorType = 'tool';          const combinedLower = (stdout + stderr).toLowerCase();          // Quota/rate-limit must be checked BEFORE auth — rotate, don't bail          if (/quota exceeded|rate.?limit|too many requests|billing|exceeded.*plan/i.test(combinedLower)) {            errorType = 'quota';          } else if (combinedLower.includes('api key') || combinedLower.includes('unauthorized') || combinedLower.includes('authentication')) {            errorType = 'auth';          } else if (/does not exist|model.*not found|you do not have access/i.test(combinedLower)) {            errorType = 'auth'; // Model access errors bail immediately, same as auth          }          resolve({ success: false, output: stdout, error: stderr || `Process exited with code ${code}`, errorType });        }      });       child.on('error', (err) => {        cleanupPromptFile();        // Spawn errors (e.g., EACCES on binary) are tool errors        resolve({ success: false, output: stdout, error: err.message, errorType: 'tool' });      });    });  }}

Keyboard shortcuts

General

Show keyboard shortcuts
?
Focus the current search
/

Issues and pull requests

Open filters
f

Issues, pull requests and Inbox

Select next item
j↓
Select previous item
k↑
Open selected item
Enter
Dismiss filters or clear selection
Esc

Repository code

Go to file
t