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 <noreply@anthropic.com>
This commit is contained in:
AndyMik90
2026-02-20 12:48:02 +01:00
parent ce6b340704
commit 53b54eaacd
21 changed files with 702 additions and 365 deletions
+67
View File
@@ -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
+55 -23
View File
@@ -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)
+2 -1
View File
@@ -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"])
+1 -15
View File
@@ -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
# ---------------------------------------------------------------------------
+3 -22
View File
@@ -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"
+76 -102
View File
@@ -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}
+5 -32
View File
@@ -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)
# ---------------------------------------------------------------------------
+49 -31
View File
@@ -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)
# ---------------------------------------------------------------------------
+1 -11
View File
@@ -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 (
+1 -11
View File
@@ -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 (
+8 -28
View File
@@ -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
# ---------------------------------------------------------------------------
+1 -11
View File
@@ -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 (
+7 -6
View File
@@ -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"}
+42 -32
View File
@@ -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")
+36 -10
View File
@@ -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
+195
View File
@@ -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 <token>`` 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")
+49 -12
View File
@@ -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"}
+13 -8
View File
@@ -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:
+69 -10
View File
@@ -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)
+13
View File
@@ -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",
+9
View File
@@ -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,
}: {