* fix: resolve Claude CLI not found on Windows - PATH merge, prompt size cap, and cwd (#1661) Three root causes addressed: 1. PATH overwrite: pythonEnv.PATH was overwriting the augmented PATH (with npm globals) in spawn env. Now merges PATH entries instead, prepending python-specific paths (pywin32_system32) while preserving all augmented entries. 2. System prompt size: On Windows, SDK passes system_prompt as --system-prompt CLI arg. Large CLAUDE.md files exceed CreateProcessW's 32,768 char limit, causing misleading "Claude Code not found" error. Now caps CLAUDE.md content on Windows to stay under the limit. 3. Cross-drive cwd: Agent processes were spawned with autoBuildSource as cwd. On Windows with cross-drive setups, this caused file access issues. Now uses projectPath as cwd since all script paths are absolute. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix: address PR review findings - constants, logging, CI fixes (#1661) - Extract magic number 24000 into WINDOWS_MAX_SYSTEM_PROMPT_CHARS constant (set to 20000 for more conservative ~12KB CLI headroom) - Extract truncation suffix into WINDOWS_TRUNCATION_MESSAGE constant - Fix double-print when truncation occurs: only print "included in system prompt" when CLAUDE.md was NOT truncated (was_truncated flag) - Fix CI test failures: update subprocess-spawn tests to expect projectPath as cwd instead of autoBuildSource (matches the #1661 CWD change) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix: normalize PATH key casing and fix truncation budget on Windows - Normalize env objects to a single uppercase 'PATH' key before merging to prevent duplicate PATH keys on Windows where process.env has 'Path' and getAugmentedEnv() writes 'PATH'. Without this, Object.keys().find() returns 'Path' first (insertion order), discarding augmented entries, and the final spread produces both 'Path' and 'PATH' keys. Follows the same pattern used in python-env-manager.ts. (#1661) - Subtract WINDOWS_TRUNCATION_MESSAGE length from the truncation budget so the final system prompt stays within WINDOWS_MAX_SYSTEM_PROMPT_CHARS. Addresses PR #1843 review findings NEW-001, NEW-002, NEW-003. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix: address PR review findings for Windows PATH key casing and truncation budget - Finding 1 (MEDIUM): Prefer 'PATH' key directly when present in env to avoid insertion-order bug where Object.keys().find() returned 'Path' first on Windows - Finding 2 (MEDIUM): Normalization block (delete stale cased key, write 'PATH') already in place from previous commit; Finding 1 fix ensures envPathKey resolves correctly so normalization fires only when truly needed - Finding 3 (LOW): Subtract header template overhead from max_claude_md_chars to prevent ~44-char overshoot in Windows command-line truncation budget (#1661) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix: remove stale 'Path' key after PATH normalization on Windows When getAugmentedEnv() spreads process.env on Windows, the resulting object contains both 'Path' (from process.env spread) and 'PATH' (explicitly written by getAugmentedEnv). The prior normalization block only removed non-'PATH' keys when 'PATH' was absent, leaving the stale 'Path' key when both coexisted. Add a cleanup loop to delete all case-variant PATH keys that differ from 'PATH' after the main normalization, ensuring the child process inherits a single canonical 'PATH' entry with the fully-augmented value. (#1661) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * refactor: extract shared PATH normalization utilities and add unit tests - Extract normalizeEnvPathKey() and mergePythonEnvPath() into env-utils.ts as shared, exported helpers to eliminate duplicated PATH key case-normalization logic across agent-process.ts and python-env-manager.ts (Finding 3) - Add PATH normalization call in agent-queue.ts spawnIdeationProcess and spawnRoadmapProcess to fix the same Windows PATH duplicate-key issue that was fixed in agent-process.ts (#1661) (Finding 1) - Add comprehensive unit tests for normalizeEnvPathKey() and mergePythonEnvPath() covering Windows-style 'Path' key renaming, duplicate key removal, PATH deduplication across merge, and Unix separator support (Finding 2) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -29,6 +29,18 @@ from core.platform import (
|
||||
|
||||
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:
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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<string, string | undefined>, mergedPythonEnv as Record<string, string | undefined>, 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)
|
||||
}
|
||||
|
||||
@@ -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<string, string | undefined>);
|
||||
|
||||
// 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<string, string | undefined>);
|
||||
|
||||
// Debug: Show OAuth token source (token values intentionally omitted for security - AC4)
|
||||
const tokenSource = profileEnv['CLAUDE_CODE_OAUTH_TOKEN']
|
||||
? 'Electron app profile'
|
||||
|
||||
@@ -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<string, string | undefined> = { 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<string, string | undefined> = { 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<string, string | undefined> = {
|
||||
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<string, string | undefined> = {
|
||||
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<string, string | undefined> = { 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<string, string | undefined> = { 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<string, string | undefined> = {
|
||||
PATH: 'C:\\npm;C:\\homebrew'
|
||||
};
|
||||
const mergedPythonEnv: Record<string, string | undefined> = {
|
||||
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<string, string | undefined> = {
|
||||
PATH: 'C:\\npm;C:\\homebrew;C:\\pywin32_system32'
|
||||
};
|
||||
const mergedPythonEnv: Record<string, string | undefined> = {
|
||||
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<string, string | undefined> = {
|
||||
PATH: 'C:\\npm;C:\\homebrew'
|
||||
};
|
||||
// pythonEnv uses 'Path' (Windows native casing)
|
||||
const mergedPythonEnv: Record<string, string | undefined> = {
|
||||
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<string, string | undefined> = {
|
||||
Path: 'C:\\old',
|
||||
PATH: 'C:\\npm;C:\\homebrew'
|
||||
};
|
||||
const mergedPythonEnv: Record<string, string | undefined> = {
|
||||
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<string, string | undefined> = {
|
||||
PATH: 'C:\\npm;C:\\homebrew'
|
||||
};
|
||||
const mergedPythonEnv: Record<string, string | undefined> = {
|
||||
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<string, string | undefined> = {
|
||||
PATH: '/usr/bin:/bin'
|
||||
};
|
||||
const mergedPythonEnv: Record<string, string | undefined> = {
|
||||
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<string, string | undefined> = {
|
||||
PATH: 'C:\\npm;C:\\homebrew'
|
||||
};
|
||||
// pythonEnv has no PATH at all
|
||||
const mergedPythonEnv: Record<string, string | undefined> = {
|
||||
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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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<string, string | undefined>): Record<string, string | undefined> {
|
||||
// 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<string, string | undefined>,
|
||||
mergedPythonEnv: Record<string, string | undefined>,
|
||||
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
|
||||
*
|
||||
|
||||
@@ -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}`;
|
||||
|
||||
Reference in New Issue
Block a user