Skip to content

Commit 035e8fd

Browse files
nioasoftclaude
andcommitted
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>
1 parent f4facb3 commit 035e8fd

File tree

12 files changed

+68
-25
lines changed

12 files changed

+68
-25
lines changed

bin/autoforge.js

100644100755
File mode changed.

server/routers/assistant_chat.py

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -217,20 +217,26 @@ async def assistant_chat_websocket(websocket: WebSocket, project_name: str):
217217
- {"type": "error", "content": "..."} - Error message
218218
- {"type": "pong"} - Keep-alive pong
219219
"""
220-
if not validate_project_name(project_name):
220+
# Always accept WebSocket first to avoid opaque 403 errors
221+
await websocket.accept()
222+
223+
try:
224+
project_name = validate_project_name(project_name)
225+
except HTTPException:
226+
await websocket.send_json({"type": "error", "content": "Invalid project name"})
221227
await websocket.close(code=4000, reason="Invalid project name")
222228
return
223229

224230
project_dir = _get_project_path(project_name)
225231
if not project_dir:
232+
await websocket.send_json({"type": "error", "content": "Project not found in registry"})
226233
await websocket.close(code=4004, reason="Project not found in registry")
227234
return
228235

229236
if not project_dir.exists():
237+
await websocket.send_json({"type": "error", "content": "Project directory not found"})
230238
await websocket.close(code=4004, reason="Project directory not found")
231239
return
232-
233-
await websocket.accept()
234240
logger.info(f"Assistant WebSocket connected for project: {project_name}")
235241

236242
session: Optional[AssistantChatSession] = None

server/routers/expand_project.py

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -104,31 +104,37 @@ async def expand_project_websocket(websocket: WebSocket, project_name: str):
104104
- {"type": "error", "content": "..."} - Error message
105105
- {"type": "pong"} - Keep-alive pong
106106
"""
107+
# Always accept the WebSocket first to avoid opaque 403 errors.
108+
# Starlette returns 403 if we close before accepting.
109+
await websocket.accept()
110+
107111
try:
108112
project_name = validate_project_name(project_name)
109113
except HTTPException:
114+
await websocket.send_json({"type": "error", "content": "Invalid project name"})
110115
await websocket.close(code=4000, reason="Invalid project name")
111116
return
112117

113118
# Look up project directory from registry
114119
project_dir = _get_project_path(project_name)
115120
if not project_dir:
121+
await websocket.send_json({"type": "error", "content": "Project not found in registry"})
116122
await websocket.close(code=4004, reason="Project not found in registry")
117123
return
118124

119125
if not project_dir.exists():
126+
await websocket.send_json({"type": "error", "content": "Project directory not found"})
120127
await websocket.close(code=4004, reason="Project directory not found")
121128
return
122129

123130
# Verify project has app_spec.txt
124131
from autoforge_paths import get_prompts_dir
125132
spec_path = get_prompts_dir(project_dir) / "app_spec.txt"
126133
if not spec_path.exists():
134+
await websocket.send_json({"type": "error", "content": "Project has no spec. Create a spec first before expanding."})
127135
await websocket.close(code=4004, reason="Project has no spec. Create spec first.")
128136
return
129137

130-
await websocket.accept()
131-
132138
session: Optional[ExpandChatSession] = None
133139

134140
try:

server/routers/spec_creation.py

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -166,22 +166,28 @@ async def spec_chat_websocket(websocket: WebSocket, project_name: str):
166166
- {"type": "error", "content": "..."} - Error message
167167
- {"type": "pong"} - Keep-alive pong
168168
"""
169-
if not validate_project_name(project_name):
169+
# Always accept WebSocket first to avoid opaque 403 errors
170+
await websocket.accept()
171+
172+
try:
173+
project_name = validate_project_name(project_name)
174+
except HTTPException:
175+
await websocket.send_json({"type": "error", "content": "Invalid project name"})
170176
await websocket.close(code=4000, reason="Invalid project name")
171177
return
172178

173179
# Look up project directory from registry
174180
project_dir = _get_project_path(project_name)
175181
if not project_dir:
182+
await websocket.send_json({"type": "error", "content": "Project not found in registry"})
176183
await websocket.close(code=4004, reason="Project not found in registry")
177184
return
178185

179186
if not project_dir.exists():
187+
await websocket.send_json({"type": "error", "content": "Project directory not found"})
180188
await websocket.close(code=4004, reason="Project directory not found")
181189
return
182190

183-
await websocket.accept()
184-
185191
session: Optional[SpecChatSession] = None
186192

187193
try:

server/routers/terminal.py

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -221,15 +221,22 @@ async def terminal_websocket(websocket: WebSocket, project_name: str, terminal_i
221221
- {"type": "pong"} - Keep-alive response
222222
- {"type": "error", "message": "..."} - Error message
223223
"""
224+
# Always accept WebSocket first to avoid opaque 403 errors
225+
await websocket.accept()
226+
224227
# Validate project name
225-
if not validate_project_name(project_name):
228+
try:
229+
project_name = validate_project_name(project_name)
230+
except Exception:
231+
await websocket.send_json({"type": "error", "message": "Invalid project name"})
226232
await websocket.close(
227233
code=TerminalCloseCode.INVALID_PROJECT_NAME, reason="Invalid project name"
228234
)
229235
return
230236

231237
# Validate terminal ID
232238
if not validate_terminal_id(terminal_id):
239+
await websocket.send_json({"type": "error", "message": "Invalid terminal ID"})
233240
await websocket.close(
234241
code=TerminalCloseCode.INVALID_PROJECT_NAME, reason="Invalid terminal ID"
235242
)
@@ -238,13 +245,15 @@ async def terminal_websocket(websocket: WebSocket, project_name: str, terminal_i
238245
# Look up project directory from registry
239246
project_dir = _get_project_path(project_name)
240247
if not project_dir:
248+
await websocket.send_json({"type": "error", "message": "Project not found in registry"})
241249
await websocket.close(
242250
code=TerminalCloseCode.PROJECT_NOT_FOUND,
243251
reason="Project not found in registry",
244252
)
245253
return
246254

247255
if not project_dir.exists():
256+
await websocket.send_json({"type": "error", "message": "Project directory not found"})
248257
await websocket.close(
249258
code=TerminalCloseCode.PROJECT_NOT_FOUND,
250259
reason="Project directory not found",
@@ -254,14 +263,13 @@ async def terminal_websocket(websocket: WebSocket, project_name: str, terminal_i
254263
# Verify terminal exists in metadata
255264
terminal_info = get_terminal_info(project_name, terminal_id)
256265
if not terminal_info:
266+
await websocket.send_json({"type": "error", "message": "Terminal not found"})
257267
await websocket.close(
258268
code=TerminalCloseCode.PROJECT_NOT_FOUND,
259269
reason="Terminal not found",
260270
)
261271
return
262272

263-
await websocket.accept()
264-
265273
# Get or create terminal session for this project/terminal
266274
session = get_terminal_session(project_name, project_dir, terminal_id)
267275

server/websocket.py

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -640,9 +640,7 @@ def __init__(self):
640640
self._lock = asyncio.Lock()
641641

642642
async def connect(self, websocket: WebSocket, project_name: str):
643-
"""Accept a WebSocket connection for a project."""
644-
await websocket.accept()
645-
643+
"""Register a WebSocket connection for a project (must already be accepted)."""
646644
async with self._lock:
647645
if project_name not in self.active_connections:
648646
self.active_connections[project_name] = set()
@@ -727,16 +725,24 @@ async def project_websocket(websocket: WebSocket, project_name: str):
727725
- Agent status changes
728726
- Agent stdout/stderr lines
729727
"""
730-
if not validate_project_name(project_name):
728+
# Always accept WebSocket first to avoid opaque 403 errors
729+
await websocket.accept()
730+
731+
try:
732+
project_name = validate_project_name(project_name)
733+
except Exception:
734+
await websocket.send_json({"type": "error", "content": "Invalid project name"})
731735
await websocket.close(code=4000, reason="Invalid project name")
732736
return
733737

734738
project_dir = _get_project_path(project_name)
735739
if not project_dir:
740+
await websocket.send_json({"type": "error", "content": "Project not found in registry"})
736741
await websocket.close(code=4004, reason="Project not found in registry")
737742
return
738743

739744
if not project_dir.exists():
745+
await websocket.send_json({"type": "error", "content": "Project directory not found"})
740746
await websocket.close(code=4004, reason="Project directory not found")
741747
return
742748

ui/src/App.tsx

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -178,8 +178,8 @@ function App() {
178178
setShowAddFeature(true)
179179
}
180180

181-
// E : Expand project with AI (when project selected and has features)
182-
if ((e.key === 'e' || e.key === 'E') && selectedProject && features &&
181+
// E : Expand project with AI (when project selected, has spec and has features)
182+
if ((e.key === 'e' || e.key === 'E') && selectedProject && hasSpec && features &&
183183
(features.pending.length + features.in_progress.length + features.done.length) > 0) {
184184
e.preventDefault()
185185
setShowExpandProject(true)
@@ -239,7 +239,7 @@ function App() {
239239

240240
window.addEventListener('keydown', handleKeyDown)
241241
return () => window.removeEventListener('keydown', handleKeyDown)
242-
}, [selectedProject, showAddFeature, showExpandProject, selectedFeature, debugOpen, debugActiveTab, assistantOpen, features, showSettings, showKeyboardHelp, isSpecCreating, viewMode, showResetModal, wsState.agentStatus])
242+
}, [selectedProject, showAddFeature, showExpandProject, selectedFeature, debugOpen, debugActiveTab, assistantOpen, features, showSettings, showKeyboardHelp, isSpecCreating, viewMode, showResetModal, wsState.agentStatus, hasSpec])
243243

244244
// Combine WebSocket progress with feature data
245245
const progress = wsState.progress.total > 0 ? wsState.progress : {
@@ -490,7 +490,7 @@ function App() {
490490
)}
491491

492492
{/* Expand Project Modal - AI-powered bulk feature creation */}
493-
{showExpandProject && selectedProject && (
493+
{showExpandProject && selectedProject && hasSpec && (
494494
<ExpandProjectModal
495495
isOpen={showExpandProject}
496496
projectName={selectedProject}

ui/src/components/KanbanBoard.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -51,7 +51,7 @@ export function KanbanBoard({ features, onFeatureClick, onAddFeature, onExpandPr
5151
onFeatureClick={onFeatureClick}
5252
onAddFeature={onAddFeature}
5353
onExpandProject={onExpandProject}
54-
showExpandButton={hasFeatures}
54+
showExpandButton={hasFeatures && hasSpec}
5555
onCreateSpec={onCreateSpec}
5656
showCreateSpec={!hasSpec && !hasFeatures}
5757
/>

ui/src/components/KeyboardShortcutsHelp.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ const shortcuts: Shortcut[] = [
1919
{ key: 'D', description: 'Toggle debug panel' },
2020
{ key: 'T', description: 'Toggle terminal tab' },
2121
{ key: 'N', description: 'Add new feature', context: 'with project' },
22-
{ key: 'E', description: 'Expand project with AI', context: 'with features' },
22+
{ key: 'E', description: 'Expand project with AI', context: 'with spec & features' },
2323
{ key: 'A', description: 'Toggle AI assistant', context: 'with project' },
2424
{ key: 'G', description: 'Toggle Kanban/Graph view', context: 'with project' },
2525
{ key: ',', description: 'Open settings' },

ui/src/hooks/useExpandChat.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -107,16 +107,20 @@ export function useExpandChat({
107107
}, 30000)
108108
}
109109

110-
ws.onclose = () => {
110+
ws.onclose = (event) => {
111111
setConnectionStatus('disconnected')
112112
if (pingIntervalRef.current) {
113113
clearInterval(pingIntervalRef.current)
114114
pingIntervalRef.current = null
115115
}
116116

117+
// Don't retry on application-level errors (4xxx codes won't resolve on retry)
118+
const isAppError = event.code >= 4000 && event.code <= 4999
119+
117120
// Attempt reconnection if not intentionally closed
118121
if (
119122
!manuallyDisconnectedRef.current &&
123+
!isAppError &&
120124
reconnectAttempts.current < maxReconnectAttempts &&
121125
!isCompleteRef.current
122126
) {

0 commit comments

Comments
 (0)