mirror of
https://github.com/leonvanzyl/autocoder.git
synced 2026-02-02 15:23:37 +00:00
feat: move autocoder runtime files into .autocoder/ subdirectory
Add centralized path resolution module (autocoder_paths.py) that consolidates all autocoder-generated file paths behind a dual-path strategy: check .autocoder/X first, fall back to root-level X for backward compatibility, default to .autocoder/X for new projects. Key changes: - New autocoder_paths.py with dual-path resolution for features.db, assistant.db, lock files, settings, prompts dir, and progress cache - migrate_project_layout() safely moves old-layout projects to new layout with SQLite WAL flush and integrity verification - Updated 22 files to delegate path construction to autocoder_paths - Reset/delete logic cleans both old and new file locations - Orphan lock cleanup checks both locations per project - Migration called automatically at agent start in autonomous_agent_demo.py - Updated markdown commands/skills to reference .autocoder/prompts/ - CLAUDE.md documentation updated with new project structure Files at project root that remain unchanged: - CLAUDE.md (Claude SDK reads from cwd via setting_sources=["project"]) - app_spec.txt root copy (agent templates reference it via cat) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -222,7 +222,14 @@ if UI_DIST_DIR.exists():
|
||||
raise HTTPException(status_code=404)
|
||||
|
||||
# Try to serve the file directly
|
||||
file_path = UI_DIST_DIR / path
|
||||
file_path = (UI_DIST_DIR / path).resolve()
|
||||
|
||||
# Ensure resolved path is within UI_DIST_DIR (prevent path traversal)
|
||||
try:
|
||||
file_path.relative_to(UI_DIST_DIR.resolve())
|
||||
except ValueError:
|
||||
raise HTTPException(status_code=404)
|
||||
|
||||
if file_path.exists() and file_path.is_file():
|
||||
return FileResponse(file_path)
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ API endpoints for dev server control (start/stop) and configuration.
|
||||
Uses project registry for path lookups and project_config for command detection.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import re
|
||||
import sys
|
||||
from pathlib import Path
|
||||
@@ -33,6 +34,9 @@ if str(_root) not in sys.path:
|
||||
sys.path.insert(0, str(_root))
|
||||
|
||||
from registry import get_project_path as registry_get_project_path
|
||||
from security import extract_commands, get_effective_commands, is_command_allowed
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _get_project_path(project_name: str) -> Path | None:
|
||||
@@ -106,6 +110,45 @@ def get_project_devserver_manager(project_name: str):
|
||||
return get_devserver_manager(project_name, project_dir)
|
||||
|
||||
|
||||
def validate_dev_command(command: str, project_dir: Path) -> None:
|
||||
"""
|
||||
Validate a dev server command against the security allowlist.
|
||||
|
||||
Extracts all commands from the shell string and checks each against
|
||||
the effective allowlist (global + org + project). Raises HTTPException
|
||||
if any command is blocked or not allowed.
|
||||
|
||||
Args:
|
||||
command: The shell command string to validate
|
||||
project_dir: Project directory for loading project-level allowlists
|
||||
|
||||
Raises:
|
||||
HTTPException 400: If the command fails validation
|
||||
"""
|
||||
commands = extract_commands(command)
|
||||
if not commands:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="Could not parse command for security validation"
|
||||
)
|
||||
|
||||
allowed_commands, blocked_commands = get_effective_commands(project_dir)
|
||||
|
||||
for cmd in commands:
|
||||
if cmd in blocked_commands:
|
||||
logger.warning("Blocked dev server command '%s' (in blocklist) for project dir %s", cmd, project_dir)
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"Command '{cmd}' is blocked and cannot be used as a dev server command"
|
||||
)
|
||||
if not is_command_allowed(cmd, allowed_commands):
|
||||
logger.warning("Rejected dev server command '%s' (not in allowlist) for project dir %s", cmd, project_dir)
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"Command '{cmd}' is not in the allowed commands list"
|
||||
)
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Endpoints
|
||||
# ============================================================================
|
||||
@@ -167,7 +210,10 @@ async def start_devserver(
|
||||
detail="No dev command available. Configure a custom command or ensure project type can be detected."
|
||||
)
|
||||
|
||||
# Now command is definitely str
|
||||
# Validate command against security allowlist before execution
|
||||
validate_dev_command(command, project_dir)
|
||||
|
||||
# Now command is definitely str and validated
|
||||
success, message = await manager.start(command)
|
||||
|
||||
return DevServerActionResponse(
|
||||
@@ -258,6 +304,9 @@ async def update_devserver_config(
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
else:
|
||||
# Validate command against security allowlist before persisting
|
||||
validate_dev_command(update.custom_command, project_dir)
|
||||
|
||||
# Set the custom command
|
||||
try:
|
||||
set_dev_command(project_dir, update.custom_command)
|
||||
|
||||
@@ -136,7 +136,8 @@ async def expand_project_websocket(websocket: WebSocket, project_name: str):
|
||||
return
|
||||
|
||||
# Verify project has app_spec.txt
|
||||
spec_path = project_dir / "prompts" / "app_spec.txt"
|
||||
from autocoder_paths import get_prompts_dir
|
||||
spec_path = get_prompts_dir(project_dir) / "app_spec.txt"
|
||||
if not spec_path.exists():
|
||||
await websocket.close(code=4004, reason="Project has no spec. Create spec first.")
|
||||
return
|
||||
|
||||
@@ -134,7 +134,8 @@ async def list_features(project_name: str):
|
||||
if not project_dir.exists():
|
||||
raise HTTPException(status_code=404, detail="Project directory not found")
|
||||
|
||||
db_file = project_dir / "features.db"
|
||||
from autocoder_paths import get_features_db_path
|
||||
db_file = get_features_db_path(project_dir)
|
||||
if not db_file.exists():
|
||||
return FeatureListResponse(pending=[], in_progress=[], done=[])
|
||||
|
||||
@@ -329,7 +330,8 @@ async def get_dependency_graph(project_name: str):
|
||||
if not project_dir.exists():
|
||||
raise HTTPException(status_code=404, detail="Project directory not found")
|
||||
|
||||
db_file = project_dir / "features.db"
|
||||
from autocoder_paths import get_features_db_path
|
||||
db_file = get_features_db_path(project_dir)
|
||||
if not db_file.exists():
|
||||
return DependencyGraphResponse(nodes=[], edges=[])
|
||||
|
||||
@@ -393,7 +395,8 @@ async def get_feature(project_name: str, feature_id: int):
|
||||
if not project_dir.exists():
|
||||
raise HTTPException(status_code=404, detail="Project directory not found")
|
||||
|
||||
db_file = project_dir / "features.db"
|
||||
from autocoder_paths import get_features_db_path
|
||||
db_file = get_features_db_path(project_dir)
|
||||
if not db_file.exists():
|
||||
raise HTTPException(status_code=404, detail="No features database found")
|
||||
|
||||
|
||||
@@ -269,8 +269,8 @@ async def delete_project(name: str, delete_files: bool = False):
|
||||
raise HTTPException(status_code=404, detail=f"Project '{name}' not found")
|
||||
|
||||
# Check if agent is running
|
||||
lock_file = project_dir / ".agent.lock"
|
||||
if lock_file.exists():
|
||||
from autocoder_paths import has_agent_running
|
||||
if has_agent_running(project_dir):
|
||||
raise HTTPException(
|
||||
status_code=409,
|
||||
detail="Cannot delete project while agent is running. Stop the agent first."
|
||||
@@ -398,8 +398,8 @@ async def reset_project(name: str, full_reset: bool = False):
|
||||
raise HTTPException(status_code=404, detail="Project directory not found")
|
||||
|
||||
# Check if agent is running
|
||||
lock_file = project_dir / ".agent.lock"
|
||||
if lock_file.exists():
|
||||
from autocoder_paths import has_agent_running
|
||||
if has_agent_running(project_dir):
|
||||
raise HTTPException(
|
||||
status_code=409,
|
||||
detail="Cannot reset project while agent is running. Stop the agent first."
|
||||
@@ -415,36 +415,58 @@ async def reset_project(name: str, full_reset: bool = False):
|
||||
|
||||
deleted_files: list[str] = []
|
||||
|
||||
# Files to delete in quick reset
|
||||
quick_reset_files = [
|
||||
"features.db",
|
||||
"features.db-wal", # WAL mode journal file
|
||||
"features.db-shm", # WAL mode shared memory file
|
||||
"assistant.db",
|
||||
"assistant.db-wal",
|
||||
"assistant.db-shm",
|
||||
".claude_settings.json",
|
||||
".claude_assistant_settings.json",
|
||||
from autocoder_paths import (
|
||||
get_assistant_db_path,
|
||||
get_claude_assistant_settings_path,
|
||||
get_claude_settings_path,
|
||||
get_features_db_path,
|
||||
)
|
||||
|
||||
# Build list of files to delete using path helpers (finds files at current location)
|
||||
# Plus explicit old-location fallbacks for backward compatibility
|
||||
db_path = get_features_db_path(project_dir)
|
||||
asst_path = get_assistant_db_path(project_dir)
|
||||
reset_files: list[Path] = [
|
||||
db_path,
|
||||
db_path.with_suffix(".db-wal"),
|
||||
db_path.with_suffix(".db-shm"),
|
||||
asst_path,
|
||||
asst_path.with_suffix(".db-wal"),
|
||||
asst_path.with_suffix(".db-shm"),
|
||||
get_claude_settings_path(project_dir),
|
||||
get_claude_assistant_settings_path(project_dir),
|
||||
# Also clean old root-level locations if they exist
|
||||
project_dir / "features.db",
|
||||
project_dir / "features.db-wal",
|
||||
project_dir / "features.db-shm",
|
||||
project_dir / "assistant.db",
|
||||
project_dir / "assistant.db-wal",
|
||||
project_dir / "assistant.db-shm",
|
||||
project_dir / ".claude_settings.json",
|
||||
project_dir / ".claude_assistant_settings.json",
|
||||
]
|
||||
|
||||
for filename in quick_reset_files:
|
||||
file_path = project_dir / filename
|
||||
for file_path in reset_files:
|
||||
if file_path.exists():
|
||||
try:
|
||||
relative = file_path.relative_to(project_dir)
|
||||
file_path.unlink()
|
||||
deleted_files.append(filename)
|
||||
deleted_files.append(str(relative))
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=f"Failed to delete {filename}: {e}")
|
||||
raise HTTPException(status_code=500, detail=f"Failed to delete {file_path.name}: {e}")
|
||||
|
||||
# Full reset: also delete prompts directory
|
||||
if full_reset:
|
||||
prompts_dir = project_dir / "prompts"
|
||||
if prompts_dir.exists():
|
||||
try:
|
||||
shutil.rmtree(prompts_dir)
|
||||
deleted_files.append("prompts/")
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=f"Failed to delete prompts/: {e}")
|
||||
from autocoder_paths import get_prompts_dir
|
||||
# Delete prompts from both possible locations
|
||||
for prompts_dir in [get_prompts_dir(project_dir), project_dir / "prompts"]:
|
||||
if prompts_dir.exists():
|
||||
try:
|
||||
relative = prompts_dir.relative_to(project_dir)
|
||||
shutil.rmtree(prompts_dir)
|
||||
deleted_files.append(f"{relative}/")
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=f"Failed to delete prompts: {e}")
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
|
||||
@@ -124,7 +124,8 @@ async def get_spec_file_status(project_name: str):
|
||||
if not project_dir.exists():
|
||||
raise HTTPException(status_code=404, detail="Project directory not found")
|
||||
|
||||
status_file = project_dir / "prompts" / ".spec_status.json"
|
||||
from autocoder_paths import get_prompts_dir
|
||||
status_file = get_prompts_dir(project_dir) / ".spec_status.json"
|
||||
|
||||
if not status_file.exists():
|
||||
return SpecFileStatus(
|
||||
|
||||
@@ -76,7 +76,8 @@ def get_system_prompt(project_name: str, project_dir: Path) -> str:
|
||||
"""Generate the system prompt for the assistant with project context."""
|
||||
# Try to load app_spec.txt for context
|
||||
app_spec_content = ""
|
||||
app_spec_path = project_dir / "prompts" / "app_spec.txt"
|
||||
from autocoder_paths import get_prompts_dir
|
||||
app_spec_path = get_prompts_dir(project_dir) / "app_spec.txt"
|
||||
if app_spec_path.exists():
|
||||
try:
|
||||
app_spec_content = app_spec_path.read_text(encoding="utf-8")
|
||||
@@ -235,7 +236,9 @@ class AssistantChatSession:
|
||||
"allow": permissions_list,
|
||||
},
|
||||
}
|
||||
settings_file = self.project_dir / ".claude_assistant_settings.json"
|
||||
from autocoder_paths import get_claude_assistant_settings_path
|
||||
settings_file = get_claude_assistant_settings_path(self.project_dir)
|
||||
settings_file.parent.mkdir(parents=True, exist_ok=True)
|
||||
with open(settings_file, "w") as f:
|
||||
json.dump(security_settings, f, indent=2)
|
||||
|
||||
|
||||
@@ -63,7 +63,8 @@ class ConversationMessage(Base):
|
||||
|
||||
def get_db_path(project_dir: Path) -> Path:
|
||||
"""Get the path to the assistant database for a project."""
|
||||
return project_dir / "assistant.db"
|
||||
from autocoder_paths import get_assistant_db_path
|
||||
return get_assistant_db_path(project_dir)
|
||||
|
||||
|
||||
def get_engine(project_dir: Path):
|
||||
|
||||
@@ -24,6 +24,7 @@ from typing import Awaitable, Callable, Literal, Set
|
||||
import psutil
|
||||
|
||||
from registry import list_registered_projects
|
||||
from security import extract_commands, get_effective_commands, is_command_allowed
|
||||
from server.utils.process_utils import kill_process_tree
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -114,7 +115,8 @@ class DevServerProcessManager:
|
||||
self._callbacks_lock = threading.Lock()
|
||||
|
||||
# Lock file to prevent multiple instances (stored in project directory)
|
||||
self.lock_file = self.project_dir / ".devserver.lock"
|
||||
from autocoder_paths import get_devserver_lock_path
|
||||
self.lock_file = get_devserver_lock_path(self.project_dir)
|
||||
|
||||
@property
|
||||
def status(self) -> Literal["stopped", "running", "crashed"]:
|
||||
@@ -304,6 +306,20 @@ class DevServerProcessManager:
|
||||
if not self.project_dir.exists():
|
||||
return False, f"Project directory does not exist: {self.project_dir}"
|
||||
|
||||
# Defense-in-depth: validate command against security allowlist
|
||||
commands = extract_commands(command)
|
||||
if not commands:
|
||||
return False, "Could not parse command for security validation"
|
||||
|
||||
allowed_commands, blocked_commands = get_effective_commands(self.project_dir)
|
||||
for cmd in commands:
|
||||
if cmd in blocked_commands:
|
||||
logger.warning("Blocked dev server command '%s' (in blocklist) for %s", cmd, self.project_name)
|
||||
return False, f"Command '{cmd}' is blocked and cannot be used as a dev server command"
|
||||
if not is_command_allowed(cmd, allowed_commands):
|
||||
logger.warning("Rejected dev server command '%s' (not in allowlist) for %s", cmd, self.project_name)
|
||||
return False, f"Command '{cmd}' is not in the allowed commands list"
|
||||
|
||||
self._command = command
|
||||
self._detected_url = None # Reset URL detection
|
||||
|
||||
@@ -487,8 +503,18 @@ def cleanup_orphaned_devserver_locks() -> int:
|
||||
if not project_path.exists():
|
||||
continue
|
||||
|
||||
lock_file = project_path / ".devserver.lock"
|
||||
if not lock_file.exists():
|
||||
# Check both legacy and new locations for lock files
|
||||
from autocoder_paths import get_autocoder_dir
|
||||
lock_locations = [
|
||||
project_path / ".devserver.lock",
|
||||
get_autocoder_dir(project_path) / ".devserver.lock",
|
||||
]
|
||||
lock_file = None
|
||||
for candidate in lock_locations:
|
||||
if candidate.exists():
|
||||
lock_file = candidate
|
||||
break
|
||||
if lock_file is None:
|
||||
continue
|
||||
|
||||
try:
|
||||
|
||||
@@ -128,7 +128,8 @@ class ExpandChatSession:
|
||||
return
|
||||
|
||||
# Verify project has existing spec
|
||||
spec_path = self.project_dir / "prompts" / "app_spec.txt"
|
||||
from autocoder_paths import get_prompts_dir
|
||||
spec_path = get_prompts_dir(self.project_dir) / "app_spec.txt"
|
||||
if not spec_path.exists():
|
||||
yield {
|
||||
"type": "error",
|
||||
@@ -166,7 +167,9 @@ class ExpandChatSession:
|
||||
],
|
||||
},
|
||||
}
|
||||
settings_file = self.project_dir / f".claude_settings.expand.{uuid.uuid4().hex}.json"
|
||||
from autocoder_paths import get_expand_settings_path
|
||||
settings_file = get_expand_settings_path(self.project_dir, uuid.uuid4().hex)
|
||||
settings_file.parent.mkdir(parents=True, exist_ok=True)
|
||||
self._settings_file = settings_file
|
||||
with open(settings_file, "w", encoding="utf-8") as f:
|
||||
json.dump(security_settings, f, indent=2)
|
||||
|
||||
@@ -92,7 +92,8 @@ class AgentProcessManager:
|
||||
self._callbacks_lock = threading.Lock()
|
||||
|
||||
# Lock file to prevent multiple instances (stored in project directory)
|
||||
self.lock_file = self.project_dir / ".agent.lock"
|
||||
from autocoder_paths import get_agent_lock_path
|
||||
self.lock_file = get_agent_lock_path(self.project_dir)
|
||||
|
||||
@property
|
||||
def status(self) -> Literal["stopped", "running", "paused", "crashed"]:
|
||||
@@ -579,8 +580,18 @@ def cleanup_orphaned_locks() -> int:
|
||||
if not project_path.exists():
|
||||
continue
|
||||
|
||||
lock_file = project_path / ".agent.lock"
|
||||
if not lock_file.exists():
|
||||
# Check both legacy and new locations for lock files
|
||||
from autocoder_paths import get_autocoder_dir
|
||||
lock_locations = [
|
||||
project_path / ".agent.lock",
|
||||
get_autocoder_dir(project_path) / ".agent.lock",
|
||||
]
|
||||
lock_file = None
|
||||
for candidate in lock_locations:
|
||||
if candidate.exists():
|
||||
lock_file = candidate
|
||||
break
|
||||
if lock_file is None:
|
||||
continue
|
||||
|
||||
try:
|
||||
|
||||
@@ -92,8 +92,9 @@ class SchedulerService:
|
||||
async def _load_project_schedules(self, project_name: str, project_dir: Path) -> int:
|
||||
"""Load schedules for a single project. Returns count of schedules loaded."""
|
||||
from api.database import Schedule, create_database
|
||||
from autocoder_paths import get_features_db_path
|
||||
|
||||
db_path = project_dir / "features.db"
|
||||
db_path = get_features_db_path(project_dir)
|
||||
if not db_path.exists():
|
||||
return 0
|
||||
|
||||
@@ -567,8 +568,9 @@ class SchedulerService:
|
||||
):
|
||||
"""Check if a project should be started on server startup."""
|
||||
from api.database import Schedule, ScheduleOverride, create_database
|
||||
from autocoder_paths import get_features_db_path
|
||||
|
||||
db_path = project_dir / "features.db"
|
||||
db_path = get_features_db_path(project_dir)
|
||||
if not db_path.exists():
|
||||
return
|
||||
|
||||
|
||||
@@ -125,7 +125,8 @@ class SpecChatSession:
|
||||
# Delete app_spec.txt so Claude can create it fresh
|
||||
# The SDK requires reading existing files before writing, but app_spec.txt is created new
|
||||
# Note: We keep initializer_prompt.md so Claude can read and update the template
|
||||
prompts_dir = self.project_dir / "prompts"
|
||||
from autocoder_paths import get_prompts_dir
|
||||
prompts_dir = get_prompts_dir(self.project_dir)
|
||||
app_spec_path = prompts_dir / "app_spec.txt"
|
||||
if app_spec_path.exists():
|
||||
app_spec_path.unlink()
|
||||
@@ -145,7 +146,9 @@ class SpecChatSession:
|
||||
],
|
||||
},
|
||||
}
|
||||
settings_file = self.project_dir / ".claude_settings.json"
|
||||
from autocoder_paths import get_claude_settings_path
|
||||
settings_file = get_claude_settings_path(self.project_dir)
|
||||
settings_file.parent.mkdir(parents=True, exist_ok=True)
|
||||
with open(settings_file, "w") as f:
|
||||
json.dump(security_settings, f, indent=2)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user