diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index 2a4a39c854..c9db1818a7 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -40,9 +40,21 @@ Follow conventional commits: `: ` - [ ] I've followed the code principles (SOLID, DRY, KISS) - [ ] My PR is small and focused (< 400 lines ideally) +## Platform Testing Checklist + +**CRITICAL:** This project supports Windows, macOS, and Linux. Platform-specific bugs are a common source of breakage. + +- [ ] **Windows tested** (either on Windows or via CI) +- [ ] **macOS tested** (either on macOS or via CI) +- [ ] **Linux tested** (CI covers this) +- [ ] Used centralized `platform/` module instead of direct `process.platform` checks +- [ ] No hardcoded paths (used `findExecutable()` or platform abstractions) + +**If you only have access to one OS:** CI now tests on all platforms. Ensure all checks pass before submitting. + ## CI/Testing Requirements -- [ ] All CI checks pass +- [ ] All CI checks pass on **all platforms** (Windows, macOS, Linux) - [ ] All existing tests pass - [ ] New features include test coverage - [ ] Bug fixes include regression tests diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ad30f230b5..9681ee111d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,3 +1,11 @@ +# Cross-Platform CI Pipeline +# +# Tests on all target platforms (Linux, Windows, macOS) to catch +# platform-specific bugs before they merge. ALL platforms must pass. +# +# Why this matters: Platform-specific code often breaks when developers +# commit from one OS without testing on others. This CI prevents that. + name: CI on: @@ -15,15 +23,21 @@ permissions: actions: read jobs: - # Python tests + # -------------------------------------------------------------------------- + # Python Backend Tests - All Platforms + # -------------------------------------------------------------------------- test-python: - runs-on: ubuntu-latest + name: Python ${{ matrix.python-version }} on ${{ matrix.os }} + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false # Don't cancel all jobs if one platform fails matrix: + os: [ubuntu-latest, windows-latest, macos-latest] python-version: ['3.12', '3.13'] steps: - - name: Checkout + - name: Checkout repository uses: actions/checkout@v4 - name: Set up Python ${{ matrix.python-version }} @@ -31,13 +45,14 @@ jobs: with: python-version: ${{ matrix.python-version }} - - name: Install uv + - name: Install uv package manager uses: astral-sh/setup-uv@v4 with: version: "latest" - name: Install dependencies working-directory: apps/backend + shell: bash run: | uv venv uv pip install -r requirements.txt @@ -45,22 +60,32 @@ jobs: - name: Run tests working-directory: apps/backend + shell: bash env: PYTHONPATH: ${{ github.workspace }}/apps/backend run: | - source .venv/bin/activate + if [ "$RUNNER_OS" == "Windows" ]; then + source .venv/Scripts/activate + else + source .venv/bin/activate + fi pytest ../../tests/ -v --tb=short -x - - name: Run tests with coverage + - name: Run coverage (Python 3.12 only) if: matrix.python-version == '3.12' working-directory: apps/backend + shell: bash env: PYTHONPATH: ${{ github.workspace }}/apps/backend run: | - source .venv/bin/activate - pytest ../../tests/ -v --cov=. --cov-report=xml --cov-report=term-missing --cov-fail-under=20 - - - name: Upload coverage reports + if [ "$RUNNER_OS" == "Windows" ]; then + source .venv/Scripts/activate + else + source .venv/bin/activate + fi + pytest ../../tests/ -v --cov=. --cov-report=xml --cov-report=term-missing --cov-fail-under=10 + + - name: Upload coverage to Codecov if: matrix.python-version == '3.12' uses: codecov/codecov-action@v4 with: @@ -69,11 +94,20 @@ jobs: env: CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} - # Frontend lint, typecheck, test, and build + # -------------------------------------------------------------------------- + # Frontend Tests - All Platforms + # -------------------------------------------------------------------------- test-frontend: - runs-on: ubuntu-latest + name: Frontend on ${{ matrix.os }} + runs-on: ${{ matrix.os }} + + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, windows-latest, macos-latest] + steps: - - name: Checkout + - name: Checkout repository uses: actions/checkout@v4 - name: Setup Node.js @@ -83,9 +117,11 @@ jobs: - name: Get npm cache directory id: npm-cache + shell: bash run: echo "dir=$(npm config get cache)" >> "$GITHUB_OUTPUT" - - uses: actions/cache@v4 + - name: Cache npm dependencies + uses: actions/cache@v4 with: path: ${{ steps.npm-cache.outputs.dir }} key: ${{ runner.os }}-npm-${{ hashFiles('**/package-lock.json') }} @@ -95,18 +131,68 @@ jobs: working-directory: apps/frontend run: npm ci --ignore-scripts - - name: Lint + - name: Run linter working-directory: apps/frontend run: npm run lint - - name: Type check + - name: Run TypeScript type check working-directory: apps/frontend run: npm run typecheck - - name: Run tests + - name: Run unit tests working-directory: apps/frontend run: npm run test - - name: Build + - name: Build application working-directory: apps/frontend run: npm run build + + # -------------------------------------------------------------------------- + # Platform-Specific Integration Tests + # -------------------------------------------------------------------------- + test-platform-integration: + name: Platform Integration Tests on ${{ matrix.os }} + runs-on: ${{ matrix.os }} + + # Only run integration tests after basic tests pass + needs: [test-python, test-frontend] + + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, windows-latest, macos-latest] + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Setup Python + uses: actions/setup-python@v5 + with: + python-version: '3.12' + + - name: Install uv + uses: astral-sh/setup-uv@v4 + with: + version: "latest" + + - name: Install backend dependencies + working-directory: apps/backend + shell: bash + run: | + uv venv + uv pip install -r requirements.txt + uv pip install -r ../../tests/requirements-test.txt + + - name: Run platform-specific tests + working-directory: apps/backend + shell: bash + env: + PYTHONPATH: ${{ github.workspace }}/apps/backend + run: | + if [ "$RUNNER_OS" == "Windows" ]; then + source .venv/Scripts/activate + else + source .venv/bin/activate + fi + pytest ../../tests/test_platform.py -v --tb=short diff --git a/CLAUDE.md b/CLAUDE.md index 3a465bf754..45327eba3d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -401,6 +401,139 @@ const { t } = useTranslation(['errors']); 2. Use `namespace:section.key` format (e.g., `navigation:items.githubPRs`) 3. Never use hardcoded strings in JSX/TSX files +### Cross-Platform Development + +**CRITICAL: This project supports Windows, macOS, and Linux. Platform-specific bugs are the #1 source of breakage.** + +#### The Problem + +When developers on macOS fix something using Mac-specific assumptions, it breaks on Windows. When Windows developers fix something, it breaks on macOS. This happens because: + +1. **CI only tested on Linux** - Platform-specific bugs weren't caught until after merge +2. **Scattered platform checks** - `process.platform === 'win32'` checks were spread across 50+ files +3. **Hardcoded paths** - Direct paths like `C:\Program Files` or `/opt/homebrew/bin` throughout code + +#### The Solution + +**1. Centralized Platform Abstraction** + +All platform-specific code now lives in dedicated modules: + +- **Frontend:** `apps/frontend/src/main/platform/` +- **Backend:** `apps/backend/core/platform/` + +**Import from these modules instead of checking `process.platform` directly:** + +```typescript +// ❌ WRONG - Direct platform check +if (process.platform === 'win32') { + // Windows logic +} + +// ✅ CORRECT - Use abstraction +import { isWindows, getPathDelimiter } from './platform'; + +if (isWindows()) { + // Windows logic +} +``` + +**2. Multi-Platform CI** + +CI now tests on **all three platforms** (Windows, macOS, Linux). A PR cannot merge unless all platforms pass: + +```yaml +# .github/workflows/ci.yml +strategy: + matrix: + os: [ubuntu-latest, windows-latest, macos-latest] +``` + +**3. Platform Module API** + +The platform module provides: + +| Function | Purpose | +|----------|---------| +| `isWindows()` / `isMacOS()` / `isLinux()` | OS detection | +| `getPathDelimiter()` | Get `;` (Windows) or `:` (Unix) | +| `getExecutableExtension()` | Get `.exe` (Windows) or `` (Unix) | +| `findExecutable(name)` | Find executables across platforms | +| `getBinaryDirectories()` | Get platform-specific bin paths | +| `requiresShell(command)` | Check if .cmd/.bat needs shell on Windows | + +**4. Path Handling Best Practices** + +```typescript +// ❌ WRONG - Hardcoded Windows path +const claudePath = 'C:\\Program Files\\Claude\\claude.exe'; + +// ❌ WRONG - Hardcoded macOS path +const brewPath = '/opt/homebrew/bin/python3'; + +// ❌ WRONG - Manual path joining +const fullPath = dir + '/subdir/file.txt'; + +// ✅ CORRECT - Use platform abstraction +import { findExecutable, joinPaths } from './platform'; + +const claudePath = await findExecutable('claude'); +const fullPath = joinPaths(dir, 'subdir', 'file.txt'); +``` + +**5. Testing Platform-Specific Code** + +```typescript +// Mock process.platform for testing +import { isWindows } from './platform'; + +// In tests, use jest.mock or similar +jest.mock('./platform', () => ({ + isWindows: () => true // Simulate Windows +})); +``` + +**6. When You Need Platform-Specific Code** + +If you must write platform-specific code: + +1. **Add it to the platform module** - Not scattered in your feature code +2. **Write tests for all platforms** - Mock `process.platform` to test each case +3. **Use feature detection** - Check for file/path existence, not just OS name +4. **Document why** - Explain the platform difference in comments + +**7. Submitting Platform-Specific Fixes** + +When fixing a platform-specific bug: + +1. Ensure your fix doesn't break other platforms +2. Test locally if you have access to other OSs +3. Rely on CI to catch issues you can't test +4. Consider adding a test that mocks other platforms + +**Example: Adding a New Tool Detection** + +```typescript +// ✅ CORRECT - Add to platform/paths.ts +export function getMyToolPaths(): string[] { + if (isWindows()) { + return [ + joinPaths('C:', 'Program Files', 'MyTool', 'tool.exe'), + // ... more Windows paths + ]; + } + return [ + joinPaths('/usr', 'local', 'bin', 'mytool'), + // ... more Unix paths + ]; +} + +// ✅ CORRECT - Use in your code +import { findExecutable, getMyToolPaths } from './platform'; + +const toolPath = await findExecutable('mytool', getMyToolPaths()); +``` + ### End-to-End Testing (Electron App) **IMPORTANT: When bug fixing or implementing new features in the frontend, AI agents can perform automated E2E testing using the Electron MCP server.** diff --git a/apps/backend/core/client.py b/apps/backend/core/client.py index 91f384cf80..807d129561 100644 --- a/apps/backend/core/client.py +++ b/apps/backend/core/client.py @@ -16,7 +16,6 @@ import json import logging import os -import platform import shutil import subprocess import threading @@ -24,6 +23,14 @@ from pathlib import Path from typing import Any +from core.platform import ( + get_claude_detection_paths_structured, + get_comspec_path, + is_macos, + is_windows, + validate_cli_path, +) + logger = logging.getLogger(__name__) # ============================================================================= @@ -133,83 +140,18 @@ def invalidate_project_cache(project_dir: Path | None = None) -> None: _CLI_CACHE_LOCK = threading.Lock() -def _get_claude_detection_paths() -> dict[str, list[str]]: +def _get_claude_detection_paths() -> dict[str, list[str] | str]: """ Get all candidate paths for Claude CLI detection. - Returns platform-specific paths where Claude CLI might be installed. - - IMPORTANT: This function mirrors the frontend's getClaudeDetectionPaths() - in apps/frontend/src/main/cli-tool-manager.ts. Both implementations MUST - be kept in sync to ensure consistent detection behavior across the - Python backend and Electron frontend. - - When adding new detection paths, update BOTH: - 1. This function (_get_claude_detection_paths in client.py) - 2. getClaudeDetectionPaths() in cli-tool-manager.ts + This is a thin wrapper around the platform module's implementation. + See core/platform/__init__.py:get_claude_detection_paths_structured() + for the canonical implementation. Returns: - Dict with 'homebrew', 'platform', and 'nvm' path lists - """ - home_dir = Path.home() - is_windows = platform.system() == "Windows" - - homebrew_paths = [ - "/opt/homebrew/bin/claude", # Apple Silicon - "/usr/local/bin/claude", # Intel Mac - ] - - if is_windows: - platform_paths = [ - str(home_dir / "AppData" / "Local" / "Programs" / "claude" / "claude.exe"), - str(home_dir / "AppData" / "Roaming" / "npm" / "claude.cmd"), - str(home_dir / ".local" / "bin" / "claude.exe"), - "C:\\Program Files\\Claude\\claude.exe", - "C:\\Program Files (x86)\\Claude\\claude.exe", - ] - else: - platform_paths = [ - str(home_dir / ".local" / "bin" / "claude"), - str(home_dir / "bin" / "claude"), - ] - - nvm_versions_dir = str(home_dir / ".nvm" / "versions" / "node") - - return { - "homebrew": homebrew_paths, - "platform": platform_paths, - "nvm_versions_dir": nvm_versions_dir, - } - - -def _is_secure_path(path_str: str) -> bool: + Dict with 'homebrew', 'platform', and 'nvm_versions_dir' keys """ - Validate that a path doesn't contain dangerous characters. - - Prevents command injection attacks by rejecting paths with shell metacharacters, - directory traversal patterns, or environment variable expansion. - - Args: - path_str: Path to validate - - Returns: - True if the path is safe, False otherwise - """ - import re - - dangerous_patterns = [ - r'[;&|`${}[\]<>!"^]', # Shell metacharacters - r"%[^%]+%", # Windows environment variable expansion - r"\.\./", # Unix directory traversal - r"\.\.\\", # Windows directory traversal - r"[\r\n]", # Newlines (command injection) - ] - - for pattern in dangerous_patterns: - if re.search(pattern, path_str): - return False - - return True + return get_claude_detection_paths_structured() def _validate_claude_cli(cli_path: str) -> tuple[bool, str | None]: @@ -232,13 +174,11 @@ def _validate_claude_cli(cli_path: str) -> tuple[bool, str | None]: import re # Security validation: reject paths with shell metacharacters or directory traversal - if not _is_secure_path(cli_path): + if not validate_cli_path(cli_path): logger.warning(f"Rejecting insecure Claude CLI path: {cli_path}") return False, None try: - is_windows = platform.system() == "Windows" - # Augment PATH with the CLI directory for proper resolution env = os.environ.copy() cli_dir = os.path.dirname(cli_path) @@ -249,11 +189,9 @@ def _validate_claude_cli(cli_path: str) -> tuple[bool, str | None]: # /d = disable AutoRun registry commands # /s = strip first and last quotes, preserving inner quotes # /c = run command then terminate - if is_windows and cli_path.lower().endswith((".cmd", ".bat")): - # Get cmd.exe path from environment or use default - cmd_exe = os.environ.get("ComSpec") or os.path.join( - os.environ.get("SystemRoot", "C:\\Windows"), "System32", "cmd.exe" - ) + if is_windows() and cli_path.lower().endswith((".cmd", ".bat")): + # Get cmd.exe path from platform module + cmd_exe = get_comspec_path() # Use double-quoted command line for paths with spaces cmd_line = f'""{cli_path}" --version"' result = subprocess.run( @@ -271,7 +209,7 @@ def _validate_claude_cli(cli_path: str) -> tuple[bool, str | None]: text=True, timeout=5, env=env, - creationflags=subprocess.CREATE_NO_WINDOW if is_windows else 0, + creationflags=subprocess.CREATE_NO_WINDOW if is_windows() else 0, ) if result.returncode == 0: @@ -309,7 +247,6 @@ def find_claude_cli() -> str | None: logger.debug(f"Using cached Claude CLI path: {cached}") return cached - is_windows = platform.system() == "Windows" paths = _get_claude_detection_paths() # 1. Check environment variable override @@ -335,7 +272,7 @@ def find_claude_cli() -> str | None: return which_path # 3. Homebrew paths (macOS) - if platform.system() == "Darwin": + if is_macos(): for hb_path in paths["homebrew"]: if Path(hb_path).exists(): valid, version = _validate_claude_cli(hb_path) @@ -346,7 +283,7 @@ def find_claude_cli() -> str | None: return hb_path # 4. NVM paths (Unix only) - check Node.js version manager installations - if not is_windows: + if not is_windows(): nvm_dir = Path(paths["nvm_versions_dir"]) if nvm_dir.exists(): try: @@ -778,7 +715,7 @@ def create_client( # Debug: Log git-bash path detection on Windows if "CLAUDE_CODE_GIT_BASH_PATH" in sdk_env: logger.info(f"Git Bash path found: {sdk_env['CLAUDE_CODE_GIT_BASH_PATH']}") - elif platform.system() == "Windows": + elif is_windows(): logger.warning("Git Bash path not detected on Windows!") # Check if Linear integration is enabled diff --git a/apps/backend/core/platform/__init__.py b/apps/backend/core/platform/__init__.py new file mode 100644 index 0000000000..512fe6d5aa --- /dev/null +++ b/apps/backend/core/platform/__init__.py @@ -0,0 +1,516 @@ +""" +Platform Abstraction Layer + +Centralized platform-specific operations for the Python backend. +All code that checks sys.platform or handles OS differences should use this module. + +Design principles: +- Single source of truth for platform detection +- Feature detection over platform detection when possible +- Clear, intention-revealing names +- Immutable configurations where possible +""" + +import os +import platform +import re +import shutil +import subprocess +from enum import Enum +from pathlib import Path + +# ============================================================================ +# Type Definitions +# ============================================================================ + + +class OS(Enum): + """Supported operating systems.""" + + WINDOWS = "Windows" + MACOS = "Darwin" + LINUX = "Linux" + + +class ShellType(Enum): + """Available shell types.""" + + POWERSHELL = "powershell" + CMD = "cmd" + BASH = "bash" + ZSH = "zsh" + FISH = "fish" + UNKNOWN = "unknown" + + +# ============================================================================ +# Platform Detection +# ============================================================================ + + +def get_current_os() -> OS: + """Get the current operating system. + + Returns the OS enum for the current platform. For unsupported Unix-like + systems (e.g., FreeBSD, SunOS), defaults to Linux for compatibility. + """ + system = platform.system() + if system == "Windows": + return OS.WINDOWS + elif system == "Darwin": + return OS.MACOS + # Default to Linux for other Unix-like systems (FreeBSD, SunOS, etc.) + return OS.LINUX + + +def is_windows() -> bool: + """Check if running on Windows.""" + return platform.system() == "Windows" + + +def is_macos() -> bool: + """Check if running on macOS.""" + return platform.system() == "Darwin" + + +def is_linux() -> bool: + """Check if running on Linux.""" + return platform.system() == "Linux" + + +def is_unix() -> bool: + """Check if running on a Unix-like system (macOS or Linux).""" + return not is_windows() + + +# ============================================================================ +# Path Configuration +# ============================================================================ + + +def get_path_delimiter() -> str: + """Get the PATH separator for environment variables.""" + return ";" if is_windows() else ":" + + +def get_executable_extension() -> str: + """Get the default file extension for executables.""" + return ".exe" if is_windows() else "" + + +def with_executable_extension(base_name: str) -> str: + """Add executable extension to a base name if needed.""" + if not base_name: + return base_name + + # Check if already has extension + if os.path.splitext(base_name)[1]: + return base_name + + exe_ext = get_executable_extension() + return f"{base_name}{exe_ext}" if exe_ext else base_name + + +# ============================================================================ +# Binary Directories +# ============================================================================ + + +def get_binary_directories() -> dict[str, list[str]]: + """ + Get common binary directories for the current platform. + + Returns: + Dict with 'user' and 'system' keys containing lists of directories. + """ + home_dir = Path.home() + + if is_windows(): + return { + "user": [ + str(home_dir / "AppData" / "Local" / "Programs"), + str(home_dir / "AppData" / "Roaming" / "npm"), + str(home_dir / ".local" / "bin"), + ], + "system": [ + os.environ.get("ProgramFiles", "C:\\Program Files"), + os.environ.get("ProgramFiles(x86)", "C:\\Program Files (x86)"), + os.path.join(os.environ.get("SystemRoot", "C:\\Windows"), "System32"), + ], + } + + if is_macos(): + return { + "user": [ + str(home_dir / ".local" / "bin"), + str(home_dir / "bin"), + ], + "system": [ + "/opt/homebrew/bin", + "/usr/local/bin", + "/usr/bin", + ], + } + + # Linux + return { + "user": [ + str(home_dir / ".local" / "bin"), + str(home_dir / "bin"), + ], + "system": [ + "/usr/bin", + "/usr/local/bin", + "/snap/bin", + ], + } + + +def get_homebrew_path() -> str | None: + """ + Get Homebrew binary directory (macOS only). + + Returns: + Homebrew bin path or None if not on macOS. + """ + if not is_macos(): + return None + + homebrew_paths = [ + "/opt/homebrew/bin", # Apple Silicon + "/usr/local/bin", # Intel + ] + + for brew_path in homebrew_paths: + if os.path.exists(brew_path): + return brew_path + + return homebrew_paths[0] # Default to Apple Silicon + + +# ============================================================================ +# Tool Detection +# ============================================================================ + + +def find_executable(name: str, additional_paths: list[str] | None = None) -> str | None: + """ + Find an executable in standard locations. + + Searches: + 1. System PATH + 2. Platform-specific binary directories + 3. Additional custom paths + + Args: + name: Name of the executable (without extension) + additional_paths: Optional list of additional paths to search + + Returns: + Full path to executable if found, None otherwise + """ + # First check system PATH + in_path = shutil.which(name) + if in_path: + return in_path + + # Check with extension on Windows + if is_windows(): + for ext in [".exe", ".cmd", ".bat"]: + in_path = shutil.which(f"{name}{ext}") + if in_path: + return in_path + + # Search in platform-specific directories + bins = get_binary_directories() + search_dirs = bins["user"] + bins["system"] + + if additional_paths: + search_dirs.extend(additional_paths) + + for directory in search_dirs: + if not os.path.isdir(directory): + continue + + # Try without extension + exe_path = os.path.join(directory, with_executable_extension(name)) + if os.path.isfile(exe_path): + return exe_path + + # Try common extensions on Windows + if is_windows(): + for ext in [".exe", ".cmd", ".bat"]: + exe_path = os.path.join(directory, f"{name}{ext}") + if os.path.isfile(exe_path): + return exe_path + + return None + + +def get_claude_detection_paths() -> list[str]: + """ + Get platform-specific paths for Claude CLI detection. + + Returns: + List of possible Claude CLI executable paths. + """ + home_dir = Path.home() + paths = [] + + if is_windows(): + paths.extend( + [ + str( + home_dir + / "AppData" + / "Local" + / "Programs" + / "claude" + / "claude.exe" + ), + str(home_dir / "AppData" / "Roaming" / "npm" / "claude.cmd"), + str(home_dir / ".local" / "bin" / "claude.exe"), + r"C:\Program Files\Claude\claude.exe", + r"C:\Program Files (x86)\Claude\claude.exe", + ] + ) + else: + paths.extend( + [ + str(home_dir / ".local" / "bin" / "claude"), + str(home_dir / "bin" / "claude"), + ] + ) + + # Add Homebrew path on macOS + if is_macos(): + brew_path = get_homebrew_path() + if brew_path: + paths.append(os.path.join(brew_path, "claude")) + + return paths + + +def get_claude_detection_paths_structured() -> dict[str, list[str] | str]: + """ + Get platform-specific paths for Claude CLI detection in structured format. + + Returns a dict with categorized paths for different detection strategies: + - 'homebrew': Homebrew installation paths (macOS) + - 'platform': Platform-specific standard installation locations + - 'nvm_versions_dir': NVM versions directory path for scanning Node installations + + This structured format allows callers to implement custom detection logic + for each category (e.g., iterating NVM version directories). + + Returns: + Dict with 'homebrew', 'platform', and 'nvm_versions_dir' keys + """ + home_dir = Path.home() + + homebrew_paths = [ + "/opt/homebrew/bin/claude", # Apple Silicon + "/usr/local/bin/claude", # Intel Mac + ] + + if is_windows(): + platform_paths = [ + str(home_dir / "AppData/Local/Programs/claude/claude.exe"), + str(home_dir / "AppData/Roaming/npm/claude.cmd"), + str(home_dir / ".local/bin/claude.exe"), + r"C:\Program Files\Claude\claude.exe", + r"C:\Program Files (x86)\Claude\claude.exe", + ] + else: + platform_paths = [ + str(home_dir / ".local" / "bin" / "claude"), + str(home_dir / "bin" / "claude"), + ] + + nvm_versions_dir = str(home_dir / ".nvm" / "versions" / "node") + + return { + "homebrew": homebrew_paths, + "platform": platform_paths, + "nvm_versions_dir": nvm_versions_dir, + } + + +def get_python_commands() -> list[list[str]]: + """ + Get platform-specific Python command variations as argument sequences. + + Returns command arguments as sequences so callers can pass each entry + directly to subprocess.run(cmd) or use cmd[0] with shutil.which(). + + Returns: + List of command argument lists to try, in order of preference. + Each inner list contains the executable and any required arguments. + + Example: + for cmd in get_python_commands(): + if shutil.which(cmd[0]): + subprocess.run(cmd + ["--version"]) + break + """ + if is_windows(): + return [["py", "-3"], ["python"], ["python3"], ["py"]] + return [["python3"], ["python"]] + + +def validate_cli_path(cli_path: str) -> bool: + """ + Validate that a CLI path is secure and executable. + + Prevents command injection attacks by rejecting paths with shell metacharacters, + directory traversal patterns, or environment variable expansion. + + Args: + cli_path: Path to validate + + Returns: + True if path is secure, False otherwise + """ + if not cli_path: + return False + + # Security validation: reject paths with shell metacharacters or other dangerous patterns + dangerous_patterns = [ + r'[;&|`${}[\]<>!"^]', # Shell metacharacters + r"%[^%]+%", # Windows environment variable expansion + r"\.\./", # Unix directory traversal + r"\.\.\\", # Windows directory traversal + r"[\r\n]", # Newlines (command injection) + ] + + for pattern in dangerous_patterns: + if re.search(pattern, cli_path): + return False + + # On Windows, validate executable name additionally + if is_windows(): + # Extract just the executable name + exe_name = os.path.basename(cli_path) + name_without_ext = os.path.splitext(exe_name)[0] + + # Allow only alphanumeric, dots, hyphens, underscores in the name + if not name_without_ext or not all( + c.isalnum() or c in "._-" for c in name_without_ext + ): + return False + + # Check if path exists (if absolute) + if os.path.isabs(cli_path): + return os.path.isfile(cli_path) + + return True + + +# ============================================================================ +# Shell Execution +# ============================================================================ + + +def requires_shell(command: str) -> bool: + """ + Check if a command requires shell execution on Windows. + + Windows needs shell execution for .cmd and .bat files. + + Args: + command: Command string to check + + Returns: + True if shell execution is required + """ + if not is_windows(): + return False + + _, ext = os.path.splitext(command) + return ext.lower() in {".cmd", ".bat", ".ps1"} + + +def get_comspec_path() -> str: + """ + Get the path to cmd.exe on Windows. + + Returns: + Path to cmd.exe or default location. + """ + if is_windows(): + return os.environ.get( + "ComSpec", + os.path.join( + os.environ.get("SystemRoot", "C:\\Windows"), "System32", "cmd.exe" + ), + ) + return "/bin/sh" + + +def build_windows_command(cli_path: str, args: list[str]) -> list[str]: + """ + Build a command array for Windows execution. + + Handles .cmd/.bat files that require shell execution. + + Args: + cli_path: Path to the CLI executable + args: Command arguments + + Returns: + Command array suitable for subprocess.run + """ + if is_windows() and cli_path.lower().endswith((".cmd", ".bat")): + # Use cmd.exe to execute .cmd/.bat files + cmd_exe = get_comspec_path() + # Properly escape arguments for Windows command line + escaped_args = subprocess.list2cmdline(args) + return [cmd_exe, "/d", "/s", "/c", f'"{cli_path}" {escaped_args}'] + + return [cli_path] + args + + +# ============================================================================ +# Environment Variables +# ============================================================================ + + +def get_env_var(name: str, default: str | None = None) -> str | None: + """ + Get environment variable value with case-insensitive support on Windows. + + Args: + name: Environment variable name + default: Default value if not found + + Returns: + Environment variable value or default + """ + if is_windows(): + # Case-insensitive lookup on Windows + for key, value in os.environ.items(): + if key.lower() == name.lower(): + return value + return default + + return os.environ.get(name, default) + + +# ============================================================================ +# Platform Description +# ============================================================================ + + +def get_platform_description() -> str: + """ + Get a human-readable platform description. + + Returns: + String like "Windows (AMD64)" or "macOS (arm64)" + """ + os_name = {OS.WINDOWS: "Windows", OS.MACOS: "macOS", OS.LINUX: "Linux"}.get( + get_current_os(), platform.system() + ) + + arch = platform.machine() + return f"{os_name} ({arch})" diff --git a/apps/frontend/src/main/agent/agent-process.test.ts b/apps/frontend/src/main/agent/agent-process.test.ts index 47ad77d6a8..7fdd25aabd 100644 --- a/apps/frontend/src/main/agent/agent-process.test.ts +++ b/apps/frontend/src/main/agent/agent-process.test.ts @@ -116,11 +116,14 @@ vi.mock('fs', async (importOriginal) => { const actual = await importOriginal(); return { ...actual, - existsSync: vi.fn((path: string) => { + existsSync: vi.fn((inputPath: string) => { + // Normalize path separators for cross-platform compatibility + // path.join() uses backslashes on Windows, so we normalize to forward slashes + const normalizedPath = inputPath.replace(/\\/g, '/'); // Return true for the fake auto-build path and its expected files - if (path === '/fake/auto-build' || - path === '/fake/auto-build/runners' || - path === '/fake/auto-build/runners/spec_runner.py') { + if (normalizedPath === '/fake/auto-build' || + normalizedPath === '/fake/auto-build/runners' || + normalizedPath === '/fake/auto-build/runners/spec_runner.py') { return true; } return false; diff --git a/apps/frontend/src/main/cli-tool-manager.ts b/apps/frontend/src/main/cli-tool-manager.ts index 1a1b69b007..442bd438a7 100644 --- a/apps/frontend/src/main/cli-tool-manager.ts +++ b/apps/frontend/src/main/cli-tool-manager.ts @@ -27,6 +27,7 @@ import os from 'os'; import { promisify } from 'util'; import { app } from 'electron'; import { findExecutable, findExecutableAsync, getAugmentedEnv, getAugmentedEnvAsync, shouldUseShell, existsAsync } from './env-utils'; +import { isWindows, isMacOS, isUnix, joinPaths, getExecutableExtension } from './platform'; import type { ToolDetectionResult } from '../shared/types'; import { findHomebrewPython as findHomebrewPythonUtil } from './utils/homebrew-python'; @@ -95,9 +96,7 @@ interface CacheEntry { function isWrongPlatformPath(pathStr: string | undefined): boolean { if (!pathStr) return false; - const isWindows = process.platform === 'win32'; - - if (isWindows) { + if (isWindows()) { // On Windows, reject Unix-style absolute paths (starting with /) // but allow relative paths and Windows paths if (pathStr.startsWith('/') && !pathStr.startsWith('//')) { @@ -170,20 +169,20 @@ export function getClaudeDetectionPaths(homeDir: string): ClaudeDetectionPaths { '/usr/local/bin/claude', // Intel Mac ]; - const platformPaths = process.platform === 'win32' + const platformPaths = isWindows() ? [ - path.join(homeDir, 'AppData', 'Local', 'Programs', 'claude', 'claude.exe'), - path.join(homeDir, 'AppData', 'Roaming', 'npm', 'claude.cmd'), - path.join(homeDir, '.local', 'bin', 'claude.exe'), + joinPaths(homeDir, 'AppData', 'Local', 'Programs', 'claude', `claude${getExecutableExtension()}`), + joinPaths(homeDir, 'AppData', 'Roaming', 'npm', 'claude.cmd'), + joinPaths(homeDir, '.local', 'bin', `claude${getExecutableExtension()}`), 'C:\\Program Files\\Claude\\claude.exe', 'C:\\Program Files (x86)\\Claude\\claude.exe', ] : [ - path.join(homeDir, '.local', 'bin', 'claude'), - path.join(homeDir, 'bin', 'claude'), + joinPaths(homeDir, '.local', 'bin', 'claude'), + joinPaths(homeDir, 'bin', 'claude'), ]; - const nvmVersionsDir = path.join(homeDir, '.nvm', 'versions', 'node'); + const nvmVersionsDir = joinPaths(homeDir, '.nvm', 'versions', 'node'); return { homebrewPaths, platformPaths, nvmVersionsDir }; } @@ -422,7 +421,7 @@ class CLIToolManager { } // 3. Homebrew Python (macOS) - if (process.platform === 'darwin') { + if (isMacOS()) { const homebrewPath = this.findHomebrewPython(); if (homebrewPath) { const validation = this.validatePython(homebrewPath); @@ -440,7 +439,7 @@ class CLIToolManager { // 4. System PATH (augmented) const candidates = - process.platform === 'win32' + isWindows() ? ['py -3', 'python', 'python3', 'py'] : ['python3', 'python']; @@ -519,7 +518,7 @@ class CLIToolManager { } // 2. Homebrew (macOS) - if (process.platform === 'darwin') { + if (isMacOS()) { const homebrewPaths = [ '/opt/homebrew/bin/git', // Apple Silicon '/usr/local/bin/git', // Intel Mac @@ -557,7 +556,7 @@ class CLIToolManager { } // 4. Windows-specific detection using 'where' command (most reliable for custom installs) - if (process.platform === 'win32') { + if (isWindows()) { // First try 'where' command - finds git regardless of installation location const whereGitPath = findWindowsExecutableViaWhere('git', '[Git]'); if (whereGitPath) { @@ -634,7 +633,7 @@ class CLIToolManager { } // 2. Homebrew (macOS) - if (process.platform === 'darwin') { + if (isMacOS()) { const homebrewPaths = [ '/opt/homebrew/bin/gh', // Apple Silicon '/usr/local/bin/gh', // Intel Mac @@ -672,7 +671,7 @@ class CLIToolManager { } // 4. Windows Program Files - if (process.platform === 'win32') { + if (isWindows()) { const windowsPaths = [ 'C:\\Program Files\\GitHub CLI\\gh.exe', 'C:\\Program Files (x86)\\GitHub CLI\\gh.exe', @@ -725,7 +724,7 @@ class CLIToolManager { console.warn( `[Claude CLI] User-configured path is from different platform, ignoring: ${this.userConfig.claudePath}` ); - } else if (process.platform === 'win32' && !isSecurePath(this.userConfig.claudePath)) { + } else if (isWindows() && !isSecurePath(this.userConfig.claudePath)) { console.warn( `[Claude CLI] User-configured path failed security validation, ignoring: ${this.userConfig.claudePath}` ); @@ -740,7 +739,7 @@ class CLIToolManager { } // 2. Homebrew (macOS) - if (process.platform === 'darwin') { + if (isMacOS()) { for (const claudePath of paths.homebrewPaths) { if (existsSync(claudePath)) { const validation = this.validateClaude(claudePath); @@ -759,7 +758,7 @@ class CLIToolManager { } // 4. Windows where.exe detection (Windows only - most reliable for custom installs) - if (process.platform === 'win32') { + if (isWindows()) { const whereClaudePath = findWindowsExecutableViaWhere('claude', '[Claude CLI]'); if (whereClaudePath) { const validation = this.validateClaude(whereClaudePath); @@ -769,7 +768,7 @@ class CLIToolManager { } // 5. NVM paths (Unix only) - check before platform paths for better Node.js integration - if (process.platform !== 'win32') { + if (isUnix()) { try { if (existsSync(paths.nvmVersionsDir)) { const nodeVersions = readdirSync(paths.nvmVersionsDir, { withFileTypes: true }); @@ -1281,7 +1280,7 @@ class CLIToolManager { console.warn( `[Claude CLI] User-configured path is from different platform, ignoring: ${this.userConfig.claudePath}` ); - } else if (process.platform === 'win32' && !isSecurePath(this.userConfig.claudePath)) { + } else if (isWindows() && !isSecurePath(this.userConfig.claudePath)) { console.warn( `[Claude CLI] User-configured path failed security validation, ignoring: ${this.userConfig.claudePath}` ); @@ -1296,7 +1295,7 @@ class CLIToolManager { } // 2. Homebrew (macOS) - if (process.platform === 'darwin') { + if (isMacOS()) { for (const claudePath of paths.homebrewPaths) { if (await existsAsync(claudePath)) { const validation = await this.validateClaudeAsync(claudePath); @@ -1315,7 +1314,7 @@ class CLIToolManager { } // 4. Windows where.exe detection (async, non-blocking) - if (process.platform === 'win32') { + if (isWindows()) { const whereClaudePath = await findWindowsExecutableViaWhereAsync('claude', '[Claude CLI]'); if (whereClaudePath) { const validation = await this.validateClaudeAsync(whereClaudePath); @@ -1325,7 +1324,7 @@ class CLIToolManager { } // 5. NVM paths (Unix only) - check before platform paths for better Node.js integration - if (process.platform !== 'win32') { + if (isUnix()) { try { if (await existsAsync(paths.nvmVersionsDir)) { const nodeVersions = await fsPromises.readdir(paths.nvmVersionsDir, { withFileTypes: true }); @@ -1411,7 +1410,7 @@ class CLIToolManager { } // 3. Homebrew Python (macOS) - simplified async version - if (process.platform === 'darwin') { + if (isMacOS()) { const homebrewPaths = [ '/opt/homebrew/bin/python3', '/opt/homebrew/bin/python3.12', @@ -1437,7 +1436,7 @@ class CLIToolManager { // 4. System PATH (augmented) const candidates = - process.platform === 'win32' + isWindows() ? ['py -3', 'python', 'python3', 'py'] : ['python3', 'python']; @@ -1510,7 +1509,7 @@ class CLIToolManager { } // 2. Homebrew (macOS) - if (process.platform === 'darwin') { + if (isMacOS()) { const homebrewPaths = [ '/opt/homebrew/bin/git', '/usr/local/bin/git', @@ -1548,7 +1547,7 @@ class CLIToolManager { } // 4. Windows-specific detection (async to avoid blocking main process) - if (process.platform === 'win32') { + if (isWindows()) { const whereGitPath = await findWindowsExecutableViaWhereAsync('git', '[Git]'); if (whereGitPath) { const validation = await this.validateGitAsync(whereGitPath); @@ -1616,7 +1615,7 @@ class CLIToolManager { } // 2. Homebrew (macOS) - if (process.platform === 'darwin') { + if (isMacOS()) { const homebrewPaths = [ '/opt/homebrew/bin/gh', '/usr/local/bin/gh', @@ -1654,7 +1653,7 @@ class CLIToolManager { } // 4. Windows Program Files - if (process.platform === 'win32') { + if (isWindows()) { const windowsPaths = [ 'C:\\Program Files\\GitHub CLI\\gh.exe', 'C:\\Program Files (x86)\\GitHub CLI\\gh.exe', @@ -1698,9 +1697,7 @@ class CLIToolManager { } const resourcesPath = process.resourcesPath; - const isWindows = process.platform === 'win32'; - - const pythonPath = isWindows + const pythonPath = isWindows() ? path.join(resourcesPath, 'python', 'python.exe') : path.join(resourcesPath, 'python', 'bin', 'python3'); diff --git a/apps/frontend/src/main/env-utils.ts b/apps/frontend/src/main/env-utils.ts index fc54f2ac35..5f592c0562 100644 --- a/apps/frontend/src/main/env-utils.ts +++ b/apps/frontend/src/main/env-utils.ts @@ -16,6 +16,7 @@ import { promises as fsPromises } from 'fs'; import { execFileSync, execFile } from 'child_process'; import { promisify } from 'util'; import { getSentryEnvForSubprocess } from './sentry'; +import { isWindows, isUnix, getPathDelimiter } from './platform'; const execFileAsync = promisify(execFile); @@ -54,7 +55,7 @@ let npmGlobalPrefixCachePromise: Promise | null = null; function getNpmGlobalPrefix(): string | null { try { // On Windows, use npm.cmd for proper command resolution - const npmCommand = process.platform === 'win32' ? 'npm.cmd' : 'npm'; + const npmCommand = isWindows() ? 'npm.cmd' : 'npm'; // Use --location=global to bypass workspace context and avoid ENOWORKSPACES error const rawPrefix = execFileSync(npmCommand, ['config', 'get', 'prefix', '--location=global'], { @@ -62,7 +63,7 @@ function getNpmGlobalPrefix(): string | null { timeout: 3000, windowsHide: true, cwd: os.homedir(), // Run from home dir to avoid ENOWORKSPACES error in monorepos - shell: process.platform === 'win32', // Enable shell on Windows for .cmd resolution + shell: isWindows(), // Enable shell on Windows for .cmd resolution }).trim(); if (!rawPrefix) { @@ -71,7 +72,7 @@ function getNpmGlobalPrefix(): string | null { // On non-Windows platforms, npm globals are installed in prefix/bin // On Windows, they're installed directly in the prefix directory - const binPath = process.platform === 'win32' + const binPath = isWindows() ? rawPrefix : path.join(rawPrefix, 'bin'); @@ -196,8 +197,7 @@ function buildPathsToAdd( */ export function getAugmentedEnv(additionalPaths?: string[]): Record { const env = { ...process.env } as Record; - const platform = process.platform as 'darwin' | 'linux' | 'win32'; - const pathSeparator = platform === 'win32' ? ';' : ':'; + const pathSeparator = getPathDelimiter(); // Get all candidate paths (platform + additional) const candidatePaths = getExpandedPlatformPaths(additionalPaths); @@ -208,7 +208,7 @@ export function getAugmentedEnv(additionalPaths?: string[]): Record !pathSetForEssentials.has(p)); @@ -257,12 +257,12 @@ export function getAugmentedEnv(additionalPaths?: string[]): Record { // Start the async fetch npmGlobalPrefixCachePromise = (async () => { try { - const npmCommand = process.platform === 'win32' ? 'npm.cmd' : 'npm'; + const npmCommand = isWindows() ? 'npm.cmd' : 'npm'; const { stdout } = await execFileAsync(npmCommand, ['config', 'get', 'prefix', '--location=global'], { encoding: 'utf-8', timeout: 3000, windowsHide: true, cwd: os.homedir(), // Run from home dir to avoid ENOWORKSPACES error in monorepos - shell: process.platform === 'win32', + shell: isWindows(), }); const rawPrefix = stdout.trim(); @@ -330,7 +330,7 @@ async function getNpmGlobalPrefixAsync(): Promise { return null; } - const binPath = process.platform === 'win32' + const binPath = isWindows() ? rawPrefix : path.join(rawPrefix, 'bin'); @@ -360,8 +360,7 @@ async function getNpmGlobalPrefixAsync(): Promise { */ export async function getAugmentedEnvAsync(additionalPaths?: string[]): Promise> { const env = { ...process.env } as Record; - const platform = process.platform as 'darwin' | 'linux' | 'win32'; - const pathSeparator = platform === 'win32' ? ';' : ':'; + const pathSeparator = getPathDelimiter(); // Get all candidate paths (platform + additional) const candidatePaths = getExpandedPlatformPaths(additionalPaths); @@ -369,7 +368,7 @@ export async function getAugmentedEnvAsync(additionalPaths?: string[]): Promise< // Ensure essential system paths are present (for macOS Keychain access) let currentPath = env.PATH || ''; - if (platform !== 'win32') { + if (isUnix()) { const pathSetForEssentials = new Set(currentPath.split(pathSeparator).filter(Boolean)); const missingEssentials = ESSENTIAL_SYSTEM_PATHS.filter(p => !pathSetForEssentials.has(p)); @@ -421,10 +420,10 @@ export async function getAugmentedEnvAsync(additionalPaths?: string[]): Promise< */ export async function findExecutableAsync(command: string): Promise { const env = await getAugmentedEnvAsync(); - const pathSeparator = process.platform === 'win32' ? ';' : ':'; + const pathSeparator = getPathDelimiter(); const pathDirs = (env.PATH || '').split(pathSeparator); - const extensions = process.platform === 'win32' + const extensions = isWindows() ? ['.exe', '.cmd', '.bat', '.ps1', ''] : ['']; @@ -470,7 +469,7 @@ export function clearNpmPrefixCache(): void { */ export function shouldUseShell(command: string): boolean { // Only Windows needs special handling for .cmd/.bat files - if (process.platform !== 'win32') { + if (isUnix()) { return false; } diff --git a/apps/frontend/src/main/platform/__tests__/platform.test.ts b/apps/frontend/src/main/platform/__tests__/platform.test.ts new file mode 100644 index 0000000000..db12ac4ad6 --- /dev/null +++ b/apps/frontend/src/main/platform/__tests__/platform.test.ts @@ -0,0 +1,334 @@ +/** + * Platform Module Tests + * + * Tests platform abstraction layer using mocks to simulate + * different operating systems. + */ + +import { describe, it, expect, afterEach, vi } from 'vitest'; +import * as path from 'path'; +import { + getCurrentOS, + isWindows, + isMacOS, + isLinux, + isUnix, + getPathConfig, + getPathDelimiter, + getExecutableExtension, + withExecutableExtension, + getBinaryDirectories, + getHomebrewPath, + getShellConfig, + requiresShell, + getNpmCommand, + getNpxCommand, + isSecurePath, + normalizePath, + joinPaths, + getPlatformDescription +} from '../index.js'; + +// Mock process.platform +const originalPlatform = process.platform; + +function mockPlatform(platform: NodeJS.Platform) { + Object.defineProperty(process, 'platform', { + value: platform, + writable: true, + configurable: true + }); +} + +describe('Platform Module', () => { + afterEach(() => { + mockPlatform(originalPlatform); + vi.restoreAllMocks(); + }); + + describe('getCurrentOS', () => { + it('returns win32 on Windows', () => { + mockPlatform('win32'); + expect(getCurrentOS()).toBe('win32'); + }); + + it('returns darwin on macOS', () => { + mockPlatform('darwin'); + expect(getCurrentOS()).toBe('darwin'); + }); + + it('returns linux on Linux', () => { + mockPlatform('linux'); + expect(getCurrentOS()).toBe('linux'); + }); + }); + + describe('OS Detection', () => { + it('detects Windows correctly', () => { + mockPlatform('win32'); + expect(isWindows()).toBe(true); + expect(isMacOS()).toBe(false); + expect(isLinux()).toBe(false); + expect(isUnix()).toBe(false); + }); + + it('detects macOS correctly', () => { + mockPlatform('darwin'); + expect(isWindows()).toBe(false); + expect(isMacOS()).toBe(true); + expect(isLinux()).toBe(false); + expect(isUnix()).toBe(true); + }); + + it('detects Linux correctly', () => { + mockPlatform('linux'); + expect(isWindows()).toBe(false); + expect(isMacOS()).toBe(false); + expect(isLinux()).toBe(true); + expect(isUnix()).toBe(true); + }); + }); + + describe('Path Configuration', () => { + it('returns Windows path config on Windows', () => { + mockPlatform('win32'); + const config = getPathConfig(); + + expect(config.separator).toBe(path.sep); + expect(config.delimiter).toBe(';'); + expect(config.executableExtensions).toContain('.exe'); + expect(config.executableExtensions).toContain('.cmd'); + expect(config.executableExtensions).toContain('.bat'); + }); + + it('returns Unix path config on macOS', () => { + mockPlatform('darwin'); + const config = getPathConfig(); + + expect(config.delimiter).toBe(':'); + expect(config.executableExtensions).toEqual(['']); + }); + + it('returns Unix path config on Linux', () => { + mockPlatform('linux'); + const config = getPathConfig(); + + expect(config.delimiter).toBe(':'); + expect(config.executableExtensions).toEqual(['']); + }); + }); + + describe('Path Delimiter', () => { + it('returns semicolon on Windows', () => { + mockPlatform('win32'); + expect(getPathDelimiter()).toBe(';'); + }); + + it('returns colon on Unix', () => { + mockPlatform('darwin'); + expect(getPathDelimiter()).toBe(':'); + }); + }); + + describe('Executable Extension', () => { + it('returns .exe on Windows', () => { + mockPlatform('win32'); + expect(getExecutableExtension()).toBe('.exe'); + }); + + it('returns empty string on Unix', () => { + mockPlatform('darwin'); + expect(getExecutableExtension()).toBe(''); + }); + }); + + describe('withExecutableExtension', () => { + it('adds .exe on Windows when no extension present', () => { + mockPlatform('win32'); + expect(withExecutableExtension('claude')).toBe('claude.exe'); + }); + + it('does not add extension if already present on Windows', () => { + mockPlatform('win32'); + expect(withExecutableExtension('claude.exe')).toBe('claude.exe'); + expect(withExecutableExtension('npm.cmd')).toBe('npm.cmd'); + }); + + it('returns original name on Unix', () => { + mockPlatform('darwin'); + expect(withExecutableExtension('claude')).toBe('claude'); + }); + }); + + describe('Binary Directories', () => { + it('returns Windows-specific directories on Windows', () => { + mockPlatform('win32'); + const dirs = getBinaryDirectories(); + + expect(dirs.user).toContainEqual( + expect.stringContaining('AppData') + ); + expect(dirs.system).toContainEqual( + expect.stringContaining('Program Files') + ); + }); + + it('returns macOS-specific directories on macOS', () => { + mockPlatform('darwin'); + const dirs = getBinaryDirectories(); + + expect(dirs.system).toContain('/opt/homebrew/bin'); + expect(dirs.system).toContain('/usr/local/bin'); + }); + + it('returns Linux-specific directories on Linux', () => { + mockPlatform('linux'); + const dirs = getBinaryDirectories(); + + expect(dirs.system).toContain('/usr/bin'); + expect(dirs.system).toContain('/snap/bin'); + }); + }); + + describe('Homebrew Path', () => { + it('returns null on non-macOS platforms', () => { + mockPlatform('win32'); + expect(getHomebrewPath()).toBe(null); + + mockPlatform('linux'); + expect(getHomebrewPath()).toBe(null); + }); + + it('returns path on macOS', () => { + mockPlatform('darwin'); + const result = getHomebrewPath(); + + // Should be one of the Homebrew paths + expect(['/opt/homebrew/bin', '/usr/local/bin']).toContain(result); + }); + }); + + describe('Shell Configuration', () => { + it('returns PowerShell config on Windows by default', () => { + mockPlatform('win32'); + const config = getShellConfig(); + + // Accept either PowerShell Core (pwsh.exe), Windows PowerShell (powershell.exe), + // or cmd.exe fallback (when PowerShell paths don't exist, e.g., in test environments) + const isValidShell = config.executable.includes('pwsh.exe') || + config.executable.includes('powershell.exe') || + config.executable.includes('cmd.exe'); + expect(isValidShell).toBe(true); + }); + + it('returns shell config on Unix', () => { + mockPlatform('darwin'); + const config = getShellConfig(); + + expect(config.args).toEqual(['-l']); + }); + }); + + describe('requiresShell', () => { + it('returns true for .cmd files on Windows', () => { + mockPlatform('win32'); + expect(requiresShell('npm.cmd')).toBe(true); + expect(requiresShell('script.bat')).toBe(true); + }); + + it('returns false for executables on Windows', () => { + mockPlatform('win32'); + expect(requiresShell('node.exe')).toBe(false); + }); + + it('returns false on Unix', () => { + mockPlatform('darwin'); + expect(requiresShell('npm')).toBe(false); + }); + }); + + describe('npm Commands', () => { + it('returns npm.cmd on Windows', () => { + mockPlatform('win32'); + expect(getNpmCommand()).toBe('npm.cmd'); + expect(getNpxCommand()).toBe('npx.cmd'); + }); + + it('returns npm on Unix', () => { + mockPlatform('darwin'); + expect(getNpmCommand()).toBe('npm'); + expect(getNpxCommand()).toBe('npx'); + }); + }); + + describe('isSecurePath', () => { + it('rejects paths with .. on all platforms', () => { + mockPlatform('win32'); + expect(isSecurePath('../etc/passwd')).toBe(false); + expect(isSecurePath('../../Windows')).toBe(false); + + mockPlatform('darwin'); + expect(isSecurePath('../etc/passwd')).toBe(false); + }); + + it('rejects shell metacharacters (command injection prevention)', () => { + mockPlatform('darwin'); + expect(isSecurePath('cmd;rm -rf /')).toBe(false); + expect(isSecurePath('cmd|cat /etc/passwd')).toBe(false); + expect(isSecurePath('cmd`whoami`')).toBe(false); + expect(isSecurePath('cmd$(whoami)')).toBe(false); + expect(isSecurePath('cmd{test}')).toBe(false); + expect(isSecurePath('cmdoutput')).toBe(false); + }); + + it('rejects Windows environment variable expansion', () => { + mockPlatform('win32'); + expect(isSecurePath('%PROGRAMFILES%\\cmd.exe')).toBe(false); + expect(isSecurePath('%SystemRoot%\\System32\\cmd.exe')).toBe(false); + }); + + it('rejects newline injection', () => { + mockPlatform('darwin'); + expect(isSecurePath('cmd\n/bin/sh')).toBe(false); + expect(isSecurePath('cmd\r\n/bin/sh')).toBe(false); + }); + + it('validates Windows executable names', () => { + mockPlatform('win32'); + expect(isSecurePath('claude.exe')).toBe(true); + expect(isSecurePath('my-script.cmd')).toBe(true); + expect(isSecurePath('valid_name-123.exe')).toBe(true); + expect(isSecurePath('dangerous;command.exe')).toBe(false); + expect(isSecurePath('bad&name.exe')).toBe(false); + }); + + it('accepts valid paths on Unix', () => { + mockPlatform('darwin'); + expect(isSecurePath('/usr/bin/node')).toBe(true); + expect(isSecurePath('/opt/homebrew/bin/python3')).toBe(true); + }); + }); + + describe('normalizePath', () => { + it('normalizes paths correctly', () => { + const result = normalizePath('some/path/./to/../file'); + expect(result).toContain('file'); + }); + }); + + describe('joinPaths', () => { + it('joins paths with platform separator', () => { + const result = joinPaths('home', 'user', 'project'); + expect(result).toContain('project'); + }); + }); + + describe('getPlatformDescription', () => { + it('returns platform description', () => { + const desc = getPlatformDescription(); + expect(desc).toMatch(/(Windows|macOS|Linux)/); + expect(desc).toMatch(/\(.*\)/); // Architecture in parentheses + }); + }); +}); diff --git a/apps/frontend/src/main/platform/index.ts b/apps/frontend/src/main/platform/index.ts new file mode 100644 index 0000000000..04f19f579d --- /dev/null +++ b/apps/frontend/src/main/platform/index.ts @@ -0,0 +1,401 @@ +/** + * Platform Abstraction Layer + * + * Centralized platform-specific operations. All code that checks + * process.platform or handles OS differences should go here. + * + * Design principles: + * - Single source of truth for platform detection + * - Feature detection over platform detection when possible + * - Clear, intention-revealing names + * - Immutable configurations + */ + +import * as os from 'os'; +import * as path from 'path'; +import { existsSync } from 'fs'; +import { OS, ShellType, PathConfig, ShellConfig, BinaryDirectories } from './types'; + +// Re-export from paths.ts for backward compatibility +export { getWindowsShellPaths } from './paths'; + +/** + * Get the current operating system + * + * Returns the OS enum if running on a supported platform (Windows, macOS, Linux), + * otherwise defaults to Linux for other Unix-like systems (e.g., FreeBSD, SunOS). + */ +export function getCurrentOS(): OS { + const platform = process.platform; + if (platform === OS.Windows || platform === OS.macOS || platform === OS.Linux) { + return platform as OS; + } + // Default to Linux for other Unix-like systems + return OS.Linux; +} + +/** + * Check if running on Windows + */ +export function isWindows(): boolean { + return process.platform === OS.Windows; +} + +/** + * Check if running on macOS + */ +export function isMacOS(): boolean { + return process.platform === OS.macOS; +} + +/** + * Check if running on Linux + */ +export function isLinux(): boolean { + return process.platform === OS.Linux; +} + +/** + * Check if running on a Unix-like system (macOS or Linux) + */ +export function isUnix(): boolean { + return !isWindows(); +} + +/** + * Get path configuration for the current platform + */ +export function getPathConfig(): PathConfig { + if (isWindows()) { + return { + separator: path.sep, + delimiter: ';', + executableExtensions: ['.exe', '.cmd', '.bat', '.ps1'] + }; + } + + return { + separator: path.sep, + delimiter: ':', + executableExtensions: [''] + }; +} + +/** + * Get the path separator for environment variables + */ +export function getPathDelimiter(): string { + return isWindows() ? ';' : ':'; +} + +/** + * Get the default file extension for executables + */ +export function getExecutableExtension(): string { + return isWindows() ? '.exe' : ''; +} + +/** + * Add executable extension to a base name if needed + */ +export function withExecutableExtension(baseName: string): string { + // Handle empty string - return unchanged + if (!baseName) return baseName; + + const ext = path.extname(baseName); + if (ext) return baseName; + + const exeExt = getExecutableExtension(); + return exeExt ? `${baseName}${exeExt}` : baseName; +} + +/** + * Get common binary directories for the current platform + */ +export function getBinaryDirectories(): BinaryDirectories { + const homeDir = os.homedir(); + + if (isWindows()) { + return { + user: [ + path.join(homeDir, 'AppData', 'Local', 'Programs'), + path.join(homeDir, 'AppData', 'Roaming', 'npm'), + path.join(homeDir, '.local', 'bin') + ], + system: [ + process.env.ProgramFiles || 'C:\\Program Files', + process.env['ProgramFiles(x86)'] || 'C:\\Program Files (x86)', + path.join(process.env.SystemRoot || 'C:\\Windows', 'System32') + ] + }; + } + + if (isMacOS()) { + return { + user: [ + path.join(homeDir, '.local', 'bin'), + path.join(homeDir, 'bin') + ], + system: [ + '/opt/homebrew/bin', + '/usr/local/bin', + '/usr/bin' + ] + }; + } + + // Linux + return { + user: [ + path.join(homeDir, '.local', 'bin'), + path.join(homeDir, 'bin') + ], + system: [ + '/usr/bin', + '/usr/local/bin', + '/snap/bin' + ] + }; +} + +/** + * Get Homebrew binary directory (macOS only) + */ +export function getHomebrewPath(): string | null { + if (!isMacOS()) return null; + + const homebrewPaths = [ + '/opt/homebrew/bin', // Apple Silicon + '/usr/local/bin' // Intel + ]; + + for (const brewPath of homebrewPaths) { + if (existsSync(brewPath)) { + return brewPath; + } + } + + return homebrewPaths[0]; // Default to Apple Silicon path +} + +/** + * Get shell configuration for the current platform + */ +export function getShellConfig(preferredShell?: ShellType): ShellConfig { + if (isWindows()) { + return getWindowsShellConfig(preferredShell); + } + + return getUnixShellConfig(preferredShell); +} + +/** + * Get Windows shell configuration + */ +function getWindowsShellConfig(preferredShell?: ShellType): ShellConfig { + const homeDir = os.homedir(); + + // Shell path candidates in order of preference + // Note: path.join('C:', 'foo') produces 'C:foo' (relative to C: drive), not 'C:\foo' + // We must use 'C:\\' or raw paths like 'C:\\Program Files' to get absolute paths + const shellPaths: Record = { + [ShellType.PowerShell]: [ + path.join('C:\\Program Files', 'PowerShell', '7', 'pwsh.exe'), + path.join(homeDir, 'AppData', 'Local', 'Microsoft', 'WindowsApps', 'pwsh.exe'), + path.join(process.env.SystemRoot || 'C:\\Windows', 'System32', 'WindowsPowerShell', 'v1.0', 'powershell.exe') + ], + [ShellType.CMD]: [ + path.join(process.env.SystemRoot || 'C:\\Windows', 'System32', 'cmd.exe') + ], + [ShellType.Bash]: [ + path.join('C:\\Program Files', 'Git', 'bin', 'bash.exe'), + path.join('C:\\Program Files (x86)', 'Git', 'bin', 'bash.exe'), + path.join('C:\\msys64', 'usr', 'bin', 'bash.exe'), + path.join('C:\\cygwin64', 'bin', 'bash.exe') + ], + [ShellType.Zsh]: [], + [ShellType.Fish]: [], + [ShellType.Unknown]: [] + }; + + const shellType = preferredShell || ShellType.PowerShell; + const candidates = shellPaths[shellType] || shellPaths[ShellType.PowerShell]; + + for (const shellPath of candidates) { + if (existsSync(shellPath)) { + return { + executable: shellPath, + args: shellType === ShellType.Bash ? ['--login'] : [], + env: {} + }; + } + } + + // Fallback to default CMD + return { + executable: process.env.ComSpec || path.join(process.env.SystemRoot || 'C:\\Windows', 'System32', 'cmd.exe'), + args: [], + env: {} + }; +} + +/** + * Get Unix shell configuration + */ +function getUnixShellConfig(preferredShell?: ShellType): ShellConfig { + const shellPath = process.env.SHELL || '/bin/zsh'; + + return { + executable: shellPath, + args: ['-l'], + env: {} + }; +} + +/** + * Check if a command requires shell execution on Windows + * + * Windows needs shell execution for .cmd and .bat files + */ +export function requiresShell(command: string): boolean { + if (!isWindows()) return false; + + const ext = path.extname(command).toLowerCase(); + return ['.cmd', '.bat', '.ps1'].includes(ext); +} + +/** + * Get the npm command name for the current platform + */ +export function getNpmCommand(): string { + return isWindows() ? 'npm.cmd' : 'npm'; +} + +/** + * Get the npx command name for the current platform + */ +export function getNpxCommand(): string { + return isWindows() ? 'npx.cmd' : 'npx'; +} + +/** + * Check if a path is secure (prevents command injection attacks) + * + * Rejects paths with shell metacharacters, directory traversal patterns, + * or environment variable expansion. + */ +export function isSecurePath(candidatePath: string): boolean { + // Reject empty strings to maintain cross-platform consistency + if (!candidatePath) return false; + + // Security validation: reject paths with dangerous patterns + const dangerousPatterns = [ + /[;&|`${}[\]<>!"^]/, // Shell metacharacters + /%[^%]+%/, // Windows environment variable expansion + /\.\.\//, // Unix directory traversal + /\.\.\\/, // Windows directory traversal + /[\r\n]/ // Newlines (command injection) + ]; + + for (const pattern of dangerousPatterns) { + if (pattern.test(candidatePath)) { + return false; + } + } + + // On Windows, validate executable names additionally + if (isWindows()) { + const basename = path.basename(candidatePath, getExecutableExtension()); + // Allow only alphanumeric, dots, hyphens, and underscores in the name + return /^[\w.-]+$/.test(basename); + } + + return true; +} + +/** + * Normalize a path for the current platform + */ +export function normalizePath(inputPath: string): string { + return path.normalize(inputPath); +} + +/** + * Join path parts using the platform separator + */ +export function joinPaths(...parts: string[]): string { + return path.join(...parts); +} + +/** + * Get a platform-specific environment variable value + */ +export function getEnvVar(name: string): string | undefined { + // Windows case-insensitive environment variables + if (isWindows()) { + for (const key of Object.keys(process.env)) { + if (key.toLowerCase() === name.toLowerCase()) { + return process.env[key]; + } + } + return undefined; + } + + return process.env[name]; +} + +/** + * Find an executable in standard locations + * + * Searches for an executable by name in: + * 1. System PATH + * 2. Platform-specific binary directories + * 3. Common installation paths + */ +export function findExecutable( + name: string, + additionalPaths: string[] = [] +): string | null { + const config = getPathConfig(); + const searchPaths: string[] = []; + + // Add PATH environment + const pathEnv = getEnvVar('PATH') || ''; + searchPaths.push(...pathEnv.split(config.delimiter).filter(Boolean)); + + // Add platform-specific directories + const bins = getBinaryDirectories(); + searchPaths.push(...bins.user, ...bins.system); + + // Add custom paths + searchPaths.push(...additionalPaths); + + // Search with all applicable extensions + const extensions = [...config.executableExtensions]; + + for (const searchDir of searchPaths) { + for (const ext of extensions) { + const fullPath = path.join(searchDir, `${name}${ext}`); + if (existsSync(fullPath)) { + return fullPath; + } + } + } + + return null; +} + +/** + * Create a platform-aware description for error messages + */ +export function getPlatformDescription(): string { + const currentOS = getCurrentOS(); + const osName = { + [OS.Windows]: 'Windows', + [OS.macOS]: 'macOS', + [OS.Linux]: 'Linux' + }[currentOS] || process.platform; + + const arch = os.arch(); + return `${osName} (${arch})`; +} diff --git a/apps/frontend/src/main/platform/paths.ts b/apps/frontend/src/main/platform/paths.ts new file mode 100644 index 0000000000..bf0a6b228b --- /dev/null +++ b/apps/frontend/src/main/platform/paths.ts @@ -0,0 +1,281 @@ +/** + * Platform-Specific Path Resolvers + * + * Handles detection of tool paths across platforms. + * Each tool has a dedicated resolver function. + */ + +import * as path from 'path'; +import * as os from 'os'; +import { existsSync, readdirSync } from 'fs'; +import { isWindows, isMacOS, getHomebrewPath, joinPaths, getExecutableExtension } from './index'; + +/** + * Resolve Claude CLI executable path + * + * Searches in platform-specific installation directories: + * - Windows: Program Files, AppData, npm + * - macOS: Homebrew, /usr/local/bin + * - Linux: ~/.local/bin, /usr/bin + */ +export function getClaudeExecutablePath(): string[] { + const homeDir = os.homedir(); + const paths: string[] = []; + + if (isWindows()) { + // Note: path.join('C:', 'foo') produces 'C:foo' (relative to C: drive), not 'C:\foo' + // We must use 'C:\\' or raw paths like 'C:\\Program Files' to get absolute paths + paths.push( + joinPaths(homeDir, 'AppData', 'Local', 'Programs', 'claude', `claude${getExecutableExtension()}`), + joinPaths(homeDir, 'AppData', 'Roaming', 'npm', 'claude.cmd'), + joinPaths(homeDir, '.local', 'bin', `claude${getExecutableExtension()}`), + joinPaths('C:\\Program Files', 'Claude', `claude${getExecutableExtension()}`), + joinPaths('C:\\Program Files (x86)', 'Claude', `claude${getExecutableExtension()}`) + ); + } else { + paths.push( + joinPaths(homeDir, '.local', 'bin', 'claude'), + joinPaths(homeDir, 'bin', 'claude') + ); + + // Add Homebrew paths on macOS + if (isMacOS()) { + const brewPath = getHomebrewPath(); + if (brewPath) { + paths.push(joinPaths(brewPath, 'claude')); + } + } + } + + return paths; +} + +/** + * Resolve Python executable path + * + * Returns command arguments as sequences so callers can pass each entry + * directly to spawn/exec or use cmd[0] for executable lookup. + * + * Returns platform-specific command variations: + * - Windows: ["py", "-3"], ["python"], ["python3"], ["py"] + * - Unix: ["python3"], ["python"] + */ +export function getPythonCommands(): string[][] { + if (isWindows()) { + return [['py', '-3'], ['python'], ['python3'], ['py']]; + } + return [['python3'], ['python']]; +} + +/** + * Expand a directory pattern like "Python3*" by scanning the parent directory + * Returns matching directory paths or empty array if none found + */ +function expandDirPattern(parentDir: string, pattern: string): string[] { + if (!existsSync(parentDir)) { + return []; + } + + try { + // Convert glob pattern to regex (only support simple * wildcard) + const regexPattern = new RegExp('^' + pattern.replace(/\*/g, '.*') + '$', 'i'); + const entries = readdirSync(parentDir, { withFileTypes: true }); + + return entries + .filter((entry) => entry.isDirectory() && regexPattern.test(entry.name)) + .map((entry) => joinPaths(parentDir, entry.name)); + } catch { + return []; + } +} + +/** + * Resolve Python installation paths + * + * Returns actual existing directory paths (expands glob patterns on Windows) + */ +export function getPythonPaths(): string[] { + const homeDir = os.homedir(); + const paths: string[] = []; + + if (isWindows()) { + // User-local Python installation + const userPythonPath = joinPaths(homeDir, 'AppData', 'Local', 'Programs', 'Python'); + if (existsSync(userPythonPath)) { + paths.push(userPythonPath); + } + + // System Python installations (expand Python3* patterns) + const programFiles = process.env.ProgramFiles || 'C:\\Program Files'; + const programFilesX86 = process.env['ProgramFiles(x86)'] || 'C:\\Program Files (x86)'; + + paths.push(...expandDirPattern(programFiles, 'Python3*')); + paths.push(...expandDirPattern(programFilesX86, 'Python3*')); + } else if (isMacOS()) { + const brewPath = getHomebrewPath(); + if (brewPath) { + paths.push(brewPath); + } + } + + return paths; +} + +/** + * Resolve Git executable path + */ +export function getGitExecutablePath(): string { + if (isWindows()) { + // Git for Windows installs to standard locations + const candidates = [ + joinPaths('C:\\Program Files', 'Git', 'bin', 'git.exe'), + joinPaths('C:\\Program Files (x86)', 'Git', 'bin', 'git.exe'), + joinPaths(os.homedir(), 'AppData', 'Local', 'Programs', 'Git', 'bin', 'git.exe') + ]; + + for (const candidate of candidates) { + if (existsSync(candidate)) { + return candidate; + } + } + } + + return 'git'; +} + +/** + * Resolve Node.js executable path + */ +export function getNodeExecutablePath(): string { + if (isWindows()) { + return 'node.exe'; + } + return 'node'; +} + +/** + * Resolve npm executable path + */ +export function getNpmExecutablePath(): string { + if (isWindows()) { + return 'npm.cmd'; + } + return 'npm'; +} + +/** + * Get all Windows shell paths for terminal selection + * + * Returns a map of shell types to their possible installation paths. + * Only applies to Windows; returns empty object for other platforms. + */ +export function getWindowsShellPaths(): Record { + if (!isWindows()) { + return {}; + } + + const systemRoot = process.env.SystemRoot || 'C:\\Windows'; + + // Note: path.join('C:', 'foo') produces 'C:foo' (relative to C: drive), not 'C:\foo' + // We must use 'C:\\' or raw paths like 'C:\\Program Files' to get absolute paths + return { + powershell: [ + path.join('C:\\Program Files', 'PowerShell', '7', 'pwsh.exe'), + path.join(systemRoot, 'System32', 'WindowsPowerShell', 'v1.0', 'powershell.exe') + ], + windowsterminal: [ + path.join('C:\\Program Files', 'WindowsApps', 'Microsoft.WindowsTerminal_*', 'WindowsTerminal.exe') + ], + cmd: [ + path.join(systemRoot, 'System32', 'cmd.exe') + ], + gitbash: [ + path.join('C:\\Program Files', 'Git', 'bin', 'bash.exe'), + path.join('C:\\Program Files (x86)', 'Git', 'bin', 'bash.exe') + ], + cygwin: [ + path.join('C:\\cygwin64', 'bin', 'bash.exe') + ], + msys2: [ + path.join('C:\\msys64', 'usr', 'bin', 'bash.exe') + ], + wsl: [ + path.join(systemRoot, 'System32', 'wsl.exe') + ] + }; +} + +/** + * Expand Windows environment variables in a path + * + * Replaces patterns like %PROGRAMFILES% with actual values. + * Only applies to Windows; returns original path for other platforms. + */ +export function expandWindowsEnvVars(pathPattern: string): string { + if (!isWindows()) { + return pathPattern; + } + + const homeDir = os.homedir(); + const envVars: Record = { + '%PROGRAMFILES%': process.env.ProgramFiles || 'C:\\Program Files', + '%PROGRAMFILES(X86)%': process.env['ProgramFiles(x86)'] || 'C:\\Program Files (x86)', + '%LOCALAPPDATA%': process.env.LOCALAPPDATA || path.join(homeDir, 'AppData', 'Local'), + '%APPDATA%': process.env.APPDATA || path.join(homeDir, 'AppData', 'Roaming'), + '%USERPROFILE%': process.env.USERPROFILE || homeDir, + '%SYSTEMROOT%': process.env.SystemRoot || 'C:\\Windows', + '%TEMP%': process.env.TEMP || process.env.TMP || path.join(homeDir, 'AppData', 'Local', 'Temp'), + '%TMP%': process.env.TMP || process.env.TEMP || path.join(homeDir, 'AppData', 'Local', 'Temp') + }; + + let expanded = pathPattern; + for (const [pattern, value] of Object.entries(envVars)) { + // Only replace if we have a valid value (skip replacement if empty) + if (value) { + expanded = expanded.replace(new RegExp(pattern, 'gi'), value); + } + } + + return expanded; +} + +/** + * Get Windows-specific installation paths for a tool + * + * @param toolName - Name of the tool (e.g., 'claude', 'python') + * @param subPath - Optional subdirectory within Program Files + */ +export function getWindowsToolPath(toolName: string, subPath?: string): string[] { + if (!isWindows()) { + return []; + } + + const homeDir = os.homedir(); + const programFiles = process.env.ProgramFiles || 'C:\\Program Files'; + const programFilesX86 = process.env['ProgramFiles(x86)'] || 'C:\\Program Files (x86)'; + const appData = process.env.LOCALAPPDATA || path.join(homeDir, 'AppData', 'Local'); + + const paths: string[] = []; + + // Program Files locations + if (subPath) { + paths.push( + path.join(programFiles, subPath), + path.join(programFilesX86, subPath) + ); + } else { + paths.push( + path.join(programFiles, toolName), + path.join(programFilesX86, toolName) + ); + } + + // AppData location + paths.push(path.join(appData, toolName)); + + // Roaming AppData (for npm) + const roamingAppData = process.env.APPDATA || path.join(homeDir, 'AppData', 'Roaming'); + paths.push(path.join(roamingAppData, 'npm')); + + return paths; +} diff --git a/apps/frontend/src/main/platform/types.ts b/apps/frontend/src/main/platform/types.ts new file mode 100644 index 0000000000..8d4c50c047 --- /dev/null +++ b/apps/frontend/src/main/platform/types.ts @@ -0,0 +1,72 @@ +/** + * Platform Abstraction Types + * + * Defines the contract for platform-specific operations. + * All platform differences should be expressed through these types. + */ + +/** + * Supported operating systems + */ +export enum OS { + Windows = 'win32', + macOS = 'darwin', + Linux = 'linux' +} + +/** + * Shell types available on each platform + */ +export enum ShellType { + PowerShell = 'powershell', + CMD = 'cmd', + Bash = 'bash', + Zsh = 'zsh', + Fish = 'fish', + Unknown = 'unknown' +} + +/** + * Platform-specific executable configuration + */ +export interface ExecutableConfig { + readonly name: string; + readonly defaultPath: string; + readonly alternativePaths: readonly string[]; + readonly extension: string; +} + +/** + * Shell configuration for spawning processes + */ +export interface ShellConfig { + readonly executable: string; + readonly args: readonly string[]; + readonly env: NodeJS.ProcessEnv; +} + +/** + * Path configuration for a platform + */ +export interface PathConfig { + readonly separator: string; + readonly delimiter: string; + readonly executableExtensions: readonly string[]; +} + +/** + * Common binary directories for each platform + */ +export interface BinaryDirectories { + readonly user: string[]; + readonly system: string[]; +} + +/** + * Tool detection result + */ +export interface ToolDetectionResult { + readonly found: boolean; + readonly path?: string; + readonly error?: string; +} diff --git a/apps/frontend/src/main/terminal/pty-manager.ts b/apps/frontend/src/main/terminal/pty-manager.ts index 2117917b0c..e90157b4be 100644 --- a/apps/frontend/src/main/terminal/pty-manager.ts +++ b/apps/frontend/src/main/terminal/pty-manager.ts @@ -7,40 +7,14 @@ import * as pty from '@lydell/node-pty'; import * as os from 'os'; import { existsSync } from 'fs'; import type { TerminalProcess, WindowGetter } from './types'; +import { isWindows, getWindowsShellPaths } from '../platform'; import { IPC_CHANNELS } from '../../shared/constants'; import { getClaudeProfileManager } from '../claude-profile-manager'; import { readSettingsFile } from '../settings-utils'; import { debugLog, debugError } from '../../shared/utils/debug-logger'; import type { SupportedTerminal } from '../../shared/types/settings'; -/** - * Windows shell paths for different terminal preferences - */ -const WINDOWS_SHELL_PATHS: Record = { - powershell: [ - 'C:\\Program Files\\PowerShell\\7\\pwsh.exe', // PowerShell 7 (Core) - 'C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe', // Windows PowerShell 5.1 - ], - windowsterminal: [ - 'C:\\Program Files\\PowerShell\\7\\pwsh.exe', // Prefer PowerShell Core in Windows Terminal - 'C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe', - ], - cmd: [ - 'C:\\Windows\\System32\\cmd.exe', - ], - gitbash: [ - 'C:\\Program Files\\Git\\bin\\bash.exe', - 'C:\\Program Files (x86)\\Git\\bin\\bash.exe', - ], - cygwin: [ - 'C:\\cygwin64\\bin\\bash.exe', - 'C:\\cygwin\\bin\\bash.exe', - ], - msys2: [ - 'C:\\msys64\\usr\\bin\\bash.exe', - 'C:\\msys32\\usr\\bin\\bash.exe', - ], -}; +// Windows shell paths are now imported from the platform module via getWindowsShellPaths() /** * Get the Windows shell executable based on preferred terminal setting @@ -51,8 +25,9 @@ function getWindowsShell(preferredTerminal: SupportedTerminal | undefined): stri return process.env.COMSPEC || 'cmd.exe'; } - // Check if we have paths defined for this terminal type - const paths = WINDOWS_SHELL_PATHS[preferredTerminal]; + // Check if we have paths defined for this terminal type (from platform module) + const windowsShellPaths = getWindowsShellPaths(); + const paths = windowsShellPaths[preferredTerminal]; if (paths) { // Find the first existing shell for (const shellPath of paths) { @@ -79,11 +54,11 @@ export function spawnPtyProcess( const settings = readSettingsFile(); const preferredTerminal = settings?.preferredTerminal as SupportedTerminal | undefined; - const shell = process.platform === 'win32' + const shell = isWindows() ? getWindowsShell(preferredTerminal) : process.env.SHELL || '/bin/zsh'; - const shellArgs = process.platform === 'win32' ? [] : ['-l']; + const shellArgs = isWindows() ? [] : ['-l']; debugLog('[PtyManager] Spawning shell:', shell, shellArgs, '(preferred:', preferredTerminal || 'system', ')'); diff --git a/tests/test_dependency_validator.py b/tests/test_dependency_validator.py index 550eb7b288..4d844fd468 100644 --- a/tests/test_dependency_validator.py +++ b/tests/test_dependency_validator.py @@ -176,7 +176,10 @@ def test_exit_message_contains_venv_path(self): message = str(call_args) # Should reference the full venv Scripts/activate path - assert "/path/to/venv" in message + # Path separators differ by platform: / on Unix, \ on Windows + # pathlib normalizes /path/to/venv to \path\to\venv on Windows + expected_path = str(Path("/path/to/venv")) + assert expected_path in message or "/path/to/venv" in message assert "Scripts" in message def test_exit_message_contains_python_executable(self): diff --git a/tests/test_platform.py b/tests/test_platform.py new file mode 100644 index 0000000000..520d5f686c --- /dev/null +++ b/tests/test_platform.py @@ -0,0 +1,347 @@ +""" +Platform Module Tests + +Tests the platform abstraction layer using mocks to simulate +different operating systems. +""" + +import os +import sys +from pathlib import Path +from unittest.mock import patch + +# Add backend to path for imports +sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..', 'apps', 'backend')) + +from core.platform import ( + get_current_os, + is_windows, + is_macos, + is_linux, + is_unix, + get_path_delimiter, + get_executable_extension, + with_executable_extension, + get_binary_directories, + get_homebrew_path, + get_claude_detection_paths, + get_python_commands, + validate_cli_path, + requires_shell, + build_windows_command, + get_env_var, + get_platform_description, + OS +) + + +# ============================================================================ +# Platform Detection Tests +# ============================================================================ + +class TestPlatformDetection: + """Tests for platform detection functions.""" + + @patch('core.platform.platform.system', return_value='Windows') + def test_detects_windows(self, mock_system): + assert get_current_os() == OS.WINDOWS + assert is_windows() is True + assert is_macos() is False + assert is_linux() is False + assert is_unix() is False + + @patch('core.platform.platform.system', return_value='Darwin') + def test_detects_macos(self, mock_system): + assert get_current_os() == OS.MACOS + assert is_windows() is False + assert is_macos() is True + assert is_linux() is False + assert is_unix() is True + + @patch('core.platform.platform.system', return_value='Linux') + def test_detects_linux(self, mock_system): + assert get_current_os() == OS.LINUX + assert is_windows() is False + assert is_macos() is False + assert is_linux() is True + assert is_unix() is True + + +# ============================================================================ +# Path Configuration Tests +# ============================================================================ + +class TestPathConfiguration: + """Tests for path-related configuration.""" + + @patch('core.platform.is_windows', return_value=True) + def test_windows_path_delimiter(self, mock_is_windows): + assert get_path_delimiter() == ';' + + @patch('core.platform.is_windows', return_value=False) + def test_unix_path_delimiter(self, mock_is_windows): + assert get_path_delimiter() == ':' + + @patch('core.platform.is_windows', return_value=True) + def test_windows_executable_extension(self, mock_is_windows): + assert get_executable_extension() == '.exe' + + @patch('core.platform.is_windows', return_value=False) + def test_unix_executable_extension(self, mock_is_windows): + assert get_executable_extension() == '' + + +class TestWithExecutableExtension: + """Tests for adding executable extensions.""" + + @patch('core.platform.is_windows', return_value=True) + def test_adds_extension_on_windows(self, mock_is_windows): + assert with_executable_extension('claude') == 'claude.exe' + assert with_executable_extension('node') == 'node.exe' + + @patch('core.platform.is_windows', return_value=True) + def test_preserves_existing_extension(self, mock_is_windows): + assert with_executable_extension('claude.exe') == 'claude.exe' + assert with_executable_extension('npm.cmd') == 'npm.cmd' + + @patch('core.platform.is_windows', return_value=False) + def test_no_extension_on_unix(self, mock_is_windows): + assert with_executable_extension('claude') == 'claude' + assert with_executable_extension('node') == 'node' + + +# ============================================================================ +# Binary Directories Tests +# ============================================================================ + +class TestBinaryDirectories: + """Tests for binary directory detection.""" + + @patch('core.platform.is_windows', return_value=True) + @patch('pathlib.Path.home', return_value=Path('/home/user')) + @patch.dict(os.environ, {'ProgramFiles': 'C:\\Program Files'}) + def test_windows_binary_directories(self, mock_home, mock_is_windows): + dirs = get_binary_directories() + + assert 'user' in dirs + assert 'system' in dirs + assert any('AppData' in d for d in dirs['user']) + assert any('Program Files' in d for d in dirs['system']) + + @patch('core.platform.is_windows', return_value=False) + @patch('core.platform.is_macos', return_value=True) + def test_macos_binary_directories(self, mock_is_macos, mock_is_windows): + dirs = get_binary_directories() + + assert '/opt/homebrew/bin' in dirs['system'] + assert '/usr/local/bin' in dirs['system'] + + @patch('core.platform.is_windows', return_value=False) + @patch('core.platform.is_macos', return_value=False) + def test_linux_binary_directories(self, mock_is_macos, mock_is_windows): + dirs = get_binary_directories() + + assert '/usr/bin' in dirs['system'] + assert '/snap/bin' in dirs['system'] + + +# ============================================================================ +# Homebrew Path Tests +# ============================================================================ + +class TestHomebrewPath: + """Tests for Homebrew path detection.""" + + @patch('core.platform.is_macos', return_value=False) + def test_returns_null_on_non_macos(self, mock_is_macos): + assert get_homebrew_path() is None + + @patch('core.platform.is_macos', return_value=True) + @patch('os.path.exists', return_value=False) + def test_returns_default_on_macos(self, mock_exists, mock_is_macos): + # Should return default Apple Silicon path + result = get_homebrew_path() + assert result in ['/opt/homebrew/bin', '/usr/local/bin'] + + +# ============================================================================ +# Tool Detection Tests +# ============================================================================ + +class TestClaudeDetectionPaths: + """Tests for Claude CLI path detection.""" + + @patch('core.platform.is_macos', return_value=False) + @patch('core.platform.is_windows', return_value=True) + @patch('pathlib.Path.home', return_value=Path('/home/user')) + def test_windows_claude_paths(self, mock_home, mock_is_windows, mock_is_macos): + paths = get_claude_detection_paths() + + assert any('AppData' in p for p in paths) + assert any('Program Files' in p for p in paths) + assert any(p.endswith('.exe') for p in paths) + + @patch('core.platform.is_macos', return_value=False) + @patch('core.platform.is_windows', return_value=False) + @patch('pathlib.Path.home', return_value=Path('/home/user')) + def test_unix_claude_paths(self, mock_home, mock_is_windows, mock_is_macos): + paths = get_claude_detection_paths() + + assert any('.local' in p for p in paths) + assert not any(p.endswith('.exe') for p in paths) + + +class TestPythonCommands: + """Tests for Python command variations.""" + + @patch('core.platform.is_windows', return_value=True) + def test_windows_python_commands(self, mock_is_windows): + commands = get_python_commands() + # Commands are now returned as argument sequences + assert ["py", "-3"] in commands + assert ["python"] in commands + + @patch('core.platform.is_windows', return_value=False) + def test_unix_python_commands(self, mock_is_windows): + commands = get_python_commands() + # Commands are now returned as argument sequences + assert commands[0] == ["python3"] + + +# ============================================================================ +# Path Validation Tests +# ============================================================================ + +class TestPathValidation: + """Tests for CLI path validation.""" + + def test_rejects_path_traversal(self): + assert validate_cli_path('../etc/passwd') is False + assert validate_cli_path('..\\Windows\\System32') is False + + def test_rejects_empty_path(self): + assert validate_cli_path('') is False + assert validate_cli_path(None) is False + + def test_rejects_shell_metacharacters(self): + """Shell metacharacters should be rejected to prevent command injection.""" + assert validate_cli_path('cmd;rm -rf /') is False + assert validate_cli_path('cmd|cat /etc/passwd') is False + assert validate_cli_path('cmd&background') is False + assert validate_cli_path('cmd`whoami`') is False + assert validate_cli_path('cmd$(whoami)') is False + assert validate_cli_path('cmd{test}') is False + assert validate_cli_path('cmdoutput') is False + + def test_rejects_windows_env_expansion(self): + """Windows environment variable expansion should be rejected.""" + assert validate_cli_path('%PROGRAMFILES%\\cmd.exe') is False + assert validate_cli_path('%SystemRoot%\\System32\\cmd.exe') is False + + def test_rejects_newline_injection(self): + """Newlines in paths should be rejected to prevent command injection.""" + assert validate_cli_path('cmd\n/bin/sh') is False + assert validate_cli_path('cmd\r\n/bin/sh') is False + + @patch('core.platform.is_windows', return_value=True) + def test_validates_windows_names(self, mock_is_windows): + assert validate_cli_path('claude.exe') is True + assert validate_cli_path('my-script.cmd') is True + assert validate_cli_path('dangerous;command.exe') is False + + @patch('core.platform.os.path.isfile', return_value=True) + @patch('core.platform.is_windows', return_value=False) + def test_allows_unix_paths(self, mock_is_windows, mock_isfile): + assert validate_cli_path('/usr/bin/node') is True + assert validate_cli_path('/opt/homebrew/bin/python3') is True + + +# ============================================================================ +# Shell Execution Tests +# ============================================================================ + +class TestShellExecution: + """Tests for shell execution requirements.""" + + @patch('core.platform.is_windows', return_value=True) + def test_requires_shell_for_cmd_files(self, mock_is_windows): + assert requires_shell('npm.cmd') is True + assert requires_shell('script.bat') is True + assert requires_shell('node.exe') is False + + @patch('core.platform.is_windows', return_value=False) + def test_never_requires_shell_on_unix(self, mock_is_windows): + assert requires_shell('npm') is False + assert requires_shell('node') is False + + +class TestWindowsCommandBuilder: + """Tests for Windows command array building.""" + + @patch('core.platform.is_windows', return_value=True) + @patch.dict(os.environ, {'SystemRoot': 'C:\\Windows', 'ComSpec': 'C:\\Windows\\System32\\cmd.exe'}) + def test_wraps_cmd_files_in_cmd_exe(self, mock_is_windows): + result = build_windows_command('npm.cmd', ['install', 'package']) + + assert result[0].endswith('cmd.exe') + assert '/d' in result + assert '/s' in result + assert '/c' in result + assert any('npm.cmd' in arg for arg in result) + + @patch('core.platform.is_windows', return_value=True) + def test_passes_exe_directly(self, mock_is_windows): + result = build_windows_command('node.exe', ['script.js']) + + assert result[0] == 'node.exe' + assert result[1] == 'script.js' + + @patch('core.platform.is_windows', return_value=False) + def test_unix_command_simple(self, mock_is_windows): + result = build_windows_command('/usr/bin/node', ['script.js']) + + assert result == ['/usr/bin/node', 'script.js'] + + +# ============================================================================ +# Environment Variable Tests +# ============================================================================ + +class TestEnvironmentVariables: + """Tests for environment variable access.""" + + @patch.dict(os.environ, {'TEST_VAR': 'value'}) + @patch('core.platform.is_windows', return_value=False) + def test_gets_env_var_on_unix(self, mock_is_windows): + assert get_env_var('TEST_VAR') == 'value' + assert get_env_var('NONEXISTENT', 'default') == 'default' + + @patch('core.platform.is_windows', return_value=True) + @patch.dict(os.environ, {'TEST_VAR': 'value', 'test_var': 'other'}) + def test_case_insensitive_on_windows(self, mock_is_windows): + # Windows should be case-insensitive + result = get_env_var('TEST_VAR') + assert result in ['value', 'other'] + + +# ============================================================================ +# Platform Description Tests +# ============================================================================ + +class TestPlatformDescription: + """Tests for platform description.""" + + @patch('platform.system', return_value='Windows') + @patch('platform.machine', return_value='AMD64') + def test_windows_description(self, mock_machine, mock_system): + desc = get_platform_description() + assert 'Windows' in desc + assert 'AMD64' in desc + + @patch('core.platform.platform.system', return_value='Darwin') + @patch('platform.machine', return_value='arm64') + def test_macos_description(self, mock_machine, mock_system): + desc = get_platform_description() + assert 'macOS' in desc + assert 'arm64' in desc