diff --git a/apps/backend/agents/utils.py b/apps/backend/agents/utils.py index cc56cde2..614cdb79 100644 --- a/apps/backend/agents/utils.py +++ b/apps/backend/agents/utils.py @@ -8,42 +8,38 @@ Helper functions for git operations, plan management, and file syncing. import json import logging import shutil -import subprocess from pathlib import Path +from core.git_executable import run_git + logger = logging.getLogger(__name__) def get_latest_commit(project_dir: Path) -> str | None: """Get the hash of the latest git commit.""" - try: - result = subprocess.run( - ["git", "rev-parse", "HEAD"], - cwd=project_dir, - capture_output=True, - text=True, - check=True, - timeout=10, - ) + result = run_git( + ["rev-parse", "HEAD"], + cwd=project_dir, + timeout=10, + ) + if result.returncode == 0: return result.stdout.strip() - except (subprocess.CalledProcessError, subprocess.TimeoutExpired): - return None + return None def get_commit_count(project_dir: Path) -> int: """Get the total number of commits.""" - try: - result = subprocess.run( - ["git", "rev-list", "--count", "HEAD"], - cwd=project_dir, - capture_output=True, - text=True, - check=True, - timeout=10, - ) - return int(result.stdout.strip()) - except (subprocess.CalledProcessError, subprocess.TimeoutExpired, ValueError): - return 0 + result = run_git( + ["rev-list", "--count", "HEAD"], + cwd=project_dir, + timeout=10, + ) + if result.returncode == 0: + try: + return int(result.stdout.strip()) + except ValueError: + return 0 + return 0 def load_implementation_plan(spec_dir: Path) -> dict | None: diff --git a/apps/backend/core/git_executable.py b/apps/backend/core/git_executable.py new file mode 100644 index 00000000..d17a3e07 --- /dev/null +++ b/apps/backend/core/git_executable.py @@ -0,0 +1,142 @@ +#!/usr/bin/env python3 +""" +Git Executable Finder +====================== + +Utility to find the git executable, with Windows-specific fallbacks. +Separated into its own module to avoid circular imports. +""" + +import os +import shutil +import subprocess +from pathlib import Path + +_cached_git_path: str | None = None + + +def get_git_executable() -> str: + """Find the git executable, with Windows-specific fallbacks. + + Returns the path to git executable. On Windows, checks multiple sources: + 1. CLAUDE_CODE_GIT_BASH_PATH env var (set by Electron frontend) + 2. shutil.which (if git is in PATH) + 3. Common installation locations + 4. Windows 'where' command + + Caches the result after first successful find. + """ + global _cached_git_path + + # Return cached result if available + if _cached_git_path is not None: + return _cached_git_path + + git_path = _find_git_executable() + _cached_git_path = git_path + return git_path + + +def _find_git_executable() -> str: + """Internal function to find git executable.""" + # 1. Check CLAUDE_CODE_GIT_BASH_PATH (set by Electron frontend) + # This env var points to bash.exe, we can derive git.exe from it + bash_path = os.environ.get("CLAUDE_CODE_GIT_BASH_PATH") + if bash_path: + try: + bash_path_obj = Path(bash_path) + if bash_path_obj.exists(): + git_dir = bash_path_obj.parent.parent + # Try cmd/git.exe first (preferred), then bin/git.exe + for git_subpath in ["cmd/git.exe", "bin/git.exe"]: + git_path = git_dir / git_subpath + if git_path.is_file(): + return str(git_path) + except (OSError, ValueError): + pass + + # 2. Try shutil.which (works if git is in PATH) + git_path = shutil.which("git") + if git_path: + return git_path + + # 3. Windows-specific: check common installation locations + if os.name == "nt": + common_paths = [ + os.path.expandvars(r"%PROGRAMFILES%\Git\cmd\git.exe"), + os.path.expandvars(r"%PROGRAMFILES%\Git\bin\git.exe"), + os.path.expandvars(r"%PROGRAMFILES(X86)%\Git\cmd\git.exe"), + os.path.expandvars(r"%LOCALAPPDATA%\Programs\Git\cmd\git.exe"), + r"C:\Program Files\Git\cmd\git.exe", + r"C:\Program Files (x86)\Git\cmd\git.exe", + ] + for path in common_paths: + try: + if os.path.isfile(path): + return path + except OSError: + continue + + # 4. Try 'where' command with shell=True (more reliable on Windows) + try: + result = subprocess.run( + "where git", + capture_output=True, + text=True, + timeout=5, + shell=True, + ) + if result.returncode == 0 and result.stdout.strip(): + found_path = result.stdout.strip().split("\n")[0].strip() + if found_path and os.path.isfile(found_path): + return found_path + except (subprocess.TimeoutExpired, OSError): + pass + + # Default fallback - let subprocess handle it (may fail) + return "git" + + +def run_git( + args: list[str], + cwd: Path | str | None = None, + timeout: int = 60, + input_data: str | None = None, +) -> subprocess.CompletedProcess: + """Run a git command with proper executable finding. + + Args: + args: Git command arguments (without 'git' prefix) + cwd: Working directory for the command + timeout: Command timeout in seconds (default: 60) + input_data: Optional string data to pass to stdin + + Returns: + CompletedProcess with command results. + """ + git = get_git_executable() + try: + return subprocess.run( + [git] + args, + cwd=cwd, + input=input_data, + capture_output=True, + text=True, + encoding="utf-8", + errors="replace", + timeout=timeout, + ) + except subprocess.TimeoutExpired: + return subprocess.CompletedProcess( + args=[git] + args, + returncode=-1, + stdout="", + stderr=f"Command timed out after {timeout} seconds", + ) + except FileNotFoundError: + return subprocess.CompletedProcess( + args=[git] + args, + returncode=-1, + stdout="", + stderr="Git executable not found. Please ensure git is installed and in PATH.", + ) diff --git a/apps/backend/core/workspace/git_utils.py b/apps/backend/core/workspace/git_utils.py index 18084c29..5f6093b2 100644 --- a/apps/backend/core/workspace/git_utils.py +++ b/apps/backend/core/workspace/git_utils.py @@ -10,6 +10,45 @@ import json import subprocess from pathlib import Path +from core.git_executable import get_git_executable, run_git + +__all__ = [ + # Exported helpers + "get_git_executable", + "run_git", + # Constants + "MAX_FILE_LINES_FOR_AI", + "MAX_PARALLEL_AI_MERGES", + "LOCK_FILES", + "BINARY_EXTENSIONS", + "MERGE_LOCK_TIMEOUT", + "MAX_SYNTAX_FIX_RETRIES", + # Functions + "detect_file_renames", + "apply_path_mapping", + "get_merge_base", + "has_uncommitted_changes", + "get_current_branch", + "get_existing_build_worktree", + "get_file_content_from_ref", + "get_binary_file_content_from_ref", + "get_changed_files_from_branch", + "is_process_running", + "is_binary_file", + "is_lock_file", + "validate_merged_syntax", + "create_conflict_file_with_git", + # Backward compat aliases + "_is_process_running", + "_is_binary_file", + "_is_lock_file", + "_validate_merged_syntax", + "_get_file_content_from_ref", + "_get_binary_file_content_from_ref", + "_get_changed_files_from_branch", + "_create_conflict_file_with_git", +] + # Constants for merge limits MAX_FILE_LINES_FOR_AI = 5000 # Skip AI for files larger than this MAX_PARALLEL_AI_MERGES = 5 # Limit concurrent AI merge operations @@ -150,9 +189,8 @@ def detect_file_renames( # -M flag enables rename detection # --diff-filter=R shows only renames # --name-status shows status and file names - result = subprocess.run( + result = run_git( [ - "git", "log", "--name-status", "-M", @@ -161,8 +199,6 @@ def detect_file_renames( f"{from_ref}..{to_ref}", ], cwd=project_dir, - capture_output=True, - text=True, ) if result.returncode == 0: @@ -212,39 +248,21 @@ def get_merge_base(project_dir: Path, ref1: str, ref2: str) -> str | None: Returns: Merge-base commit hash, or None if not found """ - try: - result = subprocess.run( - ["git", "merge-base", ref1, ref2], - cwd=project_dir, - capture_output=True, - text=True, - ) - if result.returncode == 0: - return result.stdout.strip() - except Exception: - pass + result = run_git(["merge-base", ref1, ref2], cwd=project_dir) + if result.returncode == 0: + return result.stdout.strip() return None def has_uncommitted_changes(project_dir: Path) -> bool: """Check if user has unsaved work.""" - result = subprocess.run( - ["git", "status", "--porcelain"], - cwd=project_dir, - capture_output=True, - text=True, - ) + result = run_git(["status", "--porcelain"], cwd=project_dir) return bool(result.stdout.strip()) def get_current_branch(project_dir: Path) -> str: """Get the current branch name.""" - result = subprocess.run( - ["git", "rev-parse", "--abbrev-ref", "HEAD"], - cwd=project_dir, - capture_output=True, - text=True, - ) + result = run_git(["rev-parse", "--abbrev-ref", "HEAD"], cwd=project_dir) return result.stdout.strip() @@ -276,12 +294,7 @@ def get_file_content_from_ref( project_dir: Path, ref: str, file_path: str ) -> str | None: """Get file content from a git ref (branch, commit, etc.).""" - result = subprocess.run( - ["git", "show", f"{ref}:{file_path}"], - cwd=project_dir, - capture_output=True, - text=True, - ) + result = run_git(["show", f"{ref}:{file_path}"], cwd=project_dir) if result.returncode == 0: return result.stdout return None @@ -294,9 +307,13 @@ def get_binary_file_content_from_ref( Unlike get_file_content_from_ref, this returns raw bytes without text decoding, suitable for binary files like images, audio, etc. + + Note: Uses subprocess directly with get_git_executable() since + run_git() always returns text output. """ + git = get_git_executable() result = subprocess.run( - ["git", "show", f"{ref}:{file_path}"], + [git, "show", f"{ref}:{file_path}"], cwd=project_dir, capture_output=True, text=False, # Return bytes, not text @@ -324,11 +341,9 @@ def get_changed_files_from_branch( Returns: List of (file_path, status) tuples """ - result = subprocess.run( - ["git", "diff", "--name-status", f"{base_branch}...{spec_branch}"], + result = run_git( + ["diff", "--name-status", f"{base_branch}...{spec_branch}"], cwd=project_dir, - capture_output=True, - text=True, ) files = [] @@ -345,15 +360,23 @@ def get_changed_files_from_branch( return files +def _normalize_path(path: str) -> str: + """Normalize path separators to forward slashes for cross-platform comparison.""" + return path.replace("\\", "/") + + def _is_auto_claude_file(file_path: str) -> bool: - """Check if a file is in the .auto-claude or auto-claude/specs directory.""" - # These patterns cover the internal spec/build files that shouldn't be merged + """Check if a file is in the .auto-claude or auto-claude/specs directory. + + Handles both forward slashes (Unix/Git output) and backslashes (Windows). + """ + normalized = _normalize_path(file_path) excluded_patterns = [ ".auto-claude/", "auto-claude/specs/", ] for pattern in excluded_patterns: - if file_path.startswith(pattern): + if normalized.startswith(pattern): return True return False @@ -547,11 +570,9 @@ def create_conflict_file_with_git( try: # git merge-file # Exit codes: 0 = clean merge, 1 = conflicts, >1 = error - result = subprocess.run( - ["git", "merge-file", "-p", main_path, base_path, wt_path], + result = run_git( + ["merge-file", "-p", main_path, base_path, wt_path], cwd=project_dir, - capture_output=True, - text=True, ) # Read the merged content diff --git a/apps/backend/core/workspace/setup.py b/apps/backend/core/workspace/setup.py index 6ae33f43..06269e7c 100644 --- a/apps/backend/core/workspace/setup.py +++ b/apps/backend/core/workspace/setup.py @@ -8,10 +8,10 @@ Functions for setting up and initializing workspaces. import json import shutil -import subprocess import sys from pathlib import Path +from core.git_executable import run_git from merge import FileTimelineTracker from security.constants import ALLOWLIST_FILENAME, PROFILE_FILENAME from ui import ( @@ -406,11 +406,9 @@ def initialize_timeline_tracking( files_to_modify.extend(subtask.get("files", [])) # Get the current branch point commit - result = subprocess.run( - ["git", "rev-parse", "HEAD"], + result = run_git( + ["rev-parse", "HEAD"], cwd=project_dir, - capture_output=True, - text=True, ) branch_point = result.stdout.strip() if result.returncode == 0 else None diff --git a/apps/backend/core/worktree.py b/apps/backend/core/worktree.py index eb23a6db..a8e19498 100644 --- a/apps/backend/core/worktree.py +++ b/apps/backend/core/worktree.py @@ -23,6 +23,8 @@ from dataclasses import dataclass from datetime import datetime from pathlib import Path +from core.git_executable import run_git + class WorktreeError(Exception): """Error during worktree operations.""" @@ -77,13 +79,9 @@ class WorktreeManager: env_branch = os.getenv("DEFAULT_BRANCH") if env_branch: # Verify the branch exists - result = subprocess.run( - ["git", "rev-parse", "--verify", env_branch], + result = run_git( + ["rev-parse", "--verify", env_branch], cwd=self.project_dir, - capture_output=True, - text=True, - encoding="utf-8", - errors="replace", ) if result.returncode == 0: return env_branch @@ -94,13 +92,9 @@ class WorktreeManager: # 2. Auto-detect main/master for branch in ["main", "master"]: - result = subprocess.run( - ["git", "rev-parse", "--verify", branch], + result = run_git( + ["rev-parse", "--verify", branch], cwd=self.project_dir, - capture_output=True, - text=True, - encoding="utf-8", - errors="replace", ) if result.returncode == 0: return branch @@ -114,13 +108,9 @@ class WorktreeManager: def _get_current_branch(self) -> str: """Get the current git branch.""" - result = subprocess.run( - ["git", "rev-parse", "--abbrev-ref", "HEAD"], + result = run_git( + ["rev-parse", "--abbrev-ref", "HEAD"], cwd=self.project_dir, - capture_output=True, - text=True, - encoding="utf-8", - errors="replace", ) if result.returncode != 0: raise WorktreeError(f"Failed to get current branch: {result.stderr}") @@ -140,24 +130,7 @@ class WorktreeManager: CompletedProcess with command results. On timeout, returns a CompletedProcess with returncode=-1 and timeout error in stderr. """ - try: - return subprocess.run( - ["git"] + args, - cwd=cwd or self.project_dir, - capture_output=True, - text=True, - encoding="utf-8", - errors="replace", - timeout=timeout, - ) - except subprocess.TimeoutExpired: - # Return a failed result on timeout instead of raising - return subprocess.CompletedProcess( - args=["git"] + args, - returncode=-1, - stdout="", - stderr=f"Command timed out after {timeout} seconds", - ) + return run_git(args, cwd=cwd or self.project_dir, timeout=timeout) def _unstage_gitignored_files(self) -> None: """ @@ -180,14 +153,10 @@ class WorktreeManager: # 1. Check which staged files are gitignored # git check-ignore returns the files that ARE ignored - result = subprocess.run( - ["git", "check-ignore", "--stdin"], + result = run_git( + ["check-ignore", "--stdin"], cwd=self.project_dir, - input="\n".join(staged_files), - capture_output=True, - text=True, - encoding="utf-8", - errors="replace", + input_data="\n".join(staged_files), ) if result.stdout.strip(): @@ -202,8 +171,10 @@ class WorktreeManager: file = file.strip() if not file: continue + # Normalize path separators for cross-platform (Windows backslash support) + normalized = file.replace("\\", "/") for pattern in auto_claude_patterns: - if file.startswith(pattern) or f"/{pattern}" in file: + if normalized.startswith(pattern) or f"/{pattern}" in normalized: files_to_unstage.add(file) break diff --git a/apps/backend/runners/spec_runner.py b/apps/backend/runners/spec_runner.py index 4c5a4bba..30adbf3f 100644 --- a/apps/backend/runners/spec_runner.py +++ b/apps/backend/runners/spec_runner.py @@ -200,9 +200,21 @@ Examples: default=None, help="Base branch for creating worktrees (default: auto-detect or current branch)", ) + parser.add_argument( + "--direct", + action="store_true", + help="Build directly in project without worktree isolation (default: use isolated worktree)", + ) args = parser.parse_args() + # Warn user about direct mode risks + if args.direct: + print_status( + "Direct mode: Building in project directory without worktree isolation", + "warning", + ) + # Handle task from file if provided task_description = args.task if args.task_file: @@ -330,6 +342,10 @@ Examples: if args.base_branch: run_cmd.extend(["--base-branch", args.base_branch]) + # Pass --direct flag if specified (skip worktree isolation) + if args.direct: + run_cmd.append("--direct") + # Note: Model configuration for subsequent phases (planning, coding, qa) # is read from task_metadata.json by run.py, so we don't pass it here. # This allows per-phase configuration when using Auto profile. diff --git a/apps/backend/security/parser.py b/apps/backend/security/parser.py index 1b8ead06..1c519998 100644 --- a/apps/backend/security/parser.py +++ b/apps/backend/security/parser.py @@ -4,11 +4,137 @@ Command Parsing Utilities Functions for parsing and extracting commands from shell command strings. Handles compound commands, pipes, subshells, and various shell constructs. + +Windows Compatibility Note: +-------------------------- +On Windows, commands containing paths with backslashes can cause shlex.split() +to fail (e.g., incomplete commands with unclosed quotes). This module includes +a fallback parser that extracts command names even from malformed commands, +ensuring security validation can still proceed. """ -import os import re import shlex +from pathlib import PurePosixPath, PureWindowsPath + + +def _cross_platform_basename(path: str) -> str: + """ + Extract the basename from a path in a cross-platform way. + + Handles both Windows paths (C:\\dir\\cmd.exe) and POSIX paths (/dir/cmd) + regardless of the current platform. This is critical for running tests + on Linux CI while handling Windows-style paths. + + Args: + path: A file path string (Windows or POSIX format) + + Returns: + The basename of the path (e.g., "python.exe" from "C:\\Python312\\python.exe") + """ + # Strip surrounding quotes if present + path = path.strip("'\"") + + # Check if this looks like a Windows path (contains backslash or drive letter) + if "\\" in path or (len(path) >= 2 and path[1] == ":"): + # Use PureWindowsPath to handle Windows paths on any platform + return PureWindowsPath(path).name + + # For POSIX paths or simple command names, use PurePosixPath + # (os.path.basename works but PurePosixPath is more explicit) + return PurePosixPath(path).name + + +def _fallback_extract_commands(command_string: str) -> list[str]: + """ + Fallback command extraction when shlex.split() fails. + + Uses regex to extract command names from potentially malformed commands. + This is more permissive than shlex but ensures we can at least identify + the commands being executed for security validation. + + Args: + command_string: The command string to parse + + Returns: + List of command names extracted from the string + """ + commands = [] + + # Shell keywords to skip + shell_keywords = { + "if", + "then", + "else", + "elif", + "fi", + "for", + "while", + "until", + "do", + "done", + "case", + "esac", + "in", + "function", + } + + # First, split by common shell operators + # This regex splits on &&, ||, |, ; while being careful about quotes + # We're being permissive here since shlex already failed + parts = re.split(r"\s*(?:&&|\|\||\|)\s*|;\s*", command_string) + + for part in parts: + part = part.strip() + if not part: + continue + + # Skip variable assignments at the start (VAR=value cmd) + while re.match(r"^[A-Za-z_][A-Za-z0-9_]*=\S*\s+", part): + part = re.sub(r"^[A-Za-z_][A-Za-z0-9_]*=\S*\s+", "", part) + + if not part: + continue + + # Strategy: Extract command from the BEGINNING of the part + # Handle various formats: + # - Simple: python3, npm, git + # - Unix path: /usr/bin/python + # - Windows path: C:\Python312\python.exe + # - Quoted with spaces: "C:\Program Files\python.exe" + + # Extract first token, handling quoted strings with spaces + first_token_match = re.match(r'^(?:"([^"]+)"|\'([^\']+)\'|([^\s]+))', part) + if not first_token_match: + continue + + # Pick whichever capture group matched (double-quoted, single-quoted, or unquoted) + first_token = ( + first_token_match.group(1) + or first_token_match.group(2) + or first_token_match.group(3) + ) + + # Now extract just the command name from this token + # Handle Windows paths (C:\dir\cmd.exe) and Unix paths (/dir/cmd) + # Use cross-platform basename for reliable path handling on any OS + cmd = _cross_platform_basename(first_token) + + # Remove Windows extensions + cmd = re.sub(r"\.(exe|cmd|bat|ps1|sh)$", "", cmd, flags=re.IGNORECASE) + + # Clean up any remaining quotes or special chars at the start + cmd = re.sub(r'^["\'\\/]+', "", cmd) + + # Skip tokens that look like function calls or code fragments (not shell commands) + # These appear when splitting on semicolons inside malformed quoted strings + if "(" in cmd or ")" in cmd or "." in cmd: + continue + + if cmd and cmd.lower() not in shell_keywords: + commands.append(cmd) + + return commands def split_command_segments(command_string: str) -> list[str]: @@ -32,13 +158,46 @@ def split_command_segments(command_string: str) -> list[str]: return result +def _contains_windows_path(command_string: str) -> bool: + """ + Check if a command string contains Windows-style paths. + + Windows paths with backslashes cause issues with shlex.split() because + backslashes are interpreted as escape characters in POSIX mode. + + Args: + command_string: The command string to check + + Returns: + True if Windows paths are detected + """ + # Pattern matches: + # - Drive letter paths: C:\, D:\, etc. + # - Backslash followed by a path component (2+ chars to avoid escape sequences like \n, \t) + # The second char must be alphanumeric, underscore, or another path separator + # This avoids false positives on escape sequences which are single-char after backslash + return bool(re.search(r"[A-Za-z]:\\|\\[A-Za-z][A-Za-z0-9_\\/]", command_string)) + + def extract_commands(command_string: str) -> list[str]: """ Extract command names from a shell command string. Handles pipes, command chaining (&&, ||, ;), and subshells. Returns the base command names (without paths). + + On Windows or when commands contain malformed quoting (common with + Windows paths in bash-style commands), falls back to regex-based + extraction to ensure security validation can proceed. """ + # If command contains Windows paths, use fallback parser directly + # because shlex.split() interprets backslashes as escape characters + if _contains_windows_path(command_string): + fallback_commands = _fallback_extract_commands(command_string) + if fallback_commands: + return fallback_commands + # Continue with shlex if fallback found nothing + commands = [] # Split on semicolons that aren't inside quotes @@ -53,7 +212,12 @@ def extract_commands(command_string: str) -> list[str]: tokens = shlex.split(segment) except ValueError: # Malformed command (unclosed quotes, etc.) - # Return empty to trigger block (fail-safe) + # This is common on Windows with backslash paths in quoted strings + # Use fallback parser instead of blocking + fallback_commands = _fallback_extract_commands(command_string) + if fallback_commands: + return fallback_commands + # If fallback also found nothing, return empty to trigger block return [] if not tokens: @@ -106,7 +270,8 @@ def extract_commands(command_string: str) -> list[str]: if expect_command: # Extract the base command name (handle paths like /usr/bin/python) - cmd = os.path.basename(token) + # Use cross-platform basename for Windows paths on Linux CI + cmd = _cross_platform_basename(token) commands.append(cmd) expect_command = False diff --git a/apps/frontend/src/main/agent/agent-manager.ts b/apps/frontend/src/main/agent/agent-manager.ts index 0f387d18..962259e3 100644 --- a/apps/frontend/src/main/agent/agent-manager.ts +++ b/apps/frontend/src/main/agent/agent-manager.ts @@ -152,6 +152,11 @@ export class AgentManager extends EventEmitter { } } + // Workspace mode: --direct skips worktree isolation (default is isolated for safety) + if (metadata?.useWorktree === false) { + args.push('--direct'); + } + // Store context for potential restart this.storeTaskContext(taskId, projectPath, '', {}, true, taskDescription, specDir, metadata, baseBranch); @@ -200,6 +205,11 @@ export class AgentManager extends EventEmitter { // Force: When user starts a task from the UI, that IS their approval args.push('--force'); + // Workspace mode: --direct skips worktree isolation (default is isolated for safety) + if (options.useWorktree === false) { + args.push('--direct'); + } + // Pass base branch if specified (ensures worktrees are created from the correct branch) if (options.baseBranch) { args.push('--base-branch', options.baseBranch); diff --git a/apps/frontend/src/main/agent/types.ts b/apps/frontend/src/main/agent/types.ts index fa3c5b8d..8d9eb9fc 100644 --- a/apps/frontend/src/main/agent/types.ts +++ b/apps/frontend/src/main/agent/types.ts @@ -44,6 +44,7 @@ export interface TaskExecutionOptions { parallel?: boolean; workers?: number; baseBranch?: string; + useWorktree?: boolean; // If false, use --direct mode (no worktree isolation) } export interface SpecCreationMetadata { @@ -65,6 +66,8 @@ export interface SpecCreationMetadata { // Non-auto profile - single model and thinking level model?: 'haiku' | 'sonnet' | 'opus'; thinkingLevel?: 'none' | 'low' | 'medium' | 'high' | 'ultrathink'; + // Workspace mode - whether to use worktree isolation + useWorktree?: boolean; // If false, use --direct mode (no worktree isolation) } export interface IdeationProgressData { diff --git a/apps/frontend/src/main/ipc-handlers/task/execution-handlers.ts b/apps/frontend/src/main/ipc-handlers/task/execution-handlers.ts index f9ac58fd..1626190f 100644 --- a/apps/frontend/src/main/ipc-handlers/task/execution-handlers.ts +++ b/apps/frontend/src/main/ipc-handlers/task/execution-handlers.ts @@ -193,7 +193,8 @@ export function registerTaskExecutionHandlers( { parallel: false, // Sequential for planning phase workers: 1, - baseBranch + baseBranch, + useWorktree: task.metadata?.useWorktree } ); } else { @@ -208,7 +209,8 @@ export function registerTaskExecutionHandlers( { parallel: false, workers: 1, - baseBranch + baseBranch, + useWorktree: task.metadata?.useWorktree } ); } @@ -588,7 +590,8 @@ export function registerTaskExecutionHandlers( { parallel: false, workers: 1, - baseBranch: baseBranchForUpdate + baseBranch: baseBranchForUpdate, + useWorktree: task.metadata?.useWorktree } ); } else { @@ -602,7 +605,8 @@ export function registerTaskExecutionHandlers( { parallel: false, workers: 1, - baseBranch: baseBranchForUpdate + baseBranch: baseBranchForUpdate, + useWorktree: task.metadata?.useWorktree } ); } @@ -939,7 +943,8 @@ export function registerTaskExecutionHandlers( { parallel: false, workers: 1, - baseBranch: baseBranchForRecovery + baseBranch: baseBranchForRecovery, + useWorktree: task.metadata?.useWorktree } ); } diff --git a/apps/frontend/src/renderer/components/TaskCreationWizard.tsx b/apps/frontend/src/renderer/components/TaskCreationWizard.tsx index 4bbed28a..7f67f3e2 100644 --- a/apps/frontend/src/renderer/components/TaskCreationWizard.tsx +++ b/apps/frontend/src/renderer/components/TaskCreationWizard.tsx @@ -82,6 +82,8 @@ export function TaskCreationWizard({ const [isLoadingBranches, setIsLoadingBranches] = useState(false); const [baseBranch, setBaseBranch] = useState(PROJECT_DEFAULT_BRANCH); const [projectDefaultBranch, setProjectDefaultBranch] = useState(''); + // Worktree isolation - default to true for safety + const [useWorktree, setUseWorktree] = useState(true); // Get project path from project store const projects = useProjectStore((state) => state.projects); @@ -636,6 +638,8 @@ export function TaskCreationWizard({ if (requireReviewBeforeCoding) metadata.requireReviewBeforeCoding = true; // Only include baseBranch if it's not the project default placeholder if (baseBranch && baseBranch !== PROJECT_DEFAULT_BRANCH) metadata.baseBranch = baseBranch; + // Pass worktree preference - false means use --direct mode + if (!useWorktree) metadata.useWorktree = false; // Title is optional - if empty, it will be auto-generated by the backend const task = await createTask(projectId, title.trim(), description.trim(), metadata); @@ -672,6 +676,7 @@ export function TaskCreationWizard({ setReferencedFiles([]); setRequireReviewBeforeCoding(false); setBaseBranch(PROJECT_DEFAULT_BRANCH); + setUseWorktree(true); setError(null); setShowAdvanced(false); setShowFileExplorer(false); @@ -1114,6 +1119,27 @@ export function TaskCreationWizard({ Override the branch this task's worktree will be created from. Leave empty to use the project's configured default branch.

+ + {/* Workspace Isolation Toggle */} +
+ setUseWorktree(checked === true)} + disabled={isCreating} + /> +
+ +

+ {t('wizard.gitOptions.useWorktreeDescription')} +

+
+
)} diff --git a/apps/frontend/src/renderer/components/task-detail/TaskDetailModal.tsx b/apps/frontend/src/renderer/components/task-detail/TaskDetailModal.tsx index c70395bb..c9ffd4ad 100644 --- a/apps/frontend/src/renderer/components/task-detail/TaskDetailModal.tsx +++ b/apps/frontend/src/renderer/components/task-detail/TaskDetailModal.tsx @@ -1,5 +1,6 @@ import { useTranslation } from 'react-i18next'; import * as DialogPrimitive from '@radix-ui/react-dialog'; +import { useToast } from '../../hooks/use-toast'; import { Separator } from '../ui/separator'; import { Tabs, TabsContent, TabsList, TabsTrigger } from '../ui/tabs'; import { ScrollArea } from '../ui/scroll-area'; @@ -76,6 +77,7 @@ const isFilesTabEnabled = () => { // Separate component to use hooks only when task exists function TaskDetailModalContent({ open, task, onOpenChange, onSwitchToTerminals, onOpenInbuiltTerminal }: { open: boolean; task: Task; onOpenChange: (open: boolean) => void; onSwitchToTerminals?: () => void; onOpenInbuiltTerminal?: (id: string, cwd: string) => void }) { const { t } = useTranslation(['tasks']); + const { toast } = useToast(); const state = useTaskDetail({ task }); const showFilesTab = isFilesTabEnabled(); const progressPercent = calculateProgress(task.subtasks); @@ -162,6 +164,14 @@ function TaskDetailModalContent({ open, task, onOpenChange, onSwitchToTerminals, }; const handleClose = () => { + // Show toast notification if task is running + if (state.isRunning && !state.isStuck) { + toast({ + title: t('tasks:notifications.backgroundTaskTitle'), + description: t('tasks:notifications.backgroundTaskDescription'), + duration: 4000, + }); + } onOpenChange(false); }; diff --git a/apps/frontend/src/shared/i18n/locales/en/tasks.json b/apps/frontend/src/shared/i18n/locales/en/tasks.json index c06f74e1..d49d925b 100644 --- a/apps/frontend/src/shared/i18n/locales/en/tasks.json +++ b/apps/frontend/src/shared/i18n/locales/en/tasks.json @@ -115,5 +115,15 @@ "images": { "removeImageAriaLabel": "Remove image {{filename}}", "pasteHint": "Tip: Paste screenshots directly with {{shortcut}} to add reference images." + }, + "notifications": { + "backgroundTaskTitle": "Task continues in background", + "backgroundTaskDescription": "The task is still running. You can reopen this dialog to monitor progress." + }, + "wizard": { + "gitOptions": { + "useWorktreeLabel": "Use isolated workspace (recommended)", + "useWorktreeDescription": "Creates changes in a separate git worktree for safe review before merging. Disable to build directly in your project (faster but riskier)." + } } } diff --git a/apps/frontend/src/shared/i18n/locales/fr/tasks.json b/apps/frontend/src/shared/i18n/locales/fr/tasks.json index ff347de4..20f7eb5c 100644 --- a/apps/frontend/src/shared/i18n/locales/fr/tasks.json +++ b/apps/frontend/src/shared/i18n/locales/fr/tasks.json @@ -115,5 +115,15 @@ "images": { "removeImageAriaLabel": "Supprimer l'image {{filename}}", "pasteHint": "Astuce : Collez des captures d'écran directement avec {{shortcut}} pour ajouter des images de référence." + }, + "notifications": { + "backgroundTaskTitle": "La tâche continue en arrière-plan", + "backgroundTaskDescription": "La tâche est toujours en cours. Vous pouvez rouvrir cette boîte de dialogue pour suivre la progression." + }, + "wizard": { + "gitOptions": { + "useWorktreeLabel": "Utiliser un espace de travail isolé (recommandé)", + "useWorktreeDescription": "Crée les changements dans un worktree git séparé pour une révision sécurisée avant la fusion. Désactivez pour travailler directement dans votre projet (plus rapide mais risqué)." + } } } diff --git a/apps/frontend/src/shared/types/task.ts b/apps/frontend/src/shared/types/task.ts index 833516bc..68cc3357 100644 --- a/apps/frontend/src/shared/types/task.ts +++ b/apps/frontend/src/shared/types/task.ts @@ -228,6 +228,7 @@ export interface TaskMetadata { // Git/Worktree configuration baseBranch?: string; // Override base branch for this task's worktree + useWorktree?: boolean; // If false, use direct mode (no worktree isolation) - default is true for safety // Archive status archivedAt?: string; // ISO date when task was archived diff --git a/tests/test_security.py b/tests/test_security.py index f635659c..bc6c9d76 100644 --- a/tests/test_security.py +++ b/tests/test_security.py @@ -94,9 +94,24 @@ class TestCommandExtraction: assert commands == [] def test_malformed_command(self): - """Returns empty list for malformed command (fail-safe).""" + """Uses fallback parser for malformed commands (Windows path support). + + The fallback parser extracts command names even from commands with + unclosed quotes, which is common when Windows paths are used. + """ commands = extract_commands("echo 'unclosed quote") - assert commands == [] + assert commands == ["echo"] + + def test_windows_path_command(self): + """Handles Windows paths with backslashes.""" + commands = extract_commands(r'C:\Python312\python.exe -c "print(1)"') + assert "python" in commands + + def test_incomplete_windows_path_command(self): + """Handles incomplete commands with Windows paths (common AI generation issue).""" + cmd = r'python3 -c "import json; json.load(open(\'D:\path\file.json' + commands = extract_commands(cmd) + assert commands == ["python3"] class TestSplitCommandSegments: