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: