Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
36 commits
Select commit Hold shift + click to select a range
b356ce4
fix(windows): prevent Errno 22 by normalizing paths for Windows compa…
youngmrz Jan 15, 2026
557a93c
fix: improve safe_path to sanitize all path components
youngmrz Jan 15, 2026
6c8a234
style: apply ruff formatting to file_utils.py
youngmrz Jan 15, 2026
cb9cc48
docs: add usage guidance for safe_open in module docstring
youngmrz Jan 15, 2026
83d9dba
fix: use safe_open for all spec file writes on Windows
youngmrz Jan 15, 2026
3c24c82
fix: address Windows path handling issues in file_utils.py
youngmrz Jan 15, 2026
c44d19b
Merge branch 'develop' into fix/windows-path-errno22
youngmrz Jan 15, 2026
92af6ca
Merge branch 'develop' into fix/windows-path-errno22
youngmrz Jan 15, 2026
e27ff34
Fix non-functional '+ Add' button for multiple Claude accounts (#1216)
AndyMik90 Jan 17, 2026
3024d54
Fix screenshot state persistence bug in task modals (#1235)
AndyMik90 Jan 17, 2026
3085e39
Fix PR List Update on Post Status Click (#1207)
AndyMik90 Jan 17, 2026
c13d9a4
update gitignore
AndyMik90 Jan 17, 2026
9020446
fix(windows): prevent zombie process accumulation on app close (#1259)
VDT-91 Jan 17, 2026
75a3684
Fix terminal rendering, persistence, and link handling (#1215)
AndyMik90 Jan 17, 2026
ba089c5
fix: auto-commit .gitignore changes during project initialization (#1…
youngmrz Jan 17, 2026
39236f1
fix(terminal): sync worktree config after PTY creation to fix first-a…
AndyMik90 Jan 17, 2026
3606a63
Draggable Kanban Task Reordering (#1217)
AndyMik90 Jan 17, 2026
d7ed770
fix: enforce 12 terminal limit per project (#1264)
AndyMik90 Jan 17, 2026
4cc8f4d
fix(pr-review): allow re-review when previous review failed (#1268)
AndyMik90 Jan 17, 2026
10b6d70
fix(windows): prevent Errno 22 by normalizing paths for Windows compa…
youngmrz Jan 15, 2026
77503f8
fix: improve safe_path to sanitize all path components
youngmrz Jan 15, 2026
9e14787
style: apply ruff formatting to file_utils.py
youngmrz Jan 15, 2026
b83d4e3
docs: add usage guidance for safe_open in module docstring
youngmrz Jan 15, 2026
16064d2
fix: use safe_open for all spec file writes on Windows
youngmrz Jan 15, 2026
554e41b
fix: address Windows path handling issues in file_utils.py
youngmrz Jan 15, 2026
b5b4fd3
fix: handle empty/whitespace-only filenames consistently
youngmrz Jan 15, 2026
22b67b8
fix: add path separators to Windows invalid filename chars map
youngmrz Jan 16, 2026
39d65b5
fix(file_utils): handle drive-relative paths and add MAX_PATH constant
youngmrz Jan 17, 2026
9a9f0a5
style: apply ruff formatting to drive-relative path check
youngmrz Jan 17, 2026
44304a6
Fix False Stuck Detection During Planning Phase (#1236)
AndyMik90 Jan 17, 2026
f0c3e50
Fix/cleanup 2.7.5 (#1271)
AndyMik90 Jan 17, 2026
7cb9e0a
fix(worktree): prevent cross-worktree file leakage via environment va…
AndyMik90 Jan 17, 2026
f29bf14
Merge upstream/develop (include both safe_open and write_json_atomic …
youngmrz Jan 18, 2026
b48b65d
Merge origin/fix/windows-path-errno22 (keep remote version)
youngmrz Jan 18, 2026
9fa575d
fix: resolve merge conflict in auto_fix.py (use write_json_atomic)
youngmrz Jan 18, 2026
22e6479
fix: remove unused safe_open import
youngmrz Jan 18, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -169,4 +169,5 @@ OPUS_ANALYSIS_AND_IDEAS.md

# Auto Claude generated files
.security-key
/shared_docs
/shared_docs
Agents.md
53 changes: 48 additions & 5 deletions .husky/pre-commit
Original file line number Diff line number Diff line change
@@ -1,11 +1,52 @@
#!/bin/sh

# Preserve git worktree context - prevent HEAD corruption in worktrees
# =============================================================================
# GIT WORKTREE CONTEXT HANDLING
# =============================================================================
# When running in a worktree, we need to preserve git context to prevent HEAD
# corruption. However, we must also CLEAR these variables when NOT in a worktree
# to prevent cross-worktree contamination (files leaking between worktrees).
#
# The bug: If GIT_DIR/GIT_WORK_TREE are set from a previous worktree session
# and this hook runs in the main repo (where .git is a directory, not a file),
# git commands will target the wrong repository, causing files to appear as
# untracked in the wrong location.
#
# Fix: Explicitly unset these variables when NOT in a worktree context.
# =============================================================================

if [ -f ".git" ]; then
WORKTREE_GIT_DIR=$(sed 's/^gitdir: //' .git)
if [ -n "$WORKTREE_GIT_DIR" ]; then
# We're in a worktree (.git is a file pointing to the actual git dir)
# Use -n with /p to only print lines that match the gitdir: prefix, head -1 for safety
WORKTREE_GIT_DIR=$(sed -n 's/^gitdir: //p' .git | head -1)
if [ -n "$WORKTREE_GIT_DIR" ] && [ -d "$WORKTREE_GIT_DIR" ]; then
export GIT_DIR="$WORKTREE_GIT_DIR"
export GIT_WORK_TREE="$(pwd)"
else
# .git file exists but is malformed or points to non-existent directory
# CRITICAL: Clear any inherited GIT_DIR/GIT_WORK_TREE to prevent cross-worktree leakage
unset GIT_DIR
unset GIT_WORK_TREE
fi
else
# We're in the main repo (.git is a directory)
# CRITICAL: Clear any inherited GIT_DIR/GIT_WORK_TREE to prevent cross-worktree leakage
unset GIT_DIR
unset GIT_WORK_TREE
fi

# =============================================================================
# SAFETY CHECK: Detect and fix corrupted core.worktree configuration
# =============================================================================
# If core.worktree is set in the main repo's config (pointing to a worktree),
# this indicates previous corruption. Fix it automatically.
if [ ! -f ".git" ]; then
CORE_WORKTREE=$(git config --get core.worktree 2>/dev/null || true)
if [ -n "$CORE_WORKTREE" ]; then
echo "Warning: Detected corrupted core.worktree setting, removing it..."
if ! git config --unset core.worktree 2>/dev/null; then
echo "Warning: Failed to unset core.worktree. Manual intervention may be needed."
fi
fi
fi

Expand Down Expand Up @@ -62,8 +103,9 @@ if git diff --cached --name-only | grep -q "^package.json$"; then
sed -i.bak '/<!-- BETA_VERSION_BADGE -->/,/<!-- BETA_VERSION_BADGE_END -->/s|releases/tag/v[0-9.a-z-]*)|releases/tag/v'"$VERSION"')|g' README.md

# Update beta download links (within BETA_DOWNLOADS section only)
# Use perl for cross-platform compatibility (BSD sed doesn't support {block} syntax)
for SUFFIX in "win32-x64.exe" "darwin-arm64.dmg" "darwin-x64.dmg" "linux-x86_64.AppImage" "linux-amd64.deb" "linux-x86_64.flatpak"; do
sed -i.bak '/<!-- BETA_DOWNLOADS -->/,/<!-- BETA_DOWNLOADS_END -->/{s|Auto-Claude-[0-9.a-z-]*-'"$SUFFIX"'](https://github.com/AndyMik90/Auto-Claude/releases/download/v[^/]*/Auto-Claude-[^)]*-'"$SUFFIX"')|Auto-Claude-'"$VERSION"'-'"$SUFFIX"'](https://github.com/AndyMik90/Auto-Claude/releases/download/v'"$VERSION"'/Auto-Claude-'"$VERSION"'-'"$SUFFIX"')|g}' README.md
perl -i -pe 'if (/<!-- BETA_DOWNLOADS -->/ .. /<!-- BETA_DOWNLOADS_END -->/) { s|Auto-Claude-[0-9.a-z-]*-'"$SUFFIX"'\]\(https://github.com/AndyMik90/Auto-Claude/releases/download/v[^/]*/Auto-Claude-[^)]*-'"$SUFFIX"'\)|Auto-Claude-'"$VERSION"'-'"$SUFFIX"'](https://github.com/AndyMik90/Auto-Claude/releases/download/v'"$VERSION"'/Auto-Claude-'"$VERSION"'-'"$SUFFIX"')|g }' README.md
done
else
# STABLE: Update stable sections and top badge
Expand All @@ -78,8 +120,9 @@ if git diff --cached --name-only | grep -q "^package.json$"; then
sed -i.bak '/<!-- STABLE_VERSION_BADGE -->/,/<!-- STABLE_VERSION_BADGE_END -->/s|releases/tag/v[0-9.a-z-]*)|releases/tag/v'"$VERSION"')|g' README.md

# Update stable download links (within STABLE_DOWNLOADS section only)
# Use perl for cross-platform compatibility (BSD sed doesn't support {block} syntax)
for SUFFIX in "win32-x64.exe" "darwin-arm64.dmg" "darwin-x64.dmg" "linux-x86_64.AppImage" "linux-amd64.deb"; do
sed -i.bak '/<!-- STABLE_DOWNLOADS -->/,/<!-- STABLE_DOWNLOADS_END -->/{s|Auto-Claude-[0-9.a-z-]*-'"$SUFFIX"'](https://github.com/AndyMik90/Auto-Claude/releases/download/v[^/]*/Auto-Claude-[^)]*-'"$SUFFIX"')|Auto-Claude-'"$VERSION"'-'"$SUFFIX"'](https://github.com/AndyMik90/Auto-Claude/releases/download/v'"$VERSION"'/Auto-Claude-'"$VERSION"'-'"$SUFFIX"')|g}' README.md
perl -i -pe 'if (/<!-- STABLE_DOWNLOADS -->/ .. /<!-- STABLE_DOWNLOADS_END -->/) { s|Auto-Claude-[0-9.a-z-]*-'"$SUFFIX"'\]\(https://github.com/AndyMik90/Auto-Claude/releases/download/v[^/]*/Auto-Claude-[^)]*-'"$SUFFIX"'\)|Auto-Claude-'"$VERSION"'-'"$SUFFIX"'](https://github.com/AndyMik90/Auto-Claude/releases/download/v'"$VERSION"'/Auto-Claude-'"$VERSION"'-'"$SUFFIX"')|g }' README.md
done
fi

Expand Down
12 changes: 6 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,17 +16,17 @@
### Stable Release

<!-- STABLE_VERSION_BADGE -->
[![Stable](https://img.shields.io/badge/stable-2.7.4-blue?style=flat-square)](https://github.com/AndyMik90/Auto-Claude/releases/tag/v2.7.4)
[![Stable](https://img.shields.io/badge/stable-2.7.5-blue?style=flat-square)](https://github.com/AndyMik90/Auto-Claude/releases/tag/v2.7.5)
<!-- STABLE_VERSION_BADGE_END -->

<!-- STABLE_DOWNLOADS -->
| Platform | Download |
|----------|----------|
| **Windows** | [Auto-Claude-2.7.4-win32-x64.exe](https://github.com/AndyMik90/Auto-Claude/releases/download/v2.7.4/Auto-Claude-2.7.4-win32-x64.exe) |
| **macOS (Apple Silicon)** | [Auto-Claude-2.7.4-darwin-arm64.dmg](https://github.com/AndyMik90/Auto-Claude/releases/download/v2.7.4/Auto-Claude-2.7.4-darwin-arm64.dmg) |
| **macOS (Intel)** | [Auto-Claude-2.7.4-darwin-x64.dmg](https://github.com/AndyMik90/Auto-Claude/releases/download/v2.7.4/Auto-Claude-2.7.4-darwin-x64.dmg) |
| **Linux** | [Auto-Claude-2.7.4-linux-x86_64.AppImage](https://github.com/AndyMik90/Auto-Claude/releases/download/v2.7.4/Auto-Claude-2.7.4-linux-x86_64.AppImage) |
| **Linux (Debian)** | [Auto-Claude-2.7.4-linux-amd64.deb](https://github.com/AndyMik90/Auto-Claude/releases/download/v2.7.4/Auto-Claude-2.7.4-linux-amd64.deb) |
| **Windows** | [Auto-Claude-2.7.5-win32-x64.exe](https://github.com/AndyMik90/Auto-Claude/releases/download/v2.7.5/Auto-Claude-2.7.5-win32-x64.exe) |
| **macOS (Apple Silicon)** | [Auto-Claude-2.7.5-darwin-arm64.dmg](https://github.com/AndyMik90/Auto-Claude/releases/download/v2.7.5/Auto-Claude-2.7.5-darwin-arm64.dmg) |
| **macOS (Intel)** | [Auto-Claude-2.7.5-darwin-x64.dmg](https://github.com/AndyMik90/Auto-Claude/releases/download/v2.7.5/Auto-Claude-2.7.5-darwin-x64.dmg) |
| **Linux** | [Auto-Claude-2.7.5-linux-x86_64.AppImage](https://github.com/AndyMik90/Auto-Claude/releases/download/v2.7.5/Auto-Claude-2.7.5-linux-x86_64.AppImage) |
| **Linux (Debian)** | [Auto-Claude-2.7.5-linux-amd64.deb](https://github.com/AndyMik90/Auto-Claude/releases/download/v2.7.5/Auto-Claude-2.7.5-linux-amd64.deb) |
| **Linux (Flatpak)** | [Auto-Claude-2.7.4-linux-x86_64.flatpak](https://github.com/AndyMik90/Auto-Claude/releases/download/v2.7.4/Auto-Claude-2.7.4-linux-x86_64.flatpak) |
<!-- STABLE_DOWNLOADS_END -->

Expand Down
2 changes: 1 addition & 1 deletion apps/backend/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,5 +19,5 @@
See README.md for full documentation.
"""

__version__ = "2.7.4"
__version__ = "2.7.5"
__author__ = "Auto Claude Team"
225 changes: 222 additions & 3 deletions apps/backend/core/file_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,26 +3,244 @@
Atomic File Write Utilities
============================

Synchronous utilities for atomic file writes to prevent corruption.
Synchronous utilities for atomic file writes and Windows path compatibility.

Uses temp file + os.replace() pattern which is atomic on POSIX systems
and atomic on Windows when source and destination are on the same volume.

For Windows compatibility, this module provides:
- safe_path(): Normalize paths and sanitize filenames for Windows
- safe_open(): Drop-in replacement for open() with Windows path handling
- sanitize_filename(): Clean invalid characters from filenames

RECOMMENDATION: For Windows compatibility, use safe_open() instead of open()
when working with file paths that may contain user input or special characters.

Usage:
from core.file_utils import write_json_atomic
from core.file_utils import write_json_atomic, safe_open

# Atomic JSON writes
write_json_atomic("/path/to/file.json", {"key": "value"})

# Windows-safe file operations
with safe_open("/path/to/file.txt", "r") as f:
content = f.read()
"""

import json
import logging
import os
import sys
import tempfile
from collections.abc import Iterator
from contextlib import contextmanager
from pathlib import Path
from typing import IO, Any, Literal

# Windows invalid filename characters and their safe replacements
_WINDOWS_INVALID_CHARS_MAP = str.maketrans(
{
"<": "_",
">": "_",
":": "-", # Common in timestamps, replace with dash
'"': "'",
"/": "_", # Path separator - invalid in filenames
"\\": "_", # Path separator - invalid in filenames
"|": "_",
"?": "_",
"*": "_",
}
)

# Windows MAX_PATH limit (260 characters including null terminator)
_WINDOWS_MAX_PATH = 260

# Windows reserved filenames (case-insensitive)
_WINDOWS_RESERVED_NAMES = frozenset(
[
"CON",
"PRN",
"AUX",
"NUL",
"COM1",
"COM2",
"COM3",
"COM4",
"COM5",
"COM6",
"COM7",
"COM8",
"COM9",
"LPT1",
"LPT2",
"LPT3",
"LPT4",
"LPT5",
"LPT6",
"LPT7",
"LPT8",
"LPT9",
]
)


def is_windows() -> bool:
"""Check if running on Windows."""
return sys.platform == "win32"


def normalize_path(filepath: str | Path) -> Path:
"""
Normalize a file path for cross-platform compatibility.

On Windows:
- Resolves the path to absolute form
- Handles long paths (>260 chars) by adding \\\\?\\ prefix if needed
- Normalizes path separators

On other platforms:
- Simply resolves the path

Args:
filepath: Path to normalize

Returns:
Normalized Path object
"""
path = Path(filepath)

# Resolve to absolute path
try:
path = path.resolve()
except OSError:
# If resolve fails (e.g., path doesn't exist yet), use absolute instead
path = path.absolute()

if is_windows():
path_str = str(path)

# Handle long paths on Windows (>MAX_PATH chars)
# The \\?\ prefix allows paths up to ~32,767 chars
if len(path_str) > _WINDOWS_MAX_PATH and not path_str.startswith("\\\\?\\"):
if path_str.startswith("\\\\"):
# UNC path (\\server\share) needs \\?\UNC\server\share format
path = Path("\\\\?\\UNC\\" + path_str[2:])
else:
# Regular path
path = Path("\\\\?\\" + path_str)

return path


def sanitize_filename(filename: str) -> str:
"""
Sanitize a filename to be valid on Windows.

Replaces invalid characters and handles reserved names.

Args:
filename: The filename to sanitize (not a full path)

Returns:
Sanitized filename safe for Windows. Returns "_unnamed" if sanitization
produces an empty string.
"""
if not filename or not filename.strip():
return "_unnamed"

# Replace invalid characters
sanitized = filename.translate(_WINDOWS_INVALID_CHARS_MAP)

# Remove trailing dots and spaces (Windows doesn't allow them)
sanitized = sanitized.rstrip(". ")

# Handle edge case where sanitization produces empty string
# (e.g., input was "..." or " ")
if not sanitized:
return "_unnamed"

# Handle reserved names by prefixing with underscore
# Use split (not rsplit) to get base name before any extension (e.g., LPT1.foo.bar -> LPT1)
name_part = sanitized.split(".", 1)[0] if "." in sanitized else sanitized
if name_part.upper() in _WINDOWS_RESERVED_NAMES:
sanitized = "_" + sanitized

return sanitized


def safe_path(filepath: str | Path) -> Path:
"""
Create a safe, normalized path with sanitized components.

Combines path normalization with filename/directory sanitization for
full Windows compatibility.

Args:
filepath: Path to make safe

Returns:
Safe, normalized Path object
"""
path = Path(filepath)

if is_windows():
# Sanitize all path components (directories and filename)
# to handle cases where paths come from external sources
parts = list(path.parts)
sanitized_parts = []

for i, part in enumerate(parts):
# Don't sanitize the drive/root component
# pathlib returns 'C:\\' (length 3) for absolute paths, 'C:' (length 2) for
# drive-relative paths, or '\\\\server\\share' for UNC paths
if i == 0 and (
(
len(part) >= 2 and part[0].isalpha() and part[1] == ":"
) # Drive: C:\ or C:
or part.startswith("\\\\") # UNC path anchor
):
sanitized_parts.append(part)
else:
sanitized_parts.append(sanitize_filename(part))

path = Path(*sanitized_parts) if sanitized_parts else path

return normalize_path(path)


def safe_open(
filepath: str | Path,
mode: str = "r",
encoding: str | None = "utf-8",
**kwargs: Any,
) -> IO:
"""
Open a file with Windows-safe path handling.

Drop-in replacement for built-in open() that handles Windows path
normalization and long path support.

Args:
filepath: Path to the file
mode: File open mode (default: "r")
encoding: File encoding, None for binary modes (default: "utf-8")
**kwargs: Additional arguments passed to open()

Returns:
File handle
"""
safe = safe_path(filepath)

# Ensure parent directory exists for write modes
if any(c in mode for c in "wxa"):
safe.parent.mkdir(parents=True, exist_ok=True)

# Binary modes require encoding=None
if "b" in mode:
encoding = None

return open(safe, mode, encoding=encoding, **kwargs)


@contextmanager
def atomic_write(
Expand Down Expand Up @@ -51,7 +269,8 @@ def atomic_write(
Yields:
File handle to temp file
"""
filepath = Path(filepath)
# Use safe_path for Windows compatibility
filepath = safe_path(filepath)
filepath.parent.mkdir(parents=True, exist_ok=True)

# Binary modes require encoding=None
Expand Down
Loading
Loading