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:
@@ -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
@@ -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)
|
||||
|
||||
@@ -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"])
|
||||
|
||||
|
||||
@@ -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
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -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
@@ -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}
|
||||
|
||||
@@ -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)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
|
||||
@@ -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)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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,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
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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"}
|
||||
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
@@ -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"}
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
}: {
|
||||
|
||||
Reference in New Issue
Block a user