fix: accept WebSocket before validation to prevent opaque 403 errors

All 5 WebSocket endpoints (expand, spec, assistant, terminal, project)
were closing the connection before calling accept() when validation
failed. Starlette converts pre-accept close into an HTTP 403, giving
clients no meaningful error information.

Server changes:
- Move websocket.accept() before all validation checks in every WS handler
- Send JSON error message before closing so clients get actionable errors
- Fix validate_project_name usage (raises HTTPException, not returns bool)
- ConnectionManager.connect() no longer calls accept() (caller's job)

Client changes:
- All 3 WS hooks (useWebSocket, useExpandChat, useSpecChat) skip
  reconnection on 4xxx close codes (application errors won't self-resolve)
- Gate expand button, keyboard shortcut, and modal on hasSpec
- Add hasSpec to useEffect dependency array to prevent stale closure
- Update keyboard shortcuts help text for E key context

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
nioasoft
2026-02-05 21:08:46 +02:00
parent f4facb3200
commit 035e8fdfca
12 changed files with 68 additions and 25 deletions

View File

@@ -221,8 +221,14 @@ async def terminal_websocket(websocket: WebSocket, project_name: str, terminal_i
- {"type": "pong"} - Keep-alive response
- {"type": "error", "message": "..."} - Error message
"""
# Always accept WebSocket first to avoid opaque 403 errors
await websocket.accept()
# Validate project name
if not validate_project_name(project_name):
try:
project_name = validate_project_name(project_name)
except Exception:
await websocket.send_json({"type": "error", "message": "Invalid project name"})
await websocket.close(
code=TerminalCloseCode.INVALID_PROJECT_NAME, reason="Invalid project name"
)
@@ -230,6 +236,7 @@ async def terminal_websocket(websocket: WebSocket, project_name: str, terminal_i
# Validate terminal ID
if not validate_terminal_id(terminal_id):
await websocket.send_json({"type": "error", "message": "Invalid terminal ID"})
await websocket.close(
code=TerminalCloseCode.INVALID_PROJECT_NAME, reason="Invalid terminal ID"
)
@@ -238,6 +245,7 @@ async def terminal_websocket(websocket: WebSocket, project_name: str, terminal_i
# Look up project directory from registry
project_dir = _get_project_path(project_name)
if not project_dir:
await websocket.send_json({"type": "error", "message": "Project not found in registry"})
await websocket.close(
code=TerminalCloseCode.PROJECT_NOT_FOUND,
reason="Project not found in registry",
@@ -245,6 +253,7 @@ async def terminal_websocket(websocket: WebSocket, project_name: str, terminal_i
return
if not project_dir.exists():
await websocket.send_json({"type": "error", "message": "Project directory not found"})
await websocket.close(
code=TerminalCloseCode.PROJECT_NOT_FOUND,
reason="Project directory not found",
@@ -254,14 +263,13 @@ async def terminal_websocket(websocket: WebSocket, project_name: str, terminal_i
# Verify terminal exists in metadata
terminal_info = get_terminal_info(project_name, terminal_id)
if not terminal_info:
await websocket.send_json({"type": "error", "message": "Terminal not found"})
await websocket.close(
code=TerminalCloseCode.PROJECT_NOT_FOUND,
reason="Terminal not found",
)
return
await websocket.accept()
# Get or create terminal session for this project/terminal
session = get_terminal_session(project_name, project_dir, terminal_id)