From 53b54eaacd869f2741aeb469bea578f8a9efeb87 Mon Sep 17 00:00:00 2001 From: AndyMik90 Date: Fri, 20 Feb 2026 12:48:02 +0100 Subject: [PATCH] fix: address CRITICAL/HIGH/MEDIUM security and quality review findings CRITICAL fixes: - Add shared-secret token authentication to all API routes via FastAPI Depends() middleware (api/auth.py). Health endpoint remains public. - Add authentication to all Socket.IO on_connect handlers (terminal_ns, agent_ns, events_ns) validating Bearer token from query params or Authorization header. - Fix PTY read loop premature termination: _blocking_read now returns None for timeout vs b"" for real EOF, preventing the read loop from exiting on every select() timeout. HIGH fixes: - Mask sensitive API tokens in GET /env responses (show last 4 chars only for GITHUB_TOKEN, GITLAB_TOKEN, etc.). - Add path traversal validation in task deletion: task_id is checked via resolve().is_relative_to() before any filesystem operations. - Add SSRF protection for GITLAB_INSTANCE_URL: validate HTTPS scheme, resolve hostname and block private/internal IP ranges. - Validate agent build paths (projectDir, specDir) against registered project directories. - Validate terminal cwd parameter against registered projects. - Extract duplicated helpers into api/shared.py: _find_project, _load_store, _save_store, _now_iso, parse_env_file, find_env_file, update_env_file, _write_atomic. - All 9 route files now import from shared module instead of re-declaring _find_project. MEDIUM fixes: - CORS origins now read from CORS_ALLOWED_ORIGINS env var with localhost fallback. - Socket.IO ASGI app exported as 'app' for uvicorn compatibility. - update_env preserves .env comments and formatting for untouched lines. - Terminal PTY sessions cleaned up on Socket.IO disconnect via sid->session_id mapping. - All JSON writes use atomic temp-file+rename pattern via _write_atomic helper. - Cross-platform guards on Unix-only imports (pty, fcntl, select, termios) with _IS_UNIX flag. - Node.js 22 localStorage SSR workaround added back to next.config.mjs. - global-error.tsx documents why strings are hardcoded (i18n provider unavailable in root error boundary). - Missing encoding='utf-8' parameters added throughout. Co-Authored-By: Claude Opus 4.6 --- apps/backend/api/auth.py | 67 ++++++ apps/backend/api/main.py | 78 ++++--- apps/backend/api/routes/agents.py | 3 +- apps/backend/api/routes/changelog.py | 16 +- apps/backend/api/routes/context.py | 25 +-- apps/backend/api/routes/env.py | 178 +++++++--------- apps/backend/api/routes/github.py | 37 +--- apps/backend/api/routes/gitlab.py | 80 ++++--- apps/backend/api/routes/ideation.py | 12 +- apps/backend/api/routes/insights.py | 12 +- apps/backend/api/routes/projects.py | 36 +--- apps/backend/api/routes/roadmap.py | 12 +- apps/backend/api/routes/settings.py | 13 +- apps/backend/api/routes/tasks.py | 74 ++++--- apps/backend/api/services/terminal_service.py | 46 ++++- apps/backend/api/shared.py | 195 ++++++++++++++++++ apps/backend/api/websocket/agent_ns.py | 61 ++++-- apps/backend/api/websocket/events_ns.py | 21 +- apps/backend/api/websocket/terminal_ns.py | 79 ++++++- apps/web/next.config.mjs | 13 ++ apps/web/src/app/global-error.tsx | 9 + 21 files changed, 702 insertions(+), 365 deletions(-) create mode 100644 apps/backend/api/auth.py create mode 100644 apps/backend/api/shared.py diff --git a/apps/backend/api/auth.py b/apps/backend/api/auth.py new file mode 100644 index 00000000..2136dcf4 --- /dev/null +++ b/apps/backend/api/auth.py @@ -0,0 +1,67 @@ +""" +API Authentication +=================== + +Shared-secret token authentication for the Auto Claude web API. + +A random token is generated at startup and printed to stdout so the +Next.js frontend can pick it up. Alternatively, the token can be set +via the ``AUTO_CLAUDE_API_TOKEN`` environment variable. + +All HTTP routes (except ``/api/health``) and all Socket.IO ``on_connect`` +handlers must validate the token. +""" + +from __future__ import annotations + +import logging + +from fastapi import Depends, HTTPException, Request, status +from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer + +from .shared import API_TOKEN + +logger = logging.getLogger(__name__) + +_bearer_scheme = HTTPBearer(auto_error=False) + + +async def require_auth( + credentials: HTTPAuthorizationCredentials | None = Depends(_bearer_scheme), +) -> str: + """FastAPI dependency that validates the Bearer token. + + Returns the validated token string on success. + Raises HTTP 401 if the token is missing or incorrect. + """ + if credentials is None or credentials.credentials != API_TOKEN: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Invalid or missing authentication token", + headers={"WWW-Authenticate": "Bearer"}, + ) + return credentials.credentials + + +def validate_socketio_token(environ: dict) -> bool: + """Validate the Bearer token from a Socket.IO connection handshake. + + The client should pass the token as a query parameter ``token`` or in + the ``Authorization`` header. + + Returns ``True`` if valid, ``False`` otherwise. + """ + # Check query string first (Socket.IO commonly uses query params) + query_string: str = environ.get("QUERY_STRING", "") + for part in query_string.split("&"): + if part.startswith("token="): + if part[6:] == API_TOKEN: + return True + + # Check Authorization header + auth_header: str = environ.get("HTTP_AUTHORIZATION", "") + if auth_header.startswith("Bearer "): + if auth_header[7:] == API_TOKEN: + return True + + return False diff --git a/apps/backend/api/main.py b/apps/backend/api/main.py index 6453ff49..7a385f50 100644 --- a/apps/backend/api/main.py +++ b/apps/backend/api/main.py @@ -6,12 +6,17 @@ Main FastAPI application with CORS middleware, Socket.IO integration, and lifespan events. Serves as the backend for the Next.js web frontend. """ +from __future__ import annotations + +import logging +import os from contextlib import asynccontextmanager import socketio -from fastapi import FastAPI +from fastapi import Depends, FastAPI from fastapi.middleware.cors import CORSMiddleware +from .auth import require_auth from .routes.agents import router as agents_router from .routes.changelog import router as changelog_router from .routes.context import router as context_router @@ -26,14 +31,29 @@ from .routes.roadmap import router as roadmap_router from .routes.settings import router as settings_router from .routes.tasks import router as tasks_router from .routes.terminal import router as terminal_router +from .shared import API_TOKEN from .websocket.agent_ns import register_agent_namespace from .websocket.events_ns import register_events_namespace from .websocket.terminal_ns import get_terminal_service, register_terminal_namespace +logger = logging.getLogger(__name__) + +# --------------------------------------------------------------------------- +# CORS origins — read from env var or fall back to localhost dev servers +# --------------------------------------------------------------------------- +_cors_origins_raw = os.environ.get("CORS_ALLOWED_ORIGINS", "") +CORS_ORIGINS: list[str] = ( + [o.strip() for o in _cors_origins_raw.split(",") if o.strip()] + if _cors_origins_raw + else ["http://localhost:3000", "http://localhost:3001"] +) + +# --------------------------------------------------------------------------- # Socket.IO async server for real-time communication +# --------------------------------------------------------------------------- sio = socketio.AsyncServer( async_mode="asgi", - cors_allowed_origins=["http://localhost:3000", "http://localhost:3001"], + cors_allowed_origins=CORS_ORIGINS, ) # Register Socket.IO namespaces @@ -43,44 +63,56 @@ register_events_namespace(sio) @asynccontextmanager -async def lifespan(app: FastAPI): +async def lifespan(_app: FastAPI): """Application lifespan: startup and shutdown events.""" - # Startup + # Startup — log the API token so the frontend process can pick it up + logger.info("AUTO_CLAUDE_API_TOKEN=%s", API_TOKEN) yield # Shutdown — kill all PTY sessions await get_terminal_service().kill_all() -app = FastAPI( +fastapi_app = FastAPI( title="Auto Claude API", description="Backend API for the Auto Claude web frontend", lifespan=lifespan, ) -# CORS middleware for Next.js dev servers -app.add_middleware( +# CORS middleware +fastapi_app.add_middleware( CORSMiddleware, - allow_origins=["http://localhost:3000", "http://localhost:3001"], + allow_origins=CORS_ORIGINS, allow_credentials=True, allow_methods=["*"], allow_headers=["*"], ) +# --------------------------------------------------------------------------- # Register routes -app.include_router(health_router) -app.include_router(projects_router) -app.include_router(tasks_router) -app.include_router(settings_router) -app.include_router(env_router) -app.include_router(agents_router) -app.include_router(terminal_router) -app.include_router(github_router) -app.include_router(gitlab_router) -app.include_router(roadmap_router) -app.include_router(ideation_router) -app.include_router(insights_router) -app.include_router(changelog_router) -app.include_router(context_router) +# --------------------------------------------------------------------------- +# Health endpoint is public (no auth required) +fastapi_app.include_router(health_router) + +# All other routes require authentication +_auth_dep = [Depends(require_auth)] + +fastapi_app.include_router(projects_router, dependencies=_auth_dep) +fastapi_app.include_router(tasks_router, dependencies=_auth_dep) +fastapi_app.include_router(settings_router, dependencies=_auth_dep) +fastapi_app.include_router(env_router, dependencies=_auth_dep) +fastapi_app.include_router(agents_router, dependencies=_auth_dep) +fastapi_app.include_router(terminal_router, dependencies=_auth_dep) +fastapi_app.include_router(github_router, dependencies=_auth_dep) +fastapi_app.include_router(gitlab_router, dependencies=_auth_dep) +fastapi_app.include_router(roadmap_router, dependencies=_auth_dep) +fastapi_app.include_router(ideation_router, dependencies=_auth_dep) +fastapi_app.include_router(insights_router, dependencies=_auth_dep) +fastapi_app.include_router(changelog_router, dependencies=_auth_dep) +fastapi_app.include_router(context_router, dependencies=_auth_dep) + +# --------------------------------------------------------------------------- # Mount Socket.IO as ASGI sub-application -sio_asgi_app = socketio.ASGIApp(sio, other_asgi_app=app) +# --------------------------------------------------------------------------- +# Export as ``app`` so ``uvicorn api.main:app`` works out of the box. +app = socketio.ASGIApp(sio, other_asgi_app=fastapi_app) diff --git a/apps/backend/api/routes/agents.py b/apps/backend/api/routes/agents.py index 3f133fd8..b3813622 100644 --- a/apps/backend/api/routes/agents.py +++ b/apps/backend/api/routes/agents.py @@ -13,7 +13,8 @@ from typing import Any from fastapi import APIRouter, HTTPException from pydantic import BaseModel -from .tasks import _find_project, _read_json, _specs_dir +from ..shared import _AUTO_CLAUDE_DIRS, _find_project +from .tasks import _read_json, _specs_dir router = APIRouter(prefix="/api", tags=["agents"]) diff --git a/apps/backend/api/routes/changelog.py b/apps/backend/api/routes/changelog.py index 30f19b7f..b8d41058 100644 --- a/apps/backend/api/routes/changelog.py +++ b/apps/backend/api/routes/changelog.py @@ -14,7 +14,7 @@ from typing import Any from fastapi import APIRouter, HTTPException from pydantic import BaseModel -from .projects import _load_store +from ..shared import _find_project router = APIRouter(prefix="/api/projects", tags=["changelog"]) @@ -38,20 +38,6 @@ class ChangelogGenerateRequest(BaseModel): thinkingLevel: str | None = None -# --------------------------------------------------------------------------- -# Helpers -# --------------------------------------------------------------------------- - - -def _find_project(project_id: str) -> dict[str, Any]: - """Look up a project by ID from the store.""" - store = _load_store() - for project in store.get("projects", []): - if project["id"] == project_id: - return project - raise HTTPException(status_code=404, detail="Project not found") - - # --------------------------------------------------------------------------- # Routes # --------------------------------------------------------------------------- diff --git a/apps/backend/api/routes/context.py b/apps/backend/api/routes/context.py index 180b7889..135244eb 100644 --- a/apps/backend/api/routes/context.py +++ b/apps/backend/api/routes/context.py @@ -14,7 +14,7 @@ from typing import Any from fastapi import APIRouter, HTTPException -from .projects import _load_store +from ..shared import _AUTO_CLAUDE_DIRS, _find_project, parse_env_file router = APIRouter(prefix="/api/projects", tags=["context"]) @@ -22,7 +22,6 @@ router = APIRouter(prefix="/api/projects", tags=["context"]) # Constants # --------------------------------------------------------------------------- -_AUTO_CLAUDE_DIRS = (".auto-claude", "auto-claude") _CONTEXT_DIR = "context" _MEMORIES_FILE = "memories.json" _PROJECT_INDEX_FILE = "project_index.json" @@ -34,15 +33,6 @@ _ENV_FILE = ".env" # --------------------------------------------------------------------------- -def _find_project(project_id: str) -> dict[str, Any]: - """Look up a project by ID from the store.""" - store = _load_store() - for project in store.get("projects", []): - if project["id"] == project_id: - return project - raise HTTPException(status_code=404, detail="Project not found") - - def _auto_claude_dir(project: dict[str, Any]) -> Path: """Return the .auto-claude directory for a project.""" return Path(project["path"]) / project.get("autoBuildPath", _AUTO_CLAUDE_DIRS[0]) @@ -59,18 +49,9 @@ async def get_context(project_id: str) -> dict[str, Any]: project = _find_project(project_id) ac_dir = _auto_claude_dir(project) - # Read .env file if present - env_config: dict[str, str] = {} + # Read .env file if present using shared parser env_path = ac_dir / _ENV_FILE - if env_path.exists(): - try: - for line in env_path.read_text(encoding="utf-8").splitlines(): - line = line.strip() - if line and not line.startswith("#") and "=" in line: - key, _, value = line.partition("=") - env_config[key.strip()] = value.strip() - except OSError: - pass + env_config = parse_env_file(env_path) # Check for Graphiti/memory configuration graphiti_enabled = env_config.get("GRAPHITI_ENABLED", "").lower() == "true" diff --git a/apps/backend/api/routes/env.py b/apps/backend/api/routes/env.py index 0e9b0c55..930508bb 100644 --- a/apps/backend/api/routes/env.py +++ b/apps/backend/api/routes/env.py @@ -8,35 +8,30 @@ Mirrors the data contract from the Electron IPC handlers (env-handlers.ts). from __future__ import annotations -import json from pathlib import Path from typing import Any from fastapi import APIRouter, HTTPException from pydantic import BaseModel +from ..shared import _AUTO_CLAUDE_DIRS, _find_project, parse_env_file, update_env_file + router = APIRouter(prefix="/api/projects", tags=["environment"]) # --------------------------------------------------------------------------- # Helpers # --------------------------------------------------------------------------- -_STORE_DIR = Path.home() / ".auto-claude-web" -_STORE_PATH = _STORE_DIR / "projects.json" -_AUTO_CLAUDE_DIRS = (".auto-claude", "auto-claude") - - -def _find_project(project_id: str) -> dict[str, Any]: - """Look up a project by ID from the store.""" - if _STORE_PATH.exists(): - try: - store = json.loads(_STORE_PATH.read_text(encoding="utf-8")) - for project in store.get("projects", []): - if project["id"] == project_id: - return project - except (json.JSONDecodeError, OSError): - pass - raise HTTPException(status_code=404, detail="Project not found") +# Keys whose values contain sensitive tokens and should be masked in responses +_SENSITIVE_KEYS = frozenset( + { + "CLAUDE_CODE_OAUTH_TOKEN", + "GITHUB_TOKEN", + "OPENAI_API_KEY", + "GITLAB_TOKEN", + "LINEAR_API_KEY", + } +) def _env_path(project: dict[str, Any]) -> Path: @@ -54,85 +49,71 @@ def _env_path(project: dict[str, Any]) -> Path: return Path(project_path) / ".auto-claude" / ".env" -def _parse_env_file(content: str) -> dict[str, str]: - """Parse a .env file into a key-value dict (ignores comments/blanks).""" - result: dict[str, str] = {} - for line in content.splitlines(): - line = line.strip() - if not line or line.startswith("#"): - continue - if "=" in line: - key, _, value = line.partition("=") - result[key.strip()] = value.strip() - return result +def _mask_value(key: str, value: str) -> str: + """Mask sensitive token values — show only the last 4 characters.""" + env_key = _CONFIG_TO_ENV.get(key, "") + if env_key in _SENSITIVE_KEYS and len(value) > 4: + return "*" * (len(value) - 4) + value[-4:] + return value -def _env_to_config(env_vars: dict[str, str]) -> dict[str, Any]: +def _env_to_config(env_vars: dict[str, str], *, mask: bool = False) -> dict[str, Any]: """Map .env variables to a ProjectEnvConfig-style dict.""" config: dict[str, Any] = {} - _map = { - "CLAUDE_CODE_OAUTH_TOKEN": "claudeOAuthToken", - "AUTO_BUILD_MODEL": "autoBuildModel", - "LINEAR_API_KEY": "linearApiKey", - "LINEAR_TEAM_ID": "linearTeamId", - "LINEAR_PROJECT_ID": "linearProjectId", - "GITHUB_TOKEN": "githubToken", - "GITHUB_REPO": "githubRepo", - "DEFAULT_BRANCH": "defaultBranch", - "OPENAI_API_KEY": "openaiApiKey", - "GITLAB_TOKEN": "gitlabToken", - "GITLAB_INSTANCE_URL": "gitlabInstanceUrl", - "GITLAB_PROJECT": "gitlabProject", - } - for env_key, config_key in _map.items(): + for env_key, config_key in _ENV_TO_CONFIG.items(): if env_key in env_vars: - config[config_key] = env_vars[env_key] + value = env_vars[env_key] + if mask and env_key in _SENSITIVE_KEYS and len(value) > 4: + value = "*" * (len(value) - 4) + value[-4:] + config[config_key] = value # Boolean fields - _bool_map = { - "LINEAR_REALTIME_SYNC": "linearRealtimeSync", - "GITHUB_AUTO_SYNC": "githubAutoSync", - "GRAPHITI_ENABLED": "graphitiEnabled", - "GITLAB_ENABLED": "gitlabEnabled", - "GITLAB_AUTO_SYNC": "gitlabAutoSync", - "ENABLE_FANCY_UI": "enableFancyUi", - } - for env_key, config_key in _bool_map.items(): + for env_key, config_key in _ENV_TO_CONFIG_BOOL.items(): if env_key in env_vars: config[config_key] = env_vars[env_key].lower() == "true" return config +# String field mappings: ENV_KEY -> configKey +_ENV_TO_CONFIG: dict[str, str] = { + "CLAUDE_CODE_OAUTH_TOKEN": "claudeOAuthToken", + "AUTO_BUILD_MODEL": "autoBuildModel", + "LINEAR_API_KEY": "linearApiKey", + "LINEAR_TEAM_ID": "linearTeamId", + "LINEAR_PROJECT_ID": "linearProjectId", + "GITHUB_TOKEN": "githubToken", + "GITHUB_REPO": "githubRepo", + "DEFAULT_BRANCH": "defaultBranch", + "OPENAI_API_KEY": "openaiApiKey", + "GITLAB_TOKEN": "gitlabToken", + "GITLAB_INSTANCE_URL": "gitlabInstanceUrl", + "GITLAB_PROJECT": "gitlabProject", +} + +# Reverse mapping: configKey -> ENV_KEY +_CONFIG_TO_ENV: dict[str, str] = {v: k for k, v in _ENV_TO_CONFIG.items()} + +# Boolean field mappings +_ENV_TO_CONFIG_BOOL: dict[str, str] = { + "LINEAR_REALTIME_SYNC": "linearRealtimeSync", + "GITHUB_AUTO_SYNC": "githubAutoSync", + "GRAPHITI_ENABLED": "graphitiEnabled", + "GITLAB_ENABLED": "gitlabEnabled", + "GITLAB_AUTO_SYNC": "gitlabAutoSync", + "ENABLE_FANCY_UI": "enableFancyUi", +} + +_CONFIG_BOOL_TO_ENV: dict[str, str] = {v: k for k, v in _ENV_TO_CONFIG_BOOL.items()} + + def _config_to_env_lines(config: dict[str, Any]) -> dict[str, str]: """Map a ProjectEnvConfig-style dict back to env key-value pairs.""" env_vars: dict[str, str] = {} - _map = { - "claudeOAuthToken": "CLAUDE_CODE_OAUTH_TOKEN", - "autoBuildModel": "AUTO_BUILD_MODEL", - "linearApiKey": "LINEAR_API_KEY", - "linearTeamId": "LINEAR_TEAM_ID", - "linearProjectId": "LINEAR_PROJECT_ID", - "githubToken": "GITHUB_TOKEN", - "githubRepo": "GITHUB_REPO", - "defaultBranch": "DEFAULT_BRANCH", - "openaiApiKey": "OPENAI_API_KEY", - "gitlabToken": "GITLAB_TOKEN", - "gitlabInstanceUrl": "GITLAB_INSTANCE_URL", - "gitlabProject": "GITLAB_PROJECT", - } - _bool_map = { - "linearRealtimeSync": "LINEAR_REALTIME_SYNC", - "githubAutoSync": "GITHUB_AUTO_SYNC", - "graphitiEnabled": "GRAPHITI_ENABLED", - "gitlabEnabled": "GITLAB_ENABLED", - "gitlabAutoSync": "GITLAB_AUTO_SYNC", - "enableFancyUi": "ENABLE_FANCY_UI", - } - for config_key, env_key in _map.items(): + for config_key, env_key in _CONFIG_TO_ENV.items(): if config_key in config and config[config_key] is not None: env_vars[env_key] = str(config[config_key]) - for config_key, env_key in _bool_map.items(): + for config_key, env_key in _CONFIG_BOOL_TO_ENV.items(): if config_key in config and config[config_key] is not None: env_vars[env_key] = "true" if config[config_key] else "false" return env_vars @@ -144,7 +125,7 @@ def _config_to_env_lines(config: dict[str, Any]) -> dict[str, str]: class EnvConfigUpdate(BaseModel): - """Partial env config update — all fields optional.""" + """Partial env config update -- all fields optional.""" model_config = {"extra": "allow"} @@ -156,7 +137,10 @@ class EnvConfigUpdate(BaseModel): @router.get("/{project_id}/env") async def get_env(project_id: str) -> dict[str, Any]: - """Read a project's environment configuration from its .env file.""" + """Read a project's environment configuration from its .env file. + + Sensitive token values are masked (only last 4 characters shown). + """ project = _find_project(project_id) env_file = _env_path(project) @@ -164,9 +148,8 @@ async def get_env(project_id: str) -> dict[str, Any]: return {"success": True, "data": {}} try: - content = env_file.read_text(encoding="utf-8") - env_vars = _parse_env_file(content) - config = _env_to_config(env_vars) + env_vars = parse_env_file(env_file) + config = _env_to_config(env_vars, mask=True) return {"success": True, "data": config} except OSError as exc: raise HTTPException(status_code=500, detail=f"Failed to read .env: {exc}") @@ -174,29 +157,20 @@ async def get_env(project_id: str) -> dict[str, Any]: @router.put("/{project_id}/env") async def update_env(project_id: str, body: EnvConfigUpdate) -> dict[str, Any]: - """Save a project's environment configuration to its .env file.""" + """Save a project's environment configuration to its .env file. + + Preserves comments and formatting of untouched lines. + """ project = _find_project(project_id) env_file = _env_path(project) - # Load existing env vars - existing_vars: dict[str, str] = {} - if env_file.exists(): - try: - existing_vars = _parse_env_file(env_file.read_text(encoding="utf-8")) - except OSError: - pass - - # Merge new values + # Convert config keys back to env variable names new_vars = _config_to_env_lines(body.model_dump(exclude_unset=True)) - existing_vars.update(new_vars) - # Write back - try: - env_file.parent.mkdir(parents=True, exist_ok=True) - lines = [f"{k}={v}" for k, v in sorted(existing_vars.items())] - env_file.write_text("\n".join(lines) + "\n", encoding="utf-8") - except OSError as exc: - raise HTTPException(status_code=500, detail=f"Failed to write .env: {exc}") + # Update env file preserving existing comments and formatting + update_env_file(env_file, new_vars) - config = _env_to_config(existing_vars) + # Read back the full config for the response (masked) + env_vars = parse_env_file(env_file) + config = _env_to_config(env_vars, mask=True) return {"success": True, "data": config} diff --git a/apps/backend/api/routes/github.py b/apps/backend/api/routes/github.py index 4ef79ecf..633062b6 100644 --- a/apps/backend/api/routes/github.py +++ b/apps/backend/api/routes/github.py @@ -8,14 +8,14 @@ the Electron IPC handlers (github/ subdirectory). from __future__ import annotations -import json -from pathlib import Path from typing import Any import httpx from fastapi import APIRouter, HTTPException, Query from pydantic import BaseModel +from ..shared import _find_project, find_env_file, parse_env_file + router = APIRouter(prefix="/api/github", tags=["github"]) # --------------------------------------------------------------------------- @@ -52,43 +52,16 @@ class AutoFixRequest(BaseModel): # Helpers # --------------------------------------------------------------------------- -_STORE_DIR = Path.home() / ".auto-claude-web" -_STORE_PATH = _STORE_DIR / "projects.json" -_AUTO_CLAUDE_DIRS = (".auto-claude", "auto-claude") - - -def _find_project(project_id: str) -> dict[str, Any]: - """Look up a project by ID from the store.""" - if _STORE_PATH.exists(): - data = json.loads(_STORE_PATH.read_text()) - for p in data.get("projects", []): - if p.get("id") == project_id: - return p - raise HTTPException(status_code=404, detail=f"Project {project_id} not found") - def _get_github_config(project_id: str) -> dict[str, str]: """Read GitHub token and repo from the project's .env file.""" project = _find_project(project_id) - project_path = Path(project["path"]) - - env_file: Path | None = None - for d in _AUTO_CLAUDE_DIRS: - candidate = project_path / d / ".env" - if candidate.exists(): - env_file = candidate - break + env_file = find_env_file(project) if env_file is None: raise HTTPException(status_code=400, detail="No .env file found for project") - env_vars: dict[str, str] = {} - for line in env_file.read_text().splitlines(): - line = line.strip() - if not line or line.startswith("#") or "=" not in line: - continue - key, _, value = line.partition("=") - env_vars[key.strip()] = value.strip().strip("'\"") + env_vars = parse_env_file(env_file) token = env_vars.get("GITHUB_TOKEN", "") repo = env_vars.get("GITHUB_REPO", "") @@ -304,7 +277,7 @@ async def get_pull( # --------------------------------------------------------------------------- -# AI-powered actions (stubs — actual agent integration is a separate subtask) +# AI-powered actions (stubs -- actual agent integration is a separate subtask) # --------------------------------------------------------------------------- diff --git a/apps/backend/api/routes/gitlab.py b/apps/backend/api/routes/gitlab.py index c0d67e93..fc0250dc 100644 --- a/apps/backend/api/routes/gitlab.py +++ b/apps/backend/api/routes/gitlab.py @@ -8,15 +8,18 @@ the Electron IPC handlers (gitlab/ subdirectory). from __future__ import annotations -import json +import ipaddress +import socket from pathlib import Path from typing import Any -from urllib.parse import quote +from urllib.parse import quote, urlparse import httpx from fastapi import APIRouter, HTTPException, Query from pydantic import BaseModel +from ..shared import _AUTO_CLAUDE_DIRS, _find_project, find_env_file, parse_env_file + router = APIRouter(prefix="/api/gitlab", tags=["gitlab"]) # --------------------------------------------------------------------------- @@ -53,43 +56,55 @@ class AutoFixRequest(BaseModel): # Helpers # --------------------------------------------------------------------------- -_STORE_DIR = Path.home() / ".auto-claude-web" -_STORE_PATH = _STORE_DIR / "projects.json" -_AUTO_CLAUDE_DIRS = (".auto-claude", "auto-claude") +def _validate_instance_url(url: str) -> str: + """Validate that the GitLab instance URL is safe (HTTPS, no private IPs). -def _find_project(project_id: str) -> dict[str, Any]: - """Look up a project by ID from the store.""" - if _STORE_PATH.exists(): - data = json.loads(_STORE_PATH.read_text()) - for p in data.get("projects", []): - if p.get("id") == project_id: - return p - raise HTTPException(status_code=404, detail=f"Project {project_id} not found") + Returns the validated URL. + Raises ``HTTPException`` if the URL is invalid or unsafe. + """ + try: + parsed = urlparse(url) + except ValueError: + raise HTTPException(status_code=400, detail="Invalid GitLab instance URL") + + # Must be HTTPS + if parsed.scheme != "https": + raise HTTPException( + status_code=400, detail="GitLab instance URL must use HTTPS" + ) + + hostname = parsed.hostname + if not hostname: + raise HTTPException(status_code=400, detail="Invalid GitLab instance URL") + + # Resolve hostname and block private/internal IP ranges to prevent SSRF + try: + addr_infos = socket.getaddrinfo(hostname, None, socket.AF_UNSPEC) + for _family, _type, _proto, _canonname, sockaddr in addr_infos: + ip = ipaddress.ip_address(sockaddr[0]) + if ip.is_private or ip.is_loopback or ip.is_reserved or ip.is_link_local: + raise HTTPException( + status_code=400, + detail="GitLab instance URL resolves to a private/internal IP address", + ) + except socket.gaierror: + raise HTTPException( + status_code=400, detail="Cannot resolve GitLab instance URL hostname" + ) + + return url.rstrip("/") def _get_gitlab_config(project_id: str) -> dict[str, str]: """Read GitLab token, instance URL, and project from the project's .env file.""" project = _find_project(project_id) - project_path = Path(project["path"]) - - env_file: Path | None = None - for d in _AUTO_CLAUDE_DIRS: - candidate = project_path / d / ".env" - if candidate.exists(): - env_file = candidate - break + env_file = find_env_file(project) if env_file is None: raise HTTPException(status_code=400, detail="No .env file found for project") - env_vars: dict[str, str] = {} - for line in env_file.read_text().splitlines(): - line = line.strip() - if not line or line.startswith("#") or "=" not in line: - continue - key, _, value = line.partition("=") - env_vars[key.strip()] = value.strip().strip("'\"") + env_vars = parse_env_file(env_file) token = env_vars.get("GITLAB_TOKEN", "") instance_url = env_vars.get("GITLAB_INSTANCE_URL", "https://gitlab.com") @@ -100,9 +115,12 @@ def _get_gitlab_config(project_id: str) -> dict[str, str]: if not gitlab_project: raise HTTPException(status_code=400, detail="GITLAB_PROJECT not configured") + # Validate instance URL to prevent SSRF + validated_url = _validate_instance_url(instance_url) + return { "token": token, - "instance_url": instance_url.rstrip("/"), + "instance_url": validated_url, "project": gitlab_project, } @@ -112,7 +130,7 @@ def _gitlab_headers(token: str) -> dict[str, str]: def _encode_project(project_path: str) -> str: - """URL-encode the project path for GitLab API (e.g. 'group/project' → 'group%2Fproject').""" + """URL-encode the project path for GitLab API (e.g. 'group/project' -> 'group%2Fproject').""" return quote(project_path, safe="") @@ -310,7 +328,7 @@ async def get_merge_request( # --------------------------------------------------------------------------- -# AI-powered actions (stubs — actual agent integration is a separate subtask) +# AI-powered actions (stubs -- actual agent integration is a separate subtask) # --------------------------------------------------------------------------- diff --git a/apps/backend/api/routes/ideation.py b/apps/backend/api/routes/ideation.py index 1c7b4950..a73ee879 100644 --- a/apps/backend/api/routes/ideation.py +++ b/apps/backend/api/routes/ideation.py @@ -15,7 +15,7 @@ from typing import Any from fastapi import APIRouter, HTTPException from pydantic import BaseModel -from .projects import _load_store +from ..shared import _AUTO_CLAUDE_DIRS, _find_project router = APIRouter(prefix="/api/projects", tags=["ideation"]) @@ -23,7 +23,6 @@ router = APIRouter(prefix="/api/projects", tags=["ideation"]) # Constants # --------------------------------------------------------------------------- -_AUTO_CLAUDE_DIRS = (".auto-claude", "auto-claude") _IDEATION_DIR = "ideation" _SESSION_FILE = "session.json" @@ -44,15 +43,6 @@ class IdeationGenerateRequest(BaseModel): # --------------------------------------------------------------------------- -def _find_project(project_id: str) -> dict[str, Any]: - """Look up a project by ID from the store.""" - store = _load_store() - for project in store.get("projects", []): - if project["id"] == project_id: - return project - raise HTTPException(status_code=404, detail="Project not found") - - def _ideation_dir(project: dict[str, Any]) -> Path: """Return the ideation directory for a project.""" return ( diff --git a/apps/backend/api/routes/insights.py b/apps/backend/api/routes/insights.py index 5690e740..7fa655d5 100644 --- a/apps/backend/api/routes/insights.py +++ b/apps/backend/api/routes/insights.py @@ -18,7 +18,7 @@ from fastapi import APIRouter, HTTPException from fastapi.responses import StreamingResponse from pydantic import BaseModel -from .projects import _load_store +from ..shared import _AUTO_CLAUDE_DIRS, _find_project router = APIRouter(prefix="/api/projects", tags=["insights"]) @@ -26,7 +26,6 @@ router = APIRouter(prefix="/api/projects", tags=["insights"]) # Constants # --------------------------------------------------------------------------- -_AUTO_CLAUDE_DIRS = (".auto-claude", "auto-claude") _INSIGHTS_DIR = "insights" _SESSION_FILE = "session.json" @@ -47,15 +46,6 @@ class InsightsQueryRequest(BaseModel): # --------------------------------------------------------------------------- -def _find_project(project_id: str) -> dict[str, Any]: - """Look up a project by ID from the store.""" - store = _load_store() - for project in store.get("projects", []): - if project["id"] == project_id: - return project - raise HTTPException(status_code=404, detail="Project not found") - - def _insights_dir(project: dict[str, Any]) -> Path: """Return the insights directory for a project.""" return ( diff --git a/apps/backend/api/routes/projects.py b/apps/backend/api/routes/projects.py index 40f424b6..3b5992d7 100644 --- a/apps/backend/api/routes/projects.py +++ b/apps/backend/api/routes/projects.py @@ -8,16 +8,20 @@ the Electron IPC handlers (project-handlers.ts / project-store.ts). from __future__ import annotations -import json import os import uuid -from datetime import datetime, timezone -from pathlib import Path from typing import Any from fastapi import APIRouter, HTTPException from pydantic import BaseModel, Field +from ..shared import ( + _AUTO_CLAUDE_DIRS, + _load_store, + _now_iso, + _save_store, +) + router = APIRouter(prefix="/api/projects", tags=["projects"]) # --------------------------------------------------------------------------- @@ -61,29 +65,9 @@ class AddProjectRequest(BaseModel): # --------------------------------------------------------------------------- -# Store — JSON-file-backed project list +# Helpers # --------------------------------------------------------------------------- -_AUTO_CLAUDE_DIRS = (".auto-claude", "auto-claude") -_STORE_DIR = Path.home() / ".auto-claude-web" -_STORE_PATH = _STORE_DIR / "projects.json" - - -def _load_store() -> dict[str, Any]: - """Load the projects store from disk.""" - if _STORE_PATH.exists(): - try: - return json.loads(_STORE_PATH.read_text(encoding="utf-8")) - except (json.JSONDecodeError, OSError): - pass - return {"projects": [], "settings": {}} - - -def _save_store(data: dict[str, Any]) -> None: - """Persist the projects store to disk.""" - _STORE_DIR.mkdir(parents=True, exist_ok=True) - _STORE_PATH.write_text(json.dumps(data, indent=2, default=str), encoding="utf-8") - def _get_auto_build_path(project_path: str) -> str: """Detect the .auto-claude directory inside a project, if any.""" @@ -94,10 +78,6 @@ def _get_auto_build_path(project_path: str) -> str: return "" -def _now_iso() -> str: - return datetime.now(timezone.utc).isoformat() - - # --------------------------------------------------------------------------- # Routes # --------------------------------------------------------------------------- diff --git a/apps/backend/api/routes/roadmap.py b/apps/backend/api/routes/roadmap.py index b6b6296e..40ac00a2 100644 --- a/apps/backend/api/routes/roadmap.py +++ b/apps/backend/api/routes/roadmap.py @@ -15,7 +15,7 @@ from typing import Any from fastapi import APIRouter, HTTPException from pydantic import BaseModel -from .projects import _load_store +from ..shared import _AUTO_CLAUDE_DIRS, _find_project router = APIRouter(prefix="/api/projects", tags=["roadmap"]) @@ -23,7 +23,6 @@ router = APIRouter(prefix="/api/projects", tags=["roadmap"]) # Constants # --------------------------------------------------------------------------- -_AUTO_CLAUDE_DIRS = (".auto-claude", "auto-claude") _ROADMAP_DIR = "roadmap" _ROADMAP_FILE = "roadmap.json" _COMPETITOR_ANALYSIS_FILE = "competitor_analysis.json" @@ -45,15 +44,6 @@ class RoadmapGenerateRequest(BaseModel): # --------------------------------------------------------------------------- -def _find_project(project_id: str) -> dict[str, Any]: - """Look up a project by ID from the store.""" - store = _load_store() - for project in store.get("projects", []): - if project["id"] == project_id: - return project - raise HTTPException(status_code=404, detail="Project not found") - - def _roadmap_dir(project: dict[str, Any]) -> Path: """Return the roadmap directory for a project.""" return ( diff --git a/apps/backend/api/routes/settings.py b/apps/backend/api/routes/settings.py index dd7e38a5..3fd415b8 100644 --- a/apps/backend/api/routes/settings.py +++ b/apps/backend/api/routes/settings.py @@ -15,13 +15,14 @@ from typing import Any from fastapi import APIRouter from pydantic import BaseModel +from ..shared import _STORE_DIR, _write_atomic + router = APIRouter(prefix="/api/settings", tags=["settings"]) # --------------------------------------------------------------------------- -# Store — JSON-file-backed app settings +# Store -- JSON-file-backed app settings # --------------------------------------------------------------------------- -_STORE_DIR = Path.home() / ".auto-claude-web" _SETTINGS_PATH = _STORE_DIR / "settings.json" # Default application settings (mirrors DEFAULT_APP_SETTINGS from the Electron app) @@ -56,10 +57,10 @@ def _load_settings() -> dict[str, Any]: def _save_settings(settings: dict[str, Any]) -> None: - """Persist app settings to disk.""" + """Persist app settings to disk atomically.""" _STORE_DIR.mkdir(parents=True, exist_ok=True) - _SETTINGS_PATH.write_text( - json.dumps(settings, indent=2, default=str), encoding="utf-8" + _write_atomic( + _SETTINGS_PATH, json.dumps(settings, indent=2, default=str) ) @@ -69,7 +70,7 @@ def _save_settings(settings: dict[str, Any]) -> None: class SettingsUpdate(BaseModel): - """Partial settings update — all fields optional.""" + """Partial settings update -- all fields optional.""" model_config = {"extra": "allow"} diff --git a/apps/backend/api/routes/tasks.py b/apps/backend/api/routes/tasks.py index ae322e89..0f69e3ef 100644 --- a/apps/backend/api/routes/tasks.py +++ b/apps/backend/api/routes/tasks.py @@ -12,14 +12,19 @@ import json import os import re import shutil -from datetime import datetime, timezone from pathlib import Path from typing import Any from fastapi import APIRouter, HTTPException from pydantic import BaseModel -from .projects import _load_store +from ..shared import ( + _AUTO_CLAUDE_DIRS, + _find_project, + _load_store, + _now_iso, + _write_atomic, +) router = APIRouter(prefix="/api", tags=["tasks"]) @@ -27,7 +32,6 @@ router = APIRouter(prefix="/api", tags=["tasks"]) # Constants # --------------------------------------------------------------------------- -_AUTO_CLAUDE_DIRS = (".auto-claude", "auto-claude") _IMPLEMENTATION_PLAN = "implementation_plan.json" _REQUIREMENTS = "requirements.json" _TASK_METADATA = "task_metadata.json" @@ -63,19 +67,6 @@ class UpdateStatusRequest(BaseModel): # --------------------------------------------------------------------------- -def _now_iso() -> str: - return datetime.now(timezone.utc).isoformat() - - -def _find_project(project_id: str) -> dict[str, Any]: - """Look up a project by ID from the store.""" - store = _load_store() - for project in store.get("projects", []): - if project["id"] == project_id: - return project - raise HTTPException(status_code=404, detail="Project not found") - - def _specs_dir(project: dict[str, Any]) -> Path: """Return the specs directory for a project.""" project_path = project["path"] @@ -112,6 +103,31 @@ def _read_json(filepath: Path) -> dict[str, Any] | None: return None +def _validate_task_id(task_id: str, specs_path: Path) -> Path: + """Validate that *task_id* does not escape the specs directory. + + Returns the resolved spec folder path. + Raises HTTP 403 on path traversal attempts. + """ + spec_folder = specs_path / task_id + try: + resolved = spec_folder.resolve() + if not resolved.is_relative_to(specs_path.resolve()): + raise HTTPException( + status_code=403, detail="Invalid task ID: path traversal detected" + ) + except (ValueError, OSError): + raise HTTPException( + status_code=403, detail="Invalid task ID: path traversal detected" + ) + return spec_folder + + +def _write_json(filepath: Path, data: Any) -> None: + """Write JSON data atomically.""" + _write_atomic(filepath, json.dumps(data, indent=2, ensure_ascii=False)) + + def _build_task( spec_folder: Path, project_id: str, @@ -244,7 +260,7 @@ async def create_task(project_id: str, body: CreateTaskRequest) -> dict[str, Any now = _now_iso() - # Create implementation_plan.json + # Create implementation_plan.json (atomic write) plan = { "feature": title, "description": body.description, @@ -253,24 +269,18 @@ async def create_task(project_id: str, body: CreateTaskRequest) -> dict[str, Any "status": "pending", "phases": [], } - (spec_dir / _IMPLEMENTATION_PLAN).write_text( - json.dumps(plan, indent=2), encoding="utf-8" - ) + _write_json(spec_dir / _IMPLEMENTATION_PLAN, plan) - # Create requirements.json + # Create requirements.json (atomic write) meta = body.metadata or TaskMetadata() requirements = { "task_description": body.description, "workflow_type": meta.category or "feature", } - (spec_dir / _REQUIREMENTS).write_text( - json.dumps(requirements, indent=2), encoding="utf-8" - ) + _write_json(spec_dir / _REQUIREMENTS, requirements) - # Save task metadata - (spec_dir / _TASK_METADATA).write_text( - json.dumps(meta.model_dump(), indent=2), encoding="utf-8" - ) + # Save task metadata (atomic write) + _write_json(spec_dir / _TASK_METADATA, meta.model_dump()) task = { "id": spec_id, @@ -297,7 +307,7 @@ async def get_task(task_id: str, project_id: str) -> dict[str, Any]: """ project = _find_project(project_id) specs_path = _specs_dir(project) - spec_folder = specs_path / task_id + spec_folder = _validate_task_id(task_id, specs_path) if not spec_folder.exists() or not spec_folder.is_dir(): raise HTTPException(status_code=404, detail="Task not found") @@ -316,7 +326,7 @@ async def update_task_status( """ project = _find_project(project_id) specs_path = _specs_dir(project) - spec_folder = specs_path / task_id + spec_folder = _validate_task_id(task_id, specs_path) if not spec_folder.exists() or not spec_folder.is_dir(): raise HTTPException(status_code=404, detail="Task not found") @@ -336,7 +346,7 @@ async def update_task_status( plan["status"] = reverse_status_map.get(body.status, body.status) plan["updated_at"] = _now_iso() - plan_path.write_text(json.dumps(plan, indent=2), encoding="utf-8") + _write_json(plan_path, plan) task = _build_task(spec_folder, project_id) return {"success": True, "data": task} @@ -350,7 +360,7 @@ async def delete_task(task_id: str, project_id: str) -> dict[str, Any]: """ project = _find_project(project_id) specs_path = _specs_dir(project) - spec_folder = specs_path / task_id + spec_folder = _validate_task_id(task_id, specs_path) if not spec_folder.exists() or not spec_folder.is_dir(): raise HTTPException(status_code=404, detail="Task not found") diff --git a/apps/backend/api/services/terminal_service.py b/apps/backend/api/services/terminal_service.py index e966ffa2..8a7a9476 100644 --- a/apps/backend/api/services/terminal_service.py +++ b/apps/backend/api/services/terminal_service.py @@ -5,24 +5,35 @@ Terminal Service Server-side PTY management: spawn, write, resize, and kill pseudo-terminal processes. Each terminal session gets its own PTY subprocess with proper lifecycle management and cleanup. + +Note: PTY functionality is only available on Unix systems (macOS, Linux). +On Windows, the service will raise ``RuntimeError`` when attempting to spawn. """ from __future__ import annotations import asyncio -import fcntl import logging import os -import pty -import select import signal import struct -import termios from collections.abc import Callable from typing import Any logger = logging.getLogger(__name__) +# --------------------------------------------------------------------------- +# Cross-platform guards for Unix-only modules +# --------------------------------------------------------------------------- + +_IS_UNIX = os.name != "nt" + +if _IS_UNIX: + import fcntl + import pty + import select + import termios + class _PtySession: """Represents a single PTY session with its master fd and child pid.""" @@ -76,6 +87,11 @@ class TerminalService: on_output: Callable[[str, bytes], Any] | None = None, ) -> str: """Spawn a new PTY process and return its session id.""" + if not _IS_UNIX: + raise RuntimeError( + "PTY terminal sessions are only supported on Unix systems (macOS, Linux)" + ) + if session_id in self._sessions: logger.warning( "Session %s already exists, killing old one first", session_id @@ -89,7 +105,7 @@ class TerminalService: child_pid, master_fd = pty.fork() if child_pid == 0: - # Child process — exec shell + # Child process -- exec shell os.chdir(work_dir) env = { **os.environ, @@ -137,7 +153,7 @@ class TerminalService: """Write data to a PTY session.""" session = self._get_session(session_id) loop = asyncio.get_event_loop() - await loop.run_in_executor(None, os.write, session.fd, data.encode()) + await loop.run_in_executor(None, os.write, session.fd, data.encode("utf-8")) async def resize(self, session_id: str, cols: int, rows: int) -> None: """Resize a PTY session.""" @@ -229,7 +245,11 @@ class TerminalService: data = await loop.run_in_executor( None, TerminalService._blocking_read, fd ) + if data is None: + # Timeout -- no data ready yet, keep looping + continue if not data: + # Real EOF -- process exited break result = on_output(session.session_id, data) if asyncio.iscoroutine(result): @@ -240,12 +260,18 @@ class TerminalService: break @staticmethod - def _blocking_read(fd: int) -> bytes: - """Blocking read from fd, suitable for run_in_executor.""" + def _blocking_read(fd: int) -> bytes | None: + """Blocking read from fd, suitable for run_in_executor. + + Returns: + ``bytes`` with data if available, + ``None`` on timeout (no data ready), + ``b""`` on real EOF or error. + """ try: readable, _, _ = select.select([fd], [], [], 0.1) if readable: return os.read(fd, 4096) - return b"" + return None # timeout -- not EOF except OSError: - return b"" + return b"" # real error / EOF diff --git a/apps/backend/api/shared.py b/apps/backend/api/shared.py new file mode 100644 index 00000000..f6124958 --- /dev/null +++ b/apps/backend/api/shared.py @@ -0,0 +1,195 @@ +""" +Shared Utilities for API Routes +================================ + +Common helpers used across multiple route modules: project lookup, +store persistence, env-file parsing, timestamps, and path constants. +""" + +from __future__ import annotations + +import json +import logging +import os +import secrets +import tempfile +from datetime import datetime, timezone +from pathlib import Path +from typing import Any + +from fastapi import HTTPException + +logger = logging.getLogger(__name__) + +# --------------------------------------------------------------------------- +# Constants +# --------------------------------------------------------------------------- + +_STORE_DIR = Path.home() / ".auto-claude-web" +_STORE_PATH = _STORE_DIR / "projects.json" +_AUTO_CLAUDE_DIRS = (".auto-claude", "auto-claude") + +# --------------------------------------------------------------------------- +# Authentication +# --------------------------------------------------------------------------- + +# Shared secret generated at startup for API authentication. +# Clients must send this as ``Authorization: Bearer `` on every request. +API_TOKEN: str = os.environ.get("AUTO_CLAUDE_API_TOKEN", "") or secrets.token_urlsafe(48) + +# --------------------------------------------------------------------------- +# Store helpers +# --------------------------------------------------------------------------- + + +def _load_store() -> dict[str, Any]: + """Load the projects store from disk.""" + if _STORE_PATH.exists(): + try: + return json.loads(_STORE_PATH.read_text(encoding="utf-8")) + except (json.JSONDecodeError, OSError): + pass + return {"projects": [], "settings": {}} + + +def _save_store(data: dict[str, Any]) -> None: + """Persist the projects store to disk atomically.""" + _STORE_DIR.mkdir(parents=True, exist_ok=True) + content = json.dumps(data, indent=2, default=str) + _write_atomic(_STORE_PATH, content) + + +def _write_atomic(filepath: Path, content: str) -> None: + """Write *content* to *filepath* atomically via temp-file + rename.""" + filepath.parent.mkdir(parents=True, exist_ok=True) + fd, tmp_path = tempfile.mkstemp( + dir=filepath.parent, prefix=f".{filepath.name}.tmp.", suffix="" + ) + try: + with os.fdopen(fd, "w", encoding="utf-8") as f: + f.write(content) + os.replace(tmp_path, filepath) + except Exception: + try: + os.unlink(tmp_path) + except OSError: + pass + raise + + +# --------------------------------------------------------------------------- +# Project lookup +# --------------------------------------------------------------------------- + + +def _find_project(project_id: str) -> dict[str, Any]: + """Look up a project by ID from the store. + + Raises :class:`~fastapi.HTTPException` (404) when the project is not + found. + """ + store = _load_store() + for project in store.get("projects", []): + if project.get("id") == project_id: + return project + raise HTTPException(status_code=404, detail="Project not found") + + +def _get_registered_project_paths() -> set[str]: + """Return the set of absolute project paths registered in the store.""" + store = _load_store() + return {p["path"] for p in store.get("projects", []) if "path" in p} + + +# --------------------------------------------------------------------------- +# Timestamps +# --------------------------------------------------------------------------- + + +def _now_iso() -> str: + """Return the current UTC time as an ISO-8601 string.""" + return datetime.now(timezone.utc).isoformat() + + +# --------------------------------------------------------------------------- +# .env file parsing +# --------------------------------------------------------------------------- + + +def parse_env_file(path: Path) -> dict[str, str]: + """Parse a ``.env`` file into a key-value dict. + + Ignores blank lines and comments (lines starting with ``#``). + Values may optionally be wrapped in single or double quotes. + """ + result: dict[str, str] = {} + if not path.exists(): + return result + try: + for line in path.read_text(encoding="utf-8").splitlines(): + line = line.strip() + if not line or line.startswith("#"): + continue + if "=" in line: + key, _, value = line.partition("=") + value = value.strip().strip("'\"") + result[key.strip()] = value + except OSError: + pass + return result + + +def find_env_file(project: dict[str, Any]) -> Path | None: + """Find the ``.env`` file inside a project's auto-claude directory. + + Returns ``None`` if no ``.env`` file exists. + """ + project_path = Path(project["path"]) + auto_build = project.get("autoBuildPath", "") + if auto_build: + candidate = project_path / auto_build / ".env" + if candidate.exists(): + return candidate + + for dirname in _AUTO_CLAUDE_DIRS: + candidate = project_path / dirname / ".env" + if candidate.exists(): + return candidate + + return None + + +def update_env_file(env_path: Path, updates: dict[str, str]) -> None: + """Update specific keys in a ``.env`` file while preserving comments and + formatting of untouched lines. + + New keys are appended at the end. + """ + lines: list[str] = [] + updated_keys: set[str] = set() + + if env_path.exists(): + try: + original = env_path.read_text(encoding="utf-8") + except OSError: + original = "" + for line in original.splitlines(): + stripped = line.strip() + if stripped and not stripped.startswith("#") and "=" in stripped: + key, _, _ = stripped.partition("=") + key = key.strip() + if key in updates: + lines.append(f"{key}={updates[key]}") + updated_keys.add(key) + else: + lines.append(line) + else: + lines.append(line) + + # Append any new keys that were not already present + for key, value in updates.items(): + if key not in updated_keys: + lines.append(f"{key}={value}") + + env_path.parent.mkdir(parents=True, exist_ok=True) + _write_atomic(env_path, "\n".join(lines) + "\n") diff --git a/apps/backend/api/websocket/agent_ns.py b/apps/backend/api/websocket/agent_ns.py index 42c8ae64..429d26e3 100644 --- a/apps/backend/api/websocket/agent_ns.py +++ b/apps/backend/api/websocket/agent_ns.py @@ -4,31 +4,34 @@ Agent WebSocket Namespace Socket.IO ``/agent`` namespace for real-time agent execution control. -Events (client → server): - agent:start — Start an agent build for a task - agent:stop — Stop a running agent - agent:join — Join a task room for progress updates - agent:leave — Leave a task room +Events (client -> server): + agent:start -- Start an agent build for a task + agent:stop -- Stop a running agent + agent:join -- Join a task room for progress updates + agent:leave -- Leave a task room -Events (server → client): - agent:progress — Phase / subtask progress updates - agent:log — Execution log lines - agent:complete — Task finished successfully - agent:error — Task failed +Events (server -> client): + agent:progress -- Phase / subtask progress updates + agent:log -- Execution log lines + agent:complete -- Task finished successfully + agent:error -- Task failed """ from __future__ import annotations import logging +from pathlib import Path from typing import Any import socketio +from ..auth import validate_socketio_token from ..services.agent_runner import AgentRunner +from ..shared import _get_registered_project_paths logger = logging.getLogger(__name__) -# Singleton runner — initialised by register_agent_namespace() +# Singleton runner -- initialised by register_agent_namespace() _agent_runner: AgentRunner | None = None @@ -36,11 +39,31 @@ def get_agent_runner() -> AgentRunner: """Return the global AgentRunner instance.""" if _agent_runner is None: raise RuntimeError( - "AgentRunner not initialised — call register_agent_namespace first" + "AgentRunner not initialised -- call register_agent_namespace first" ) return _agent_runner +def _validate_path_against_projects(path: str) -> bool: + """Check that *path* is within one of the registered project directories.""" + try: + resolved = Path(path).resolve() + except (ValueError, OSError): + return False + + for project_path in _get_registered_project_paths(): + try: + project_resolved = Path(project_path).resolve() + if resolved == project_resolved or resolved.is_relative_to( + project_resolved + ): + return True + except (ValueError, OSError): + continue + + return False + + class AgentNamespace(socketio.AsyncNamespace): """Socket.IO namespace for /agent.""" @@ -53,6 +76,9 @@ class AgentNamespace(socketio.AsyncNamespace): # ------------------------------------------------------------------ async def on_connect(self, sid: str, environ: dict[str, Any]) -> None: + if not validate_socketio_token(environ): + logger.warning("[AgentNS] Rejected unauthenticated client: %s", sid) + raise ConnectionRefusedError("Authentication required") logger.info("[AgentNS] Client connected: %s", sid) async def on_disconnect(self, sid: str) -> None: @@ -121,6 +147,17 @@ class AgentNamespace(socketio.AsyncNamespace): await self.emit("agent:error", {"taskId": task_id, "error": error}, to=sid) return {"ok": False, "error": error} + # Validate paths against registered projects + if not _validate_path_against_projects(project_dir): + error = "projectDir is not a registered project directory" + await self.emit("agent:error", {"taskId": task_id, "error": error}, to=sid) + return {"ok": False, "error": error} + + if not _validate_path_against_projects(spec_dir): + error = "specDir is not within a registered project directory" + await self.emit("agent:error", {"taskId": task_id, "error": error}, to=sid) + return {"ok": False, "error": error} + if self.runner.is_running(task_id): return {"ok": False, "error": "Task is already running"} diff --git a/apps/backend/api/websocket/events_ns.py b/apps/backend/api/websocket/events_ns.py index 0addc700..e2fd7296 100644 --- a/apps/backend/api/websocket/events_ns.py +++ b/apps/backend/api/websocket/events_ns.py @@ -4,17 +4,17 @@ Events WebSocket Namespace (/events) General-purpose event namespace for broadcasting application-wide events. -Client → Server +Client -> Server ---------------- -(none — this namespace is primarily server-push) +(none -- this namespace is primarily server-push) -Server → Client +Server -> Client ---------------- -- project:updated — project metadata changed -- task:statusChanged — task status transition -- settings:changed — user settings updated -- rateLimit:detected — API rate limit hit -- auth:failure — authentication failure detected +- project:updated -- project metadata changed +- task:statusChanged -- task status transition +- settings:changed -- user settings updated +- rateLimit:detected -- API rate limit hit +- auth:failure -- authentication failure detected """ import logging @@ -22,6 +22,8 @@ from typing import Any import socketio +from ..auth import validate_socketio_token + logger = logging.getLogger(__name__) @@ -36,6 +38,9 @@ class EventsNamespace(socketio.AsyncNamespace): # ------------------------------------------------------------------ async def on_connect(self, sid: str, environ: dict) -> None: + if not validate_socketio_token(environ): + logger.warning("Events: rejected unauthenticated client: %s", sid) + raise ConnectionRefusedError("Authentication required") logger.info("Events client connected: %s", sid) async def on_disconnect(self, sid: str) -> None: diff --git a/apps/backend/api/websocket/terminal_ns.py b/apps/backend/api/websocket/terminal_ns.py index 1bdd9aa9..4cd0a9d8 100644 --- a/apps/backend/api/websocket/terminal_ns.py +++ b/apps/backend/api/websocket/terminal_ns.py @@ -4,26 +4,29 @@ Terminal WebSocket Namespace Socket.IO ``/terminal`` namespace handling real-time terminal I/O. -Events (client → server): - terminal:create — Create a new PTY session - terminal:input — Write data to a PTY - terminal:resize — Resize a PTY window - terminal:close — Kill a PTY session +Events (client -> server): + terminal:create -- Create a new PTY session + terminal:input -- Write data to a PTY + terminal:resize -- Resize a PTY window + terminal:close -- Kill a PTY session -Events (server → client): - terminal:output — PTY output data - terminal:exit — PTY process exited - terminal:error — Error notification +Events (server -> client): + terminal:output -- PTY output data + terminal:exit -- PTY process exited + terminal:error -- Error notification """ from __future__ import annotations import logging +from pathlib import Path from typing import Any import socketio +from ..auth import validate_socketio_token from ..services.terminal_service import TerminalService +from ..shared import _get_registered_project_paths logger = logging.getLogger(__name__) @@ -36,21 +39,63 @@ def get_terminal_service() -> TerminalService: return _terminal_service +def _validate_cwd(cwd: str | None) -> str | None: + """Validate that *cwd* is under a registered project directory. + + Returns the validated path, or ``None`` if invalid. + """ + if cwd is None: + return None + + try: + resolved = Path(cwd).resolve() + except (ValueError, OSError): + return None + + allowed = _get_registered_project_paths() + for project_path in allowed: + try: + if resolved == Path(project_path).resolve() or resolved.is_relative_to( + Path(project_path).resolve() + ): + return str(resolved) + except (ValueError, OSError): + continue + + return None + + class TerminalNamespace(socketio.AsyncNamespace): """Socket.IO namespace for /terminal.""" def __init__(self) -> None: super().__init__("/terminal") self.service = _terminal_service + # Track sid -> set of session_ids for cleanup on disconnect + self._sid_sessions: dict[str, set[str]] = {} # ------------------------------------------------------------------ # Connection lifecycle # ------------------------------------------------------------------ async def on_connect(self, sid: str, environ: dict[str, Any]) -> None: + if not validate_socketio_token(environ): + logger.warning("[TerminalNS] Rejected unauthenticated client: %s", sid) + raise ConnectionRefusedError("Authentication required") + self._sid_sessions[sid] = set() logger.info("[TerminalNS] Client connected: %s", sid) async def on_disconnect(self, sid: str) -> None: + # Clean up any PTY sessions owned by this Socket.IO client + session_ids = self._sid_sessions.pop(sid, set()) + for session_id in session_ids: + if self.service.has_session(session_id): + await self.service.kill(session_id) + logger.info( + "[TerminalNS] Cleaned up orphaned session %s for disconnected client %s", + session_id, + sid, + ) logger.info("[TerminalNS] Client disconnected: %s", sid) # ------------------------------------------------------------------ @@ -79,7 +124,15 @@ class TerminalNamespace(socketio.AsyncNamespace): ) return {"ok": False, "error": "sessionId is required"} - cwd = data.get("cwd") + raw_cwd = data.get("cwd") + cwd = _validate_cwd(raw_cwd) + if raw_cwd is not None and cwd is None: + error_msg = "cwd is not within a registered project directory" + await self.emit( + "terminal:error", {"sessionId": session_id, "error": error_msg}, to=sid + ) + return {"ok": False, "error": error_msg} + cols = int(data.get("cols", 80)) rows = int(data.get("rows", 24)) @@ -97,6 +150,9 @@ class TerminalNamespace(socketio.AsyncNamespace): ) await self.service.spawn(session_id, cwd, cols, rows, on_output=_on_output) + # Track this session for cleanup on disconnect + if sid in self._sid_sessions: + self._sid_sessions[sid].add(session_id) logger.info( "[TerminalNS] Created session %s for client %s", session_id, sid ) @@ -167,6 +223,9 @@ class TerminalNamespace(socketio.AsyncNamespace): return await self.service.kill(session_id) + # Remove from tracking + if sid in self._sid_sessions: + self._sid_sessions[sid].discard(session_id) await self.emit("terminal:exit", {"sessionId": session_id}, to=sid) logger.info("[TerminalNS] Closed session %s for client %s", session_id, sid) diff --git a/apps/web/next.config.mjs b/apps/web/next.config.mjs index 353e5465..1d882a36 100644 --- a/apps/web/next.config.mjs +++ b/apps/web/next.config.mjs @@ -3,6 +3,19 @@ import { fileURLToPath } from "node:url"; const __dirname = path.dirname(fileURLToPath(import.meta.url)); +// Node.js 22+ may reference localStorage during SSR which is not available +// in server-side contexts. This shim prevents ReferenceError crashes. +if (typeof globalThis.localStorage === "undefined") { + globalThis.localStorage = { + getItem: () => null, + setItem: () => {}, + removeItem: () => {}, + clear: () => {}, + length: 0, + key: () => null, + }; +} + /** @type {import('next').NextConfig} */ const nextConfig = { output: "standalone", diff --git a/apps/web/src/app/global-error.tsx b/apps/web/src/app/global-error.tsx index e1402d06..a7adb1e2 100644 --- a/apps/web/src/app/global-error.tsx +++ b/apps/web/src/app/global-error.tsx @@ -1,5 +1,14 @@ 'use client'; +/** + * Next.js global error boundary. + * + * NOTE: Strings are intentionally hardcoded here rather than using i18n + * because this component renders as a full HTML document when the root + * layout itself crashes. At that point the i18n provider is unavailable, + * so translation keys would not resolve. Keeping plain strings ensures + * the user always sees a meaningful fallback regardless of provider state. + */ export default function GlobalError({ reset, }: {