Skip to content
Merged
37 changes: 36 additions & 1 deletion apps/backend/core/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
# =============================================================================
Expand Down Expand Up @@ -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)"
)
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Bug: The CLAUDE.md truncation logic can be bypassed on Windows if max_claude_md_chars becomes negative, causing the > 0 check in the if condition to fail.
Severity: CRITICAL

Suggested Fix

Modify the conditional logic to handle cases where max_claude_md_chars is not positive. For example, change the check to if max_claude_md_chars < len(claude_md_content): and ensure max_claude_md_chars is clamped at a minimum of 0 before being used for slicing, preventing negative budget issues.

Prompt for AI Agent
Review the code at the location below. A potential bug has been identified by an AI
agent.
Verify if this is a real issue. If it is, propose a fix; if not, explain why it's not
valid.

Location: apps/backend/core/client.py#L856

Potential issue: On Windows, the calculation for `max_claude_md_chars` can result in a
negative value if the `base_prompt` is sufficiently large. The conditional check `if
len(claude_md_content) > max_claude_md_chars > 0:` requires `max_claude_md_chars` to be
positive. When it's negative, the check fails, and the truncation logic is skipped
entirely. This allows the total system prompt to exceed the Windows `CreateProcessW`
character limit, leading to a failure that the SDK misreports as "Claude Code not
found," which is the exact bug this change was intended to fix.

Did we get this right? 👍 / 👎 to inform future reviews.

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")
Comment on lines 836 to 860
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Edge case: when max_claude_md_chars <= 0, full CLAUDE.md is included untruncated.

If base_prompt + WINDOWS_TRUNCATION_MESSAGE already exceeds WINDOWS_MAX_SYSTEM_PROMPT_CHARS, the > 0 guard skips truncation and the entire CLAUDE.md is appended — which will definitely exceed the Windows limit. While base_prompt is currently short (~400 chars), this is a latent bug if the prompt grows.

Proposed fix: omit CLAUDE.md when budget is exhausted
             if is_windows():
                 max_claude_md_chars = (
                     WINDOWS_MAX_SYSTEM_PROMPT_CHARS
                     - len(base_prompt)
                     - len(WINDOWS_TRUNCATION_MESSAGE)
                 )
-                if len(claude_md_content) > max_claude_md_chars > 0:
+                if max_claude_md_chars <= 0:
+                    claude_md_content = ""
+                    print(
+                        "   - CLAUDE.md: omitted (base prompt already near Windows limit)"
+                    )
+                    was_truncated = True
+                elif len(claude_md_content) > max_claude_md_chars:
                     claude_md_content = (
                         claude_md_content[:max_claude_md_chars]
                         + WINDOWS_TRUNCATION_MESSAGE
                     )
🤖 Prompt for AI Agents
In `@apps/backend/core/client.py` around lines 836 - 859, The current Windows
truncation logic fails when max_claude_md_chars <= 0 and ends up appending the
full claude_md_content; update the block around is_windows()/max_claude_md_chars
so that if max_claude_md_chars <= 0 you do not append CLAUDE.md at all (set
was_truncated = True), print a clear message (e.g., "CLAUDE.md: omitted (no
budget under Windows command-line limit)"), and only append claude_md_content
when max_claude_md_chars > 0 (truncating when necessary with
WINDOWS_TRUNCATION_MESSAGE); reference is_windows,
WINDOWS_MAX_SYSTEM_PROMPT_CHARS, base_prompt, WINDOWS_TRUNCATION_MESSAGE,
claude_md_content, and was_truncated to locate where to change the logic and
messages.

else:
print(" - CLAUDE.md: not found in project root")
else:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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'
})
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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)
Expand Down
12 changes: 9 additions & 3 deletions apps/frontend/src/main/agent/agent-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}

/**
Expand Down Expand Up @@ -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);
}

/**
Expand Down Expand Up @@ -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);
}

/**
Expand Down
18 changes: 14 additions & 4 deletions apps/frontend/src/main/agent/agent-process.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,10 +22,10 @@
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';

Check notice

Code scanning / CodeQL

Unused variable, import, function or class Note

Unused import normalizeEnvPathKey.

Copilot Autofix

AI 1 day ago

In general, to fix an unused import, remove the unused symbol from the import clause, or, if all imported symbols are unused, remove the entire import statement. This avoids confusion and keeps the module’s dependency surface minimal.

Here, only normalizeEnvPathKey is reported unused in the import from ./env-utils on line 25, while getOAuthModeClearVars and mergePythonEnvPath may still be used elsewhere in the file. The safest change is to edit that specific import line in apps/frontend/src/main/agent/agent-process.ts to drop normalizeEnvPathKey and leave the other imports intact. No additional methods, definitions, or new imports are required, and no runtime behavior should change.

Concretely:

  • In apps/frontend/src/main/agent/agent-process.ts, locate the import on line 25.
  • Remove normalizeEnvPathKey from the destructured import list.
  • Leave all other code untouched.
Suggested changeset 1
apps/frontend/src/main/agent/agent-process.ts

Autofix patch

Autofix patch
Run the following command in your local git repository to apply this patch
cat << 'EOF' | git apply
diff --git a/apps/frontend/src/main/agent/agent-process.ts b/apps/frontend/src/main/agent/agent-process.ts
--- a/apps/frontend/src/main/agent/agent-process.ts
+++ b/apps/frontend/src/main/agent/agent-process.ts
@@ -22,7 +22,7 @@
 import { buildMemoryEnvVars } from '../memory-env-builder';
 import { readSettingsFile } from '../settings-utils';
 import type { AppSettings } from '../../shared/types/settings';
-import { getOAuthModeClearVars, normalizeEnvPathKey, mergePythonEnvPath } from './env-utils';
+import { getOAuthModeClearVars, mergePythonEnvPath } from './env-utils';
 import { getAugmentedEnv } from '../env-utils';
 import { getToolInfo, getClaudeCliPathForSdk } from '../cli-tool-manager';
 import { killProcessGracefully, isWindows, getPathDelimiter } from '../platform';
EOF
@@ -22,7 +22,7 @@
import { buildMemoryEnvVars } from '../memory-env-builder';
import { readSettingsFile } from '../settings-utils';
import type { AppSettings } from '../../shared/types/settings';
import { getOAuthModeClearVars, normalizeEnvPathKey, mergePythonEnvPath } from './env-utils';
import { getOAuthModeClearVars, mergePythonEnvPath } from './env-utils';
import { getAugmentedEnv } from '../env-utils';
import { getToolInfo, getClaudeCliPathForSdk } from '../cli-tool-manager';
import { killProcessGracefully, isWindows, getPathDelimiter } from '../platform';
Copilot is powered by AI and may make mistakes. Always verify output.
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';

/**
Expand Down Expand Up @@ -679,15 +679,25 @@
},
});

// 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 {
childProcess = spawn(pythonCommand, [...pythonBaseArgs, ...args], {
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)
}
Comment on lines 696 to 703
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Find pythonEnvManager.getPythonEnv() calls and implementation
rg -n "getPythonEnv" --type=ts -A5 -B5 | head -100

Repository: AndyMik90/Auto-Claude

Length of output: 10363


🏁 Script executed:

#!/bin/bash
# Look for pythonEnvManager definition and initialization
rg -n "pythonEnvManager\s*=" --type=ts -A3 -B3

Repository: AndyMik90/Auto-Claude

Length of output: 615


🏁 Script executed:

#!/bin/bash
# Check the file where pythonEnvManager is defined
fd pythonEnvManager --type=ts

Repository: AndyMik90/Auto-Claude

Length of output: 234


🏁 Script executed:

#!/bin/bash
# Read the getPythonEnv() method implementation
sed -n '690,750p' apps/frontend/src/main/python-env-manager.ts

Repository: AndyMik90/Auto-Claude

Length of output: 3204


🏁 Script executed:

#!/bin/bash
# Read more of the getPythonEnv() implementation to see all returned keys
sed -n '690,800p' apps/frontend/src/main/python-env-manager.ts

Repository: AndyMik90/Auto-Claude

Length of output: 4692


🏁 Script executed:

#!/bin/bash
# Look at agent-process.ts to see mergedPythonEnv construction and context
sed -n '700,750p' apps/frontend/src/main/agent/agent-process.ts

Repository: AndyMik90/Auto-Claude

Length of output: 2573


🏁 Script executed:

#!/bin/bash
# Look at env construction before it's passed to spawn
sed -n '650,730p' apps/frontend/src/main/agent/agent-process.ts | head -100

Repository: AndyMik90/Auto-Claude

Length of output: 3992


🏁 Script executed:

#!/bin/bash
# Check what extraEnv, profileEnv, and other env components contain
rg -n "extraEnv|profileEnv|oauthModeClearVars" apps/frontend/src/main/agent/agent-process.ts -B2 -A2 | head -80

Repository: AndyMik90/Auto-Claude

Length of output: 3624


🏁 Script executed:

#!/bin/bash
# Check setupProcessEnvironment to see what's set in env
sed -n '176,260p' apps/frontend/src/main/agent/agent-process.ts

Repository: AndyMik90/Auto-Claude

Length of output: 4017


Clarify redundant Python environment variable overrides and document override semantics.

mergedPythonEnv spreads after env (line 730), causing all keys to override env values. PATH is explicitly merged (lines 706-721) to preserve augmented entries, but other keys override unconditionally: PYTHONUNBUFFERED, PYTHONIOENCODING, and PYTHONUTF8 are redundantly set in both env and mergedPythonEnv. While new keys like PYTHONDONTWRITEBYTECODE and PYTHONNOUSERSITE should override consistently, remove the redundant duplication from setupProcessEnvironment() (lines 242-244) since getPythonEnv() now provides the canonical Python configuration. Update the comment on line 730 to document this override behavior or consider an explicit merge strategy for all Python-related keys to match PATH handling.

🤖 Prompt for AI Agents
In `@apps/frontend/src/main/agent/agent-process.ts` around lines 726 - 733, The
spawn call currently spreads env then mergedPythonEnv so mergedPythonEnv
unconditionally overrides keys from env (including PATH which you purposely
merged earlier), and setupProcessEnvironment() still injects duplicate Python
vars; remove the redundant PYTHON-specific assignments (e.g., PYTHONUNBUFFERED,
PYTHONIOENCODING, PYTHONUTF8) from setupProcessEnvironment() so
getPythonEnv()/getMergedPythonEnv (the code producing mergedPythonEnv) is the
canonical source, or alternatively change the spawn merge to explicitly merge
Python keys the same way PATH is merged; also update the inline comment near the
spawn (where env, mergedPythonEnv, oauthModeClearVars, apiProfileEnv are
combined) to clearly state that mergedPythonEnv has final precedence for
Python-related vars and PATH merging is preserved.

Expand Down
14 changes: 13 additions & 1 deletion apps/frontend/src/main/agent/agent-queue.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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'
Expand Down Expand Up @@ -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'
Expand Down
165 changes: 164 additions & 1 deletion apps/frontend/src/main/agent/env-utils.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)', () => {
Expand Down Expand Up @@ -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');
});
});
Loading
Loading