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

@@ -640,9 +640,7 @@ class ConnectionManager:
self._lock = asyncio.Lock()
async def connect(self, websocket: WebSocket, project_name: str):
"""Accept a WebSocket connection for a project."""
await websocket.accept()
"""Register a WebSocket connection for a project (must already be accepted)."""
async with self._lock:
if project_name not in self.active_connections:
self.active_connections[project_name] = set()
@@ -727,16 +725,24 @@ async def project_websocket(websocket: WebSocket, project_name: str):
- Agent status changes
- Agent stdout/stderr lines
"""
if not validate_project_name(project_name):
# Always accept WebSocket first to avoid opaque 403 errors
await websocket.accept()
try:
project_name = validate_project_name(project_name)
except Exception:
await websocket.send_json({"type": "error", "content": "Invalid project name"})
await websocket.close(code=4000, reason="Invalid project name")
return
project_dir = _get_project_path(project_name)
if not project_dir:
await websocket.send_json({"type": "error", "content": "Project not found in registry"})
await websocket.close(code=4004, reason="Project not found in registry")
return
if not project_dir.exists():
await websocket.send_json({"type": "error", "content": "Project directory not found"})
await websocket.close(code=4004, reason="Project directory not found")
return