diff --git a/apps/backend/core/client.py b/apps/backend/core/client.py index de777f0624..db1d8ab449 100644 --- a/apps/backend/core/client.py +++ b/apps/backend/core/client.py @@ -29,6 +29,18 @@ logger = logging.getLogger(__name__) +# ============================================================================= +# Windows System Prompt Limits +# ============================================================================= +# Windows CreateProcessW has a 32,768 character limit for the entire command line. +# When CLAUDE.md is very large and passed as --system-prompt, the command can exceed +# this limit, causing ERROR_FILE_NOT_FOUND. We cap CLAUDE.md content to stay safe. +# 20,000 chars leaves ~12KB headroom for CLI overhead (model, tools, MCP config, etc.) +WINDOWS_MAX_SYSTEM_PROMPT_CHARS = 20000 +WINDOWS_TRUNCATION_MESSAGE = ( + "\n\n[... CLAUDE.md truncated due to Windows command-line length limit ...]" +) + # ============================================================================= # Project Index Cache # ============================================================================= @@ -821,8 +833,31 @@ def create_client( if should_use_claude_md(): claude_md_content = load_claude_md(project_dir) if claude_md_content: + # On Windows, the SDK passes system_prompt as a --system-prompt CLI argument. + # Windows CreateProcessW has a 32,768 character limit for the entire command line. + # When CLAUDE.md is very large, the command can exceed this limit, causing Windows + # to return ERROR_FILE_NOT_FOUND which the SDK misreports as "Claude Code not found". + # Cap CLAUDE.md content to keep total command line under the limit. (#1661) + was_truncated = False + if is_windows(): + max_claude_md_chars = ( + WINDOWS_MAX_SYSTEM_PROMPT_CHARS + - len(base_prompt) + - len(WINDOWS_TRUNCATION_MESSAGE) + - len("\n\n# Project Instructions (from CLAUDE.md)\n\n") + ) + if len(claude_md_content) > max_claude_md_chars > 0: + claude_md_content = ( + claude_md_content[:max_claude_md_chars] + + WINDOWS_TRUNCATION_MESSAGE + ) + print( + " - CLAUDE.md: truncated (exceeded Windows command-line limit)" + ) + was_truncated = True base_prompt = f"{base_prompt}\n\n# Project Instructions (from CLAUDE.md)\n\n{claude_md_content}" - print(" - CLAUDE.md: included in system prompt") + if not was_truncated: + print(" - CLAUDE.md: included in system prompt") else: print(" - CLAUDE.md: not found in project root") else: diff --git a/apps/frontend/src/__tests__/integration/subprocess-spawn.test.ts b/apps/frontend/src/__tests__/integration/subprocess-spawn.test.ts index 6dd99107d3..fb34455c27 100644 --- a/apps/frontend/src/__tests__/integration/subprocess-spawn.test.ts +++ b/apps/frontend/src/__tests__/integration/subprocess-spawn.test.ts @@ -186,7 +186,7 @@ describe('Subprocess Spawn Integration', () => { 'Test task description' ]), expect.objectContaining({ - cwd: AUTO_CLAUDE_SOURCE, // Process runs from auto-claude source directory + cwd: TEST_PROJECT_PATH, // Process runs from project directory to avoid cross-drive issues on Windows (#1661) env: expect.objectContaining({ PYTHONUNBUFFERED: '1' }) @@ -218,7 +218,7 @@ describe('Subprocess Spawn Integration', () => { 'spec-001' ]), expect.objectContaining({ - cwd: AUTO_CLAUDE_SOURCE // Process runs from auto-claude source directory + cwd: TEST_PROJECT_PATH // Process runs from project directory to avoid cross-drive issues on Windows (#1661) }) ); }, 30000); // Increase timeout for Windows CI (dynamic imports are slow) @@ -248,7 +248,7 @@ describe('Subprocess Spawn Integration', () => { '--qa' ]), expect.objectContaining({ - cwd: AUTO_CLAUDE_SOURCE // Process runs from auto-claude source directory + cwd: TEST_PROJECT_PATH // Process runs from project directory to avoid cross-drive issues on Windows (#1661) }) ); }, 30000); // Increase timeout for Windows CI (dynamic imports are slow) diff --git a/apps/frontend/src/main/agent/agent-manager.ts b/apps/frontend/src/main/agent/agent-manager.ts index aa3eda9579..38b2138a1d 100644 --- a/apps/frontend/src/main/agent/agent-manager.ts +++ b/apps/frontend/src/main/agent/agent-manager.ts @@ -329,7 +329,9 @@ export class AgentManager extends EventEmitter { this.registerTaskWithOperationRegistry(taskId, 'spec-creation', { projectPath, taskDescription, specDir }); // Note: This is spec-creation but it chains to task-execution via run.py - await this.processManager.spawnProcess(taskId, autoBuildSource, args, combinedEnv, 'task-execution', projectId); + // Use projectPath as cwd instead of autoBuildSource to avoid cross-drive file access + // issues on Windows. The script path is absolute so Python finds its modules via sys.path[0]. (#1661) + await this.processManager.spawnProcess(taskId, projectPath, args, combinedEnv, 'task-execution', projectId); } /** @@ -410,7 +412,10 @@ export class AgentManager extends EventEmitter { // Register with unified OperationRegistry for proactive swap support this.registerTaskWithOperationRegistry(taskId, 'task-execution', { projectPath, specId, options }); - await this.processManager.spawnProcess(taskId, autoBuildSource, args, combinedEnv, 'task-execution', projectId); + // Use projectPath as cwd instead of autoBuildSource to avoid cross-drive file access + // issues on Windows. The script path (runPath) is absolute so Python finds its modules + // via sys.path[0] which is set to the script's directory. (#1661) + await this.processManager.spawnProcess(taskId, projectPath, args, combinedEnv, 'task-execution', projectId); } /** @@ -448,7 +453,8 @@ export class AgentManager extends EventEmitter { const args = [runPath, '--spec', specId, '--project-dir', projectPath, '--qa']; - await this.processManager.spawnProcess(taskId, autoBuildSource, args, combinedEnv, 'qa-process', projectId); + // Use projectPath as cwd instead of autoBuildSource to avoid cross-drive issues on Windows (#1661) + await this.processManager.spawnProcess(taskId, projectPath, args, combinedEnv, 'qa-process', projectId); } /** diff --git a/apps/frontend/src/main/agent/agent-process.ts b/apps/frontend/src/main/agent/agent-process.ts index 58f9731674..f46c9bfc4d 100644 --- a/apps/frontend/src/main/agent/agent-process.ts +++ b/apps/frontend/src/main/agent/agent-process.ts @@ -22,10 +22,10 @@ import { pythonEnvManager, getConfiguredPythonPath } from '../python-env-manager import { buildMemoryEnvVars } from '../memory-env-builder'; import { readSettingsFile } from '../settings-utils'; import type { AppSettings } from '../../shared/types/settings'; -import { getOAuthModeClearVars } from './env-utils'; +import { getOAuthModeClearVars, normalizeEnvPathKey, mergePythonEnvPath } from './env-utils'; import { getAugmentedEnv } from '../env-utils'; import { getToolInfo, getClaudeCliPathForSdk } from '../cli-tool-manager'; -import { killProcessGracefully, isWindows } from '../platform'; +import { killProcessGracefully, isWindows, getPathDelimiter } from '../platform'; import { debugLog } from '../../shared/utils/debug-logger'; /** @@ -679,7 +679,17 @@ export class AgentProcessManager { }, }); - // Parse Python commandto handle space-separated commands like "py -3" + // Merge PATH from pythonEnv with augmented PATH from env. + // pythonEnv may contain its own PATH (e.g., on Windows with pywin32_system32 prepended). + // Simply spreading pythonEnv after env would overwrite the augmented PATH (which includes + // npm globals, homebrew, etc.), causing "Claude code not found" on Windows (#1661). + // mergePythonEnvPath() normalizes PATH key casing and prepends pythonEnv-specific paths. + const mergedPythonEnv = { ...pythonEnv }; + const pathSep = getPathDelimiter(); + + mergePythonEnvPath(env as Record, mergedPythonEnv as Record, pathSep); + + // Parse Python command to handle space-separated commands like "py -3" const [pythonCommand, pythonBaseArgs] = parsePythonCommand(this.getPythonPath()); let childProcess; try { @@ -687,7 +697,7 @@ export class AgentProcessManager { cwd, env: { ...env, // Already includes process.env, extraEnv, profileEnv, PYTHONUNBUFFERED, PYTHONUTF8 - ...pythonEnv, // Include Python environment (PYTHONPATH for bundled packages) + ...mergedPythonEnv, // Python env with merged PATH (preserves augmented PATH entries) ...oauthModeClearVars, // Clear stale ANTHROPIC_* vars when in OAuth mode ...apiProfileEnv // Include active API profile config (highest priority for ANTHROPIC_* vars) } diff --git a/apps/frontend/src/main/agent/agent-queue.ts b/apps/frontend/src/main/agent/agent-queue.ts index 8ac65ca484..94760947e6 100644 --- a/apps/frontend/src/main/agent/agent-queue.ts +++ b/apps/frontend/src/main/agent/agent-queue.ts @@ -10,7 +10,7 @@ import type { IdeationConfig, Idea } from '../../shared/types'; import { AUTO_BUILD_PATHS } from '../../shared/constants'; import { detectRateLimit, createSDKRateLimitInfo, getBestAvailableProfileEnv } from '../rate-limit-detector'; import { getAPIProfileEnv } from '../services/profile'; -import { getOAuthModeClearVars } from './env-utils'; +import { getOAuthModeClearVars, normalizeEnvPathKey } from './env-utils'; import { debugLog, debugError } from '../../shared/utils/debug-logger'; import { stripAnsiCodes } from '../../shared/utils/ansi-sanitizer'; import { parsePythonCommand } from '../python-detector'; @@ -397,6 +397,12 @@ export class AgentQueueManager { PYTHONUTF8: '1' }; + // Normalize PATH key to a single uppercase 'PATH' entry. + // On Windows, process.env spread produces 'Path' while pythonEnv may write 'PATH', + // resulting in duplicate keys in the final object. Without normalization the child + // process inherits both keys, which can cause tool-not-found errors (#1661). + normalizeEnvPathKey(finalEnv as Record); + // Debug: Show OAuth token source (token values intentionally omitted for security - AC4) const tokenSource = profileEnv['CLAUDE_CODE_OAUTH_TOKEN'] ? 'Electron app profile' @@ -730,6 +736,12 @@ export class AgentQueueManager { PYTHONUTF8: '1' }; + // Normalize PATH key to a single uppercase 'PATH' entry. + // On Windows, process.env spread produces 'Path' while pythonEnv may write 'PATH', + // resulting in duplicate keys in the final object. Without normalization the child + // process inherits both keys, which can cause tool-not-found errors (#1661). + normalizeEnvPathKey(finalEnv as Record); + // Debug: Show OAuth token source (token values intentionally omitted for security - AC4) const tokenSource = profileEnv['CLAUDE_CODE_OAUTH_TOKEN'] ? 'Electron app profile' diff --git a/apps/frontend/src/main/agent/env-utils.test.ts b/apps/frontend/src/main/agent/env-utils.test.ts index 6a5d42c54e..e9607f1370 100644 --- a/apps/frontend/src/main/agent/env-utils.test.ts +++ b/apps/frontend/src/main/agent/env-utils.test.ts @@ -4,7 +4,7 @@ */ import { describe, it, expect } from 'vitest'; -import { getOAuthModeClearVars } from './env-utils'; +import { getOAuthModeClearVars, normalizeEnvPathKey, mergePythonEnvPath } from './env-utils'; describe('getOAuthModeClearVars', () => { describe('OAuth mode (no active API profile)', () => { @@ -132,3 +132,166 @@ describe('getOAuthModeClearVars', () => { }); }); }); + +describe('normalizeEnvPathKey', () => { + it('should leave an already-uppercase PATH key untouched', () => { + const env: Record = { PATH: '/usr/bin:/bin', HOME: '/home/user' }; + normalizeEnvPathKey(env); + expect(env).toEqual({ PATH: '/usr/bin:/bin', HOME: '/home/user' }); + }); + + it('should rename a lowercase-variant "Path" key to "PATH"', () => { + const env: Record = { Path: 'C:\\Windows\\system32', HOME: '/home/user' }; + normalizeEnvPathKey(env); + expect(env['PATH']).toBe('C:\\Windows\\system32'); + expect('Path' in env).toBe(false); + }); + + it('should prefer existing "PATH" and remove "Path" when both keys coexist', () => { + // Simulates process.env spread ('Path') after getAugmentedEnv writes ('PATH') + const env: Record = { + Path: 'C:\\old', + PATH: 'C:\\Windows\\system32;C:\\augmented', + HOME: '/home/user' + }; + normalizeEnvPathKey(env); + expect(env.PATH).toBe('C:\\Windows\\system32;C:\\augmented'); + expect('Path' in env).toBe(false); + }); + + it('should remove all case-variant PATH duplicates when PATH is already present', () => { + const env: Record = { + PATH: '/correct', + Path: '/old1', + path: '/old2' + }; + normalizeEnvPathKey(env); + expect(env.PATH).toBe('/correct'); + expect('Path' in env).toBe(false); + expect('path' in env).toBe(false); + }); + + it('should handle env with no PATH-like key gracefully', () => { + const env: Record = { HOME: '/home/user', SHELL: '/bin/zsh' }; + normalizeEnvPathKey(env); + expect(env).toEqual({ HOME: '/home/user', SHELL: '/bin/zsh' }); + }); + + it('should return the same env object reference (mutates in place)', () => { + const env: Record = { PATH: '/usr/bin' }; + const result = normalizeEnvPathKey(env); + expect(result).toBe(env); + }); +}); + +describe('mergePythonEnvPath - Windows PATH merge logic (#1661)', () => { + const SEP = ';'; // Use Windows separator for these tests + + it('should prepend pythonEnv-only entries to the augmented PATH', () => { + const env: Record = { + PATH: 'C:\\npm;C:\\homebrew' + }; + const mergedPythonEnv: Record = { + PATH: 'C:\\pywin32_system32;C:\\npm;C:\\homebrew' + }; + + mergePythonEnvPath(env, mergedPythonEnv, SEP); + + // pywin32_system32 is unique to pythonEnv, so it should be prepended + expect(mergedPythonEnv.PATH).toBe('C:\\pywin32_system32;C:\\npm;C:\\homebrew'); + }); + + it('should deduplicate entries that already exist in augmented PATH', () => { + const env: Record = { + PATH: 'C:\\npm;C:\\homebrew;C:\\pywin32_system32' + }; + const mergedPythonEnv: Record = { + PATH: 'C:\\pywin32_system32;C:\\npm' + }; + + mergePythonEnvPath(env, mergedPythonEnv, SEP); + + // All pythonEnv entries are already in env.PATH, so mergedPythonEnv.PATH should equal env.PATH + expect(mergedPythonEnv.PATH).toBe('C:\\npm;C:\\homebrew;C:\\pywin32_system32'); + }); + + it('should normalize Windows-style "Path" key in pythonEnv to "PATH"', () => { + const env: Record = { + PATH: 'C:\\npm;C:\\homebrew' + }; + // pythonEnv uses 'Path' (Windows native casing) + const mergedPythonEnv: Record = { + Path: 'C:\\pywin32_system32;C:\\npm' + }; + + mergePythonEnvPath(env, mergedPythonEnv, SEP); + + // 'Path' should be normalized to 'PATH' and pythonEnv-specific entry prepended + expect('Path' in mergedPythonEnv).toBe(false); + expect(mergedPythonEnv.PATH).toBe('C:\\pywin32_system32;C:\\npm;C:\\homebrew'); + }); + + it('should normalize Windows-style "Path" in env and deduplicate duplicates', () => { + // Simulates process.env spread ('Path') + getAugmentedEnv write ('PATH') leaving both + const env: Record = { + Path: 'C:\\old', + PATH: 'C:\\npm;C:\\homebrew' + }; + const mergedPythonEnv: Record = { + PATH: 'C:\\pywin32_system32;C:\\npm' + }; + + mergePythonEnvPath(env, mergedPythonEnv, SEP); + + // env 'Path' should be removed; augmented 'PATH' value preserved + expect('Path' in env).toBe(false); + expect(env.PATH).toBe('C:\\npm;C:\\homebrew'); + // Only the unique pywin32_system32 entry prepended + expect(mergedPythonEnv.PATH).toBe('C:\\pywin32_system32;C:\\npm;C:\\homebrew'); + }); + + it('should use env.PATH unchanged when pythonEnv has no unique entries', () => { + const env: Record = { + PATH: 'C:\\npm;C:\\homebrew' + }; + const mergedPythonEnv: Record = { + PATH: 'C:\\npm;C:\\homebrew' + }; + + mergePythonEnvPath(env, mergedPythonEnv, SEP); + + expect(mergedPythonEnv.PATH).toBe('C:\\npm;C:\\homebrew'); + }); + + it('should work correctly with Unix colon separator', () => { + const unixSep = ':'; + const env: Record = { + PATH: '/usr/bin:/bin' + }; + const mergedPythonEnv: Record = { + PATH: '/opt/pyenv/shims:/usr/bin:/bin' + }; + + mergePythonEnvPath(env, mergedPythonEnv, unixSep); + + // /opt/pyenv/shims is unique and should be prepended + expect(mergedPythonEnv.PATH).toBe('/opt/pyenv/shims:/usr/bin:/bin'); + }); + + it('should handle missing PATH in pythonEnv gracefully (no-op)', () => { + const env: Record = { + PATH: 'C:\\npm;C:\\homebrew' + }; + // pythonEnv has no PATH at all + const mergedPythonEnv: Record = { + PYTHONPATH: '/site-packages' + }; + + mergePythonEnvPath(env, mergedPythonEnv, SEP); + + // Nothing should change + expect(mergedPythonEnv.PATH).toBeUndefined(); + expect(mergedPythonEnv.PYTHONPATH).toBe('/site-packages'); + expect(env.PATH).toBe('C:\\npm;C:\\homebrew'); + }); +}); diff --git a/apps/frontend/src/main/agent/env-utils.ts b/apps/frontend/src/main/agent/env-utils.ts index 3c479e607e..d2cdb0dec3 100644 --- a/apps/frontend/src/main/agent/env-utils.ts +++ b/apps/frontend/src/main/agent/env-utils.ts @@ -2,6 +2,88 @@ * Utility functions for managing environment variables in agent spawning */ +/** + * Normalize the PATH key in an environment object to a single uppercase 'PATH' key. + * + * On Windows, process.env spreads as 'Path' (the native casing) while getAugmentedEnv() + * writes 'PATH'. Without normalization, both keys coexist in the object and the child + * process receives duplicate PATH entries, causing tool-not-found errors like #1661. + * + * Mutates the provided env object in place and returns it for convenience. + * + * @param env - Mutable environment record to normalize + * @returns The same env object with PATH normalized to uppercase + */ +export function normalizeEnvPathKey(env: Record): Record { + // If 'PATH' already exists, delete all other case-variant keys (e.g. 'Path') + if ('PATH' in env) { + for (const key of Object.keys(env)) { + if (key !== 'PATH' && key.toUpperCase() === 'PATH') { + delete env[key]; + } + } + return env; + } + + // No uppercase 'PATH' key - find the first case-variant and rename it + const pathKey = Object.keys(env).find(k => k.toUpperCase() === 'PATH'); + if (pathKey) { + env['PATH'] = env[pathKey]; + delete env[pathKey]; + // Remove any remaining case-variant keys + for (const key of Object.keys(env)) { + if (key !== 'PATH' && key.toUpperCase() === 'PATH') { + delete env[key]; + } + } + } + + return env; +} + +/** + * Merge pythonEnv PATH entries with the augmented PATH in env, deduplicating entries. + * + * pythonEnv may carry its own PATH (e.g. pywin32_system32 prepended on Windows). + * Simply spreading pythonEnv after env would overwrite the augmented PATH (which + * includes npm globals, Homebrew, etc.), causing "Claude code not found" (#1661). + * + * Strategy: + * 1. Normalize PATH key casing in both env and pythonEnv to uppercase 'PATH'. + * 2. Extract only pythonEnv PATH entries that are not already in env.PATH. + * 3. Prepend those unique entries to env.PATH and store the result in pythonEnv.PATH. + * + * Mutates mergedPythonEnv in place (caller should pass a shallow copy if immutability is needed). + * + * @param env - The base environment (already augmented with tool paths) + * @param mergedPythonEnv - Shallow copy of pythonEnv to merge PATH into + * @param pathSep - Platform path separator (';' on Windows, ':' elsewhere) + */ +export function mergePythonEnvPath( + env: Record, + mergedPythonEnv: Record, + pathSep: string +): void { + // Normalize PATH key to uppercase in both objects + normalizeEnvPathKey(env); + normalizeEnvPathKey(mergedPythonEnv); + + if (mergedPythonEnv['PATH'] && env['PATH']) { + const augmentedPathEntries = new Set( + (env['PATH'] as string).split(pathSep).filter(Boolean) + ); + // Extract only new entries from pythonEnv.PATH that aren't already in the augmented PATH + const pythonPathEntries = (mergedPythonEnv['PATH'] as string) + .split(pathSep) + .filter(entry => entry && !augmentedPathEntries.has(entry)); + + // Prepend python-specific paths (e.g., pywin32_system32) to the augmented PATH + mergedPythonEnv['PATH'] = pythonPathEntries.length > 0 + ? [...pythonPathEntries, env['PATH'] as string].join(pathSep) + : env['PATH'] as string; + } +} + /** * Get environment variables to clear ANTHROPIC_* vars when in OAuth mode * diff --git a/apps/frontend/src/main/python-env-manager.ts b/apps/frontend/src/main/python-env-manager.ts index 5e98c959a0..266f894481 100644 --- a/apps/frontend/src/main/python-env-manager.ts +++ b/apps/frontend/src/main/python-env-manager.ts @@ -6,6 +6,7 @@ import { app } from 'electron'; import { findPythonCommand, getBundledPythonPath } from './python-detector'; import { isLinux, isWindows, getPathDelimiter } from './platform'; import { getIsolatedGitEnv } from './utils/git-isolation'; +import { normalizeEnvPathKey } from './agent/env-utils'; export interface PythonEnvStatus { ready: boolean; @@ -726,17 +727,10 @@ if sys.version_info >= (3, 12): const pywin32System32 = path.join(this.sitePackagesPath, 'pywin32_system32'); // Add pywin32_system32 to PATH for DLL loading - // Fix PATH case sensitivity: On Windows, env vars are case-insensitive but Node.js - // preserves case. If we have both 'PATH' and 'Path', Node.js lexicographically sorts - // and uses the first match, causing issues. Normalize to single 'PATH' key. - // See: https://github.com/nodejs/node/issues/9157 - const pathKey = Object.keys(baseEnv).find(k => k.toUpperCase() === 'PATH'); - const currentPath = pathKey ? baseEnv[pathKey] : ''; - - // Remove any existing PATH variants to avoid duplicates - if (pathKey && pathKey !== 'PATH') { - delete baseEnv[pathKey]; - } + // Normalize to single 'PATH' key before reading/writing, using the shared utility. + // This prevents duplicate 'Path'/'PATH' keys that cause DLL-load failures on Windows. + normalizeEnvPathKey(baseEnv); + const currentPath = baseEnv['PATH'] ?? ''; if (currentPath && !currentPath.includes(pywin32System32)) { windowsEnv['PATH'] = `${pywin32System32};${currentPath}`;