Skip to content

Commit dfb102d

Browse files
nioasoftclaude
andcommitted
fix: accept WebSocket before validation to prevent opaque 403 errors
All WebSocket endpoints now call websocket.accept() before any validation checks. Previously, closing the connection before accepting caused Starlette to return an opaque HTTP 403 instead of a meaningful error message. Changes: - Server: Accept WebSocket first, then send JSON error + close with 4xxx code if validation fails (expand, spec, assistant, terminal, main project WS) - Server: ConnectionManager.connect() no longer calls accept() to avoid double-accept - UI: Gate expand button and keyboard shortcut on hasSpec - UI: Skip WebSocket reconnection on application error codes (4000-4999) - UI: Update keyboard shortcuts help text Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent f4facb3 commit dfb102d

12 files changed

Lines changed: 63 additions & 25 deletions

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: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -221,15 +221,20 @@ 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
225228
if not validate_project_name(project_name):
229+
await websocket.send_json({"type": "error", "message": "Invalid project name"})
226230
await websocket.close(
227231
code=TerminalCloseCode.INVALID_PROJECT_NAME, reason="Invalid project name"
228232
)
229233
return
230234

231235
# Validate terminal ID
232236
if not validate_terminal_id(terminal_id):
237+
await websocket.send_json({"type": "error", "message": "Invalid terminal ID"})
233238
await websocket.close(
234239
code=TerminalCloseCode.INVALID_PROJECT_NAME, reason="Invalid terminal ID"
235240
)
@@ -238,13 +243,15 @@ async def terminal_websocket(websocket: WebSocket, project_name: str, terminal_i
238243
# Look up project directory from registry
239244
project_dir = _get_project_path(project_name)
240245
if not project_dir:
246+
await websocket.send_json({"type": "error", "message": "Project not found in registry"})
241247
await websocket.close(
242248
code=TerminalCloseCode.PROJECT_NOT_FOUND,
243249
reason="Project not found in registry",
244250
)
245251
return
246252

247253
if not project_dir.exists():
254+
await websocket.send_json({"type": "error", "message": "Project directory not found"})
248255
await websocket.close(
249256
code=TerminalCloseCode.PROJECT_NOT_FOUND,
250257
reason="Project directory not found",
@@ -254,14 +261,13 @@ async def terminal_websocket(websocket: WebSocket, project_name: str, terminal_i
254261
# Verify terminal exists in metadata
255262
terminal_info = get_terminal_info(project_name, terminal_id)
256263
if not terminal_info:
264+
await websocket.send_json({"type": "error", "message": "Terminal not found"})
257265
await websocket.close(
258266
code=TerminalCloseCode.PROJECT_NOT_FOUND,
259267
reason="Terminal not found",
260268
)
261269
return
262270

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

server/websocket.py

Lines changed: 8 additions & 5 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,22 @@ async def project_websocket(websocket: WebSocket, project_name: str):
727725
- Agent status changes
728726
- Agent stdout/stderr lines
729727
"""
728+
# Always accept WebSocket first to avoid opaque 403 errors
729+
await websocket.accept()
730+
730731
if not validate_project_name(project_name):
732+
await websocket.send_json({"type": "error", "content": "Invalid project name"})
731733
await websocket.close(code=4000, reason="Invalid project name")
732734
return
733735

734736
project_dir = _get_project_path(project_name)
735737
if not project_dir:
738+
await websocket.send_json({"type": "error", "content": "Project not found in registry"})
736739
await websocket.close(code=4004, reason="Project not found in registry")
737740
return
738741

739742
if not project_dir.exists():
743+
await websocket.send_json({"type": "error", "content": "Project directory not found"})
740744
await websocket.close(code=4004, reason="Project directory not found")
741745
return
742746

@@ -879,8 +883,7 @@ async def on_dev_status_change(status: str):
879883
break
880884
except json.JSONDecodeError:
881885
logger.warning(f"Invalid JSON from WebSocket: {data[:100] if data else 'empty'}")
882-
except Exception as e:
883-
logger.warning(f"WebSocket error: {e}")
886+
except Exception:
884887
break
885888

886889
finally:

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)