From 8dc50dc76757a9bdd2433530321dbc4c991e0ea2 Mon Sep 17 00:00:00 2001 From: Roo Code Date: Sat, 12 Jul 2025 16:43:07 +0000 Subject: [PATCH 1/3] fix: resolve Windows ENAMETOOLONG error in Claude Code integration (#5631) - Use environment variable CLAUDE_CODE_SYSTEM_PROMPT for long system prompts (>7000 chars) - Prevents Windows command line length limit (~8191 chars) from causing ENAMETOOLONG errors - Maintains backward compatibility by using command line args for short prompts - Add comprehensive tests for both short and long system prompt scenarios - Follows existing pattern used for messages parameter (stdin vs command line) --- .../claude-code/__tests__/run.spec.ts | 164 ++++++++++++++++++ src/integrations/claude-code/run.ts | 48 +++-- 2 files changed, 198 insertions(+), 14 deletions(-) diff --git a/src/integrations/claude-code/__tests__/run.spec.ts b/src/integrations/claude-code/__tests__/run.spec.ts index d2fda08fc0f..dd826e64787 100644 --- a/src/integrations/claude-code/__tests__/run.spec.ts +++ b/src/integrations/claude-code/__tests__/run.spec.ts @@ -287,4 +287,168 @@ describe("runClaudeCode", () => { consoleErrorSpy.mockRestore() await generator.return(undefined) }) + + test("should use command line argument for short system prompts", async () => { + const { runClaudeCode } = await import("../run") + const shortSystemPrompt = "You are a helpful assistant" + const options = { + systemPrompt: shortSystemPrompt, + messages: [{ role: "user" as const, content: "Hello" }], + } + + const generator = runClaudeCode(options) + + // Consume at least one item to trigger process spawn + await generator.next() + + // Clean up the generator + await generator.return(undefined) + + // Verify execa was called with system prompt as command line argument + const [, args, execaOptions] = mockExeca.mock.calls[0] + expect(args).toContain("--system-prompt") + expect(args).toContain(shortSystemPrompt) + + // Verify no environment variable was set for short prompt + expect(execaOptions.env?.CLAUDE_CODE_SYSTEM_PROMPT).toBeUndefined() + }) + + test("should use environment variable for long system prompts to avoid Windows ENAMETOOLONG error", async () => { + const { runClaudeCode } = await import("../run") + // Create a system prompt longer than MAX_COMMAND_LINE_LENGTH (7000 chars) + const longSystemPrompt = "You are a helpful assistant. " + "A".repeat(7000) + const options = { + systemPrompt: longSystemPrompt, + messages: [{ role: "user" as const, content: "Hello" }], + } + + const generator = runClaudeCode(options) + + // Consume at least one item to trigger process spawn + await generator.next() + + // Clean up the generator + await generator.return(undefined) + + // Verify execa was called without --system-prompt in command line arguments + const [, args, execaOptions] = mockExeca.mock.calls[0] + expect(args).not.toContain("--system-prompt") + expect(args).not.toContain(longSystemPrompt) + + // Verify environment variable was set with the long system prompt + expect(execaOptions.env?.CLAUDE_CODE_SYSTEM_PROMPT).toBe(longSystemPrompt) + }) + + test("should handle exactly MAX_COMMAND_LINE_LENGTH system prompt using command line", async () => { + const { runClaudeCode } = await import("../run") + // Create a system prompt exactly at the threshold (7000 chars) + const exactLengthPrompt = "A".repeat(7000) + const options = { + systemPrompt: exactLengthPrompt, + messages: [{ role: "user" as const, content: "Hello" }], + } + + const generator = runClaudeCode(options) + + // Consume at least one item to trigger process spawn + await generator.next() + + // Clean up the generator + await generator.return(undefined) + + // Verify execa was called with system prompt as command line argument (at threshold) + const [, args, execaOptions] = mockExeca.mock.calls[0] + expect(args).toContain("--system-prompt") + expect(args).toContain(exactLengthPrompt) + + // Verify no environment variable was set + expect(execaOptions.env?.CLAUDE_CODE_SYSTEM_PROMPT).toBeUndefined() + }) + + test("should handle system prompt one character over threshold using environment variable", async () => { + const { runClaudeCode } = await import("../run") + // Create a system prompt one character over the threshold (7001 chars) + const overThresholdPrompt = "A".repeat(7001) + const options = { + systemPrompt: overThresholdPrompt, + messages: [{ role: "user" as const, content: "Hello" }], + } + + const generator = runClaudeCode(options) + + // Consume at least one item to trigger process spawn + await generator.next() + + // Clean up the generator + await generator.return(undefined) + + // Verify execa was called without --system-prompt in command line arguments + const [, args, execaOptions] = mockExeca.mock.calls[0] + expect(args).not.toContain("--system-prompt") + expect(args).not.toContain(overThresholdPrompt) + + // Verify environment variable was set + expect(execaOptions.env?.CLAUDE_CODE_SYSTEM_PROMPT).toBe(overThresholdPrompt) + }) + + test("should preserve existing environment variables when using CLAUDE_CODE_SYSTEM_PROMPT", async () => { + const { runClaudeCode } = await import("../run") + + // Mock process.env to have some existing variables + const originalEnv = process.env + process.env = { + ...originalEnv, + EXISTING_VAR: "existing_value", + PATH: "/usr/bin:/bin", + } + + const longSystemPrompt = "You are a helpful assistant. " + "A".repeat(7000) + const options = { + systemPrompt: longSystemPrompt, + messages: [{ role: "user" as const, content: "Hello" }], + } + + const generator = runClaudeCode(options) + + // Consume at least one item to trigger process spawn + await generator.next() + + // Clean up the generator + await generator.return(undefined) + + // Verify environment variables include both existing and new ones + const [, , execaOptions] = mockExeca.mock.calls[0] + expect(execaOptions.env).toEqual({ + ...process.env, + CLAUDE_CODE_MAX_OUTPUT_TOKENS: expect.any(String), // Always set by the implementation + CLAUDE_CODE_SYSTEM_PROMPT: longSystemPrompt, + }) + + // Restore original environment + process.env = originalEnv + }) + + test("should work with empty system prompt", async () => { + const { runClaudeCode } = await import("../run") + const options = { + systemPrompt: "", + messages: [{ role: "user" as const, content: "Hello" }], + } + + const generator = runClaudeCode(options) + + // Consume at least one item to trigger process spawn + await generator.next() + + // Clean up the generator + await generator.return(undefined) + + // Verify execa was called with empty system prompt as command line argument + const [, args, execaOptions] = mockExeca.mock.calls[0] + expect(args).toContain("--system-prompt") + expect(args).toContain("") + + // Verify no environment variable was set + expect(execaOptions.env?.CLAUDE_CODE_SYSTEM_PROMPT).toBeUndefined() + }) }) diff --git a/src/integrations/claude-code/run.ts b/src/integrations/claude-code/run.ts index 59a5bf701a2..8bc66cb2c81 100644 --- a/src/integrations/claude-code/run.ts +++ b/src/integrations/claude-code/run.ts @@ -110,6 +110,10 @@ const claudeCodeTools = [ const CLAUDE_CODE_TIMEOUT = 600000 // 10 minutes +// Windows has a command line length limit of ~8191 characters +// If the system prompt is too long, we'll use an environment variable instead +const MAX_COMMAND_LINE_LENGTH = 7000 // Conservative limit to account for other arguments + function runProcess({ systemPrompt, messages, @@ -119,10 +123,17 @@ function runProcess({ }: ClaudeCodeOptions & { maxOutputTokens?: number }) { const claudePath = path || "claude" - const args = [ - "-p", - "--system-prompt", - systemPrompt, + // Check if system prompt is too long for command line + const useEnvForSystemPrompt = systemPrompt.length > MAX_COMMAND_LINE_LENGTH + + const args = ["-p"] + + // Only add --system-prompt to command line if it's short enough + if (!useEnvForSystemPrompt) { + args.push("--system-prompt", systemPrompt) + } + + args.push( "--verbose", "--output-format", "stream-json", @@ -131,32 +142,41 @@ function runProcess({ // Roo Code will handle recursive calls "--max-turns", "1", - ] + ) if (modelId) { args.push("--model", modelId) } + const env: Record = { + ...process.env, + // Use the configured value, or the environment variable, or default to CLAUDE_CODE_DEFAULT_MAX_OUTPUT_TOKENS + CLAUDE_CODE_MAX_OUTPUT_TOKENS: + maxOutputTokens?.toString() || + process.env.CLAUDE_CODE_MAX_OUTPUT_TOKENS || + CLAUDE_CODE_DEFAULT_MAX_OUTPUT_TOKENS.toString(), + } + + // If system prompt is too long, pass it via environment variable + if (useEnvForSystemPrompt) { + env.CLAUDE_CODE_SYSTEM_PROMPT = systemPrompt + } + const child = execa(claudePath, args, { stdin: "pipe", stdout: "pipe", stderr: "pipe", - env: { - ...process.env, - // Use the configured value, or the environment variable, or default to CLAUDE_CODE_DEFAULT_MAX_OUTPUT_TOKENS - CLAUDE_CODE_MAX_OUTPUT_TOKENS: - maxOutputTokens?.toString() || - process.env.CLAUDE_CODE_MAX_OUTPUT_TOKENS || - CLAUDE_CODE_DEFAULT_MAX_OUTPUT_TOKENS.toString(), - }, + env, cwd, maxBuffer: 1024 * 1024 * 1000, timeout: CLAUDE_CODE_TIMEOUT, }) // Write messages to stdin after process is spawned - // This avoids the E2BIG error on Linux when passing large messages as command line arguments + // This avoids the E2BIG error on Linux and ENAMETOOLONG error on Windows when passing large data as command line arguments // Linux has a per-argument limit of ~128KiB for execve() system calls + // Windows has a total command line length limit of ~8191 characters + // For system prompts, we use environment variables when they exceed the safe limit const messagesJson = JSON.stringify(messages) // Use setImmediate to ensure the process has been spawned before writing to stdin From d0f36c0f78894a98b16db96d05ddbd1849a3fdfd Mon Sep 17 00:00:00 2001 From: Roo Code Date: Mon, 14 Jul 2025 08:36:42 +0000 Subject: [PATCH 2/3] fix: replace CLAUDE_CODE_SYSTEM_PROMPT env var with temporary file approach - Remove undocumented CLAUDE_CODE_SYSTEM_PROMPT environment variable usage - Implement temporary file solution for long system prompts (>7000 chars) - Use --system-prompt @filepath pattern for file-based input - Add proper cleanup for temporary files using vscode.workspace.fs - Fix parameter naming conflict between path module and claudeCodePath parameter - Update tests to verify temporary file usage and proper cleanup - Maintain backward compatibility for short system prompts using command line args Resolves Windows ENAMETOOLONG error without relying on undocumented features. --- .../claude-code/__tests__/run.spec.ts | 77 ++++++++++++++----- src/integrations/claude-code/run.ts | 68 ++++++++++++---- 2 files changed, 111 insertions(+), 34 deletions(-) diff --git a/src/integrations/claude-code/__tests__/run.spec.ts b/src/integrations/claude-code/__tests__/run.spec.ts index dd826e64787..a538e452140 100644 --- a/src/integrations/claude-code/__tests__/run.spec.ts +++ b/src/integrations/claude-code/__tests__/run.spec.ts @@ -1,4 +1,4 @@ -import { describe, test, expect, vi, beforeEach } from "vitest" +import { describe, test, expect, vi, beforeEach, afterEach } from "vitest" // Mock vscode workspace vi.mock("vscode", () => ({ @@ -10,6 +10,13 @@ vi.mock("vscode", () => ({ }, }, ], + fs: { + writeFile: vi.fn().mockResolvedValue(undefined), + delete: vi.fn().mockResolvedValue(undefined), + }, + }, + Uri: { + file: vi.fn((path: string) => ({ fsPath: path })), }, })) @@ -86,6 +93,15 @@ vi.mock("readline", () => ({ }, })) +// Mock path and os modules +vi.mock("path", () => ({ + join: vi.fn((...args: string[]) => args.join("/")), +})) + +vi.mock("os", () => ({ + tmpdir: vi.fn(() => "/tmp"), +})) + describe("runClaudeCode", () => { beforeEach(() => { vi.clearAllMocks() @@ -313,7 +329,7 @@ describe("runClaudeCode", () => { expect(execaOptions.env?.CLAUDE_CODE_SYSTEM_PROMPT).toBeUndefined() }) - test("should use environment variable for long system prompts to avoid Windows ENAMETOOLONG error", async () => { + test("should use temporary file for long system prompts to avoid Windows ENAMETOOLONG error", async () => { const { runClaudeCode } = await import("../run") // Create a system prompt longer than MAX_COMMAND_LINE_LENGTH (7000 chars) const longSystemPrompt = "You are a helpful assistant. " + "A".repeat(7000) @@ -330,13 +346,23 @@ describe("runClaudeCode", () => { // Clean up the generator await generator.return(undefined) - // Verify execa was called without --system-prompt in command line arguments + // Verify execa was called with --system-prompt @filepath pattern const [, args, execaOptions] = mockExeca.mock.calls[0] - expect(args).not.toContain("--system-prompt") + expect(args).toContain("--system-prompt") + + // Find the system prompt argument + const systemPromptIndex = args.indexOf("--system-prompt") + expect(systemPromptIndex).toBeGreaterThan(-1) + const systemPromptArg = args[systemPromptIndex + 1] + + // Verify it uses the @filepath pattern for temp files + expect(systemPromptArg).toMatch(/^@.*claude-system-prompt-.*\.txt$/) + + // Verify the long system prompt is not directly in the arguments expect(args).not.toContain(longSystemPrompt) - // Verify environment variable was set with the long system prompt - expect(execaOptions.env?.CLAUDE_CODE_SYSTEM_PROMPT).toBe(longSystemPrompt) + // Verify no environment variable was set for system prompt + expect(execaOptions.env?.CLAUDE_CODE_SYSTEM_PROMPT).toBeUndefined() }) test("should handle exactly MAX_COMMAND_LINE_LENGTH system prompt using command line", async () => { @@ -361,11 +387,13 @@ describe("runClaudeCode", () => { expect(args).toContain("--system-prompt") expect(args).toContain(exactLengthPrompt) - // Verify no environment variable was set - expect(execaOptions.env?.CLAUDE_CODE_SYSTEM_PROMPT).toBeUndefined() + // Verify no temporary file was used (no @ prefix) + const systemPromptIndex = args.indexOf("--system-prompt") + const systemPromptArg = args[systemPromptIndex + 1] + expect(systemPromptArg).not.toMatch(/^@/) }) - test("should handle system prompt one character over threshold using environment variable", async () => { + test("should handle system prompt one character over threshold using temporary file", async () => { const { runClaudeCode } = await import("../run") // Create a system prompt one character over the threshold (7001 chars) const overThresholdPrompt = "A".repeat(7001) @@ -382,16 +410,25 @@ describe("runClaudeCode", () => { // Clean up the generator await generator.return(undefined) - // Verify execa was called without --system-prompt in command line arguments + // Verify execa was called with --system-prompt @filepath pattern const [, args, execaOptions] = mockExeca.mock.calls[0] - expect(args).not.toContain("--system-prompt") + expect(args).toContain("--system-prompt") + + // Find the system prompt argument + const systemPromptIndex = args.indexOf("--system-prompt") + const systemPromptArg = args[systemPromptIndex + 1] + + // Verify it uses the @filepath pattern for temp files + expect(systemPromptArg).toMatch(/^@.*claude-system-prompt-.*\.txt$/) + + // Verify the long system prompt is not directly in the arguments expect(args).not.toContain(overThresholdPrompt) - // Verify environment variable was set - expect(execaOptions.env?.CLAUDE_CODE_SYSTEM_PROMPT).toBe(overThresholdPrompt) + // Verify no environment variable was set + expect(execaOptions.env?.CLAUDE_CODE_SYSTEM_PROMPT).toBeUndefined() }) - test("should preserve existing environment variables when using CLAUDE_CODE_SYSTEM_PROMPT", async () => { + test("should preserve existing environment variables when using temporary files", async () => { const { runClaudeCode } = await import("../run") // Mock process.env to have some existing variables @@ -416,14 +453,16 @@ describe("runClaudeCode", () => { // Clean up the generator await generator.return(undefined) - // Verify environment variables include both existing and new ones + // Verify environment variables include existing ones but no CLAUDE_CODE_SYSTEM_PROMPT const [, , execaOptions] = mockExeca.mock.calls[0] expect(execaOptions.env).toEqual({ ...process.env, CLAUDE_CODE_MAX_OUTPUT_TOKENS: expect.any(String), // Always set by the implementation - CLAUDE_CODE_SYSTEM_PROMPT: longSystemPrompt, }) + // Verify no system prompt environment variable was set + expect(execaOptions.env?.CLAUDE_CODE_SYSTEM_PROMPT).toBeUndefined() + // Restore original environment process.env = originalEnv }) @@ -448,7 +487,9 @@ describe("runClaudeCode", () => { expect(args).toContain("--system-prompt") expect(args).toContain("") - // Verify no environment variable was set - expect(execaOptions.env?.CLAUDE_CODE_SYSTEM_PROMPT).toBeUndefined() + // Verify no temporary file was used (no @ prefix) + const systemPromptIndex = args.indexOf("--system-prompt") + const systemPromptArg = args[systemPromptIndex + 1] + expect(systemPromptArg).not.toMatch(/^@/) }) }) diff --git a/src/integrations/claude-code/run.ts b/src/integrations/claude-code/run.ts index 8bc66cb2c81..79a6468ba73 100644 --- a/src/integrations/claude-code/run.ts +++ b/src/integrations/claude-code/run.ts @@ -4,6 +4,8 @@ import { execa } from "execa" import { ClaudeCodeMessage } from "./types" import readline from "readline" import { CLAUDE_CODE_DEFAULT_MAX_OUTPUT_TOKENS } from "@roo-code/types" +import * as path from "path" +import * as os from "os" const cwd = vscode.workspace.workspaceFolders?.map((folder) => folder.uri.fsPath).at(0) @@ -21,10 +23,15 @@ type ProcessState = { exitCode: number | null } +type TempFileCleanup = { + filePath: string + cleanup: () => Promise +} + export async function* runClaudeCode( options: ClaudeCodeOptions & { maxOutputTokens?: number }, ): AsyncGenerator { - const process = runProcess(options) + const { process, tempFileCleanup } = await runProcess(options) const rl = readline.createInterface({ input: process.stdout, @@ -84,6 +91,10 @@ export async function* runClaudeCode( if (!process.killed) { process.kill() } + // Clean up temporary file if it was created + if (tempFileCleanup) { + await tempFileCleanup.cleanup() + } } } @@ -111,25 +122,55 @@ const claudeCodeTools = [ const CLAUDE_CODE_TIMEOUT = 600000 // 10 minutes // Windows has a command line length limit of ~8191 characters -// If the system prompt is too long, we'll use an environment variable instead +// If the system prompt is too long, we'll write it to a temporary file instead const MAX_COMMAND_LINE_LENGTH = 7000 // Conservative limit to account for other arguments -function runProcess({ +async function runProcess({ systemPrompt, messages, - path, + path: claudeCodePath, modelId, maxOutputTokens, -}: ClaudeCodeOptions & { maxOutputTokens?: number }) { - const claudePath = path || "claude" +}: ClaudeCodeOptions & { maxOutputTokens?: number }): Promise<{ + process: ReturnType + tempFileCleanup: TempFileCleanup | null +}> { + const claudePath = claudeCodePath || "claude" // Check if system prompt is too long for command line - const useEnvForSystemPrompt = systemPrompt.length > MAX_COMMAND_LINE_LENGTH + const useTempFileForSystemPrompt = systemPrompt.length > MAX_COMMAND_LINE_LENGTH const args = ["-p"] + let tempFileCleanup: TempFileCleanup | null = null - // Only add --system-prompt to command line if it's short enough - if (!useEnvForSystemPrompt) { + // Handle system prompt - use temp file for long prompts, command line for short ones + if (useTempFileForSystemPrompt) { + // Create temporary file for system prompt + const tempFilePath = path.join( + os.tmpdir(), + `claude-system-prompt-${Date.now()}-${Math.random().toString(36).substring(2)}.txt`, + ) + + try { + await vscode.workspace.fs.writeFile(vscode.Uri.file(tempFilePath), Buffer.from(systemPrompt, "utf8")) + args.push("--system-prompt", `@${tempFilePath}`) + + tempFileCleanup = { + filePath: tempFilePath, + cleanup: async () => { + try { + await vscode.workspace.fs.delete(vscode.Uri.file(tempFilePath)) + } catch (error) { + // Ignore cleanup errors - temp files will be cleaned up by OS eventually + console.warn(`Failed to clean up temporary system prompt file ${tempFilePath}:`, error) + } + }, + } + } catch (error) { + throw new Error(`Failed to create temporary file for system prompt: ${error}`) + } + } else { + // Use command line argument for short system prompts args.push("--system-prompt", systemPrompt) } @@ -157,11 +198,6 @@ function runProcess({ CLAUDE_CODE_DEFAULT_MAX_OUTPUT_TOKENS.toString(), } - // If system prompt is too long, pass it via environment variable - if (useEnvForSystemPrompt) { - env.CLAUDE_CODE_SYSTEM_PROMPT = systemPrompt - } - const child = execa(claudePath, args, { stdin: "pipe", stdout: "pipe", @@ -176,7 +212,7 @@ function runProcess({ // This avoids the E2BIG error on Linux and ENAMETOOLONG error on Windows when passing large data as command line arguments // Linux has a per-argument limit of ~128KiB for execve() system calls // Windows has a total command line length limit of ~8191 characters - // For system prompts, we use environment variables when they exceed the safe limit + // For system prompts, we use temporary files when they exceed the safe limit const messagesJson = JSON.stringify(messages) // Use setImmediate to ensure the process has been spawned before writing to stdin @@ -196,7 +232,7 @@ function runProcess({ } }) - return child + return { process: child, tempFileCleanup } } function parseChunk(data: string, processState: ProcessState) { From 27e1a420454f464f8ce7a55483a2edd760eea791 Mon Sep 17 00:00:00 2001 From: Roo Code Date: Mon, 14 Jul 2025 08:37:58 +0000 Subject: [PATCH 3/3] fix: resolve TypeScript errors in Claude Code integration - Fix readline.createInterface input type issue with process.stdout - Add null check for process.stderr to handle potential null values - Ensure type safety while maintaining functionality --- src/integrations/claude-code/run.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/integrations/claude-code/run.ts b/src/integrations/claude-code/run.ts index 79a6468ba73..564e5a7e2d1 100644 --- a/src/integrations/claude-code/run.ts +++ b/src/integrations/claude-code/run.ts @@ -34,7 +34,7 @@ export async function* runClaudeCode( const { process, tempFileCleanup } = await runProcess(options) const rl = readline.createInterface({ - input: process.stdout, + input: process.stdout!, }) try { @@ -45,7 +45,7 @@ export async function* runClaudeCode( partialData: null, } - process.stderr.on("data", (data) => { + process.stderr?.on("data", (data) => { processState.stderrLogs += data.toString() })