Skip to content

Commit 2ef0c74

Browse files
Debug Agentclaude
andcommitted
fix: address review comments on first-message auth
- Add comment explaining 10-second timeout rationale - Add comment explaining why accept() must precede first-message read - Log specific failure reasons (timeout, malformed JSON, disconnect, wrong type, invalid key) instead of a single generic message - Log info on successful first-message auth for adoption tracking - Add test for query-param-takes-precedence-over-first-message case Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 9db9d66 commit 2ef0c74

File tree

2 files changed

+64
-12
lines changed

2 files changed

+64
-12
lines changed

openhands-agent-server/openhands/agent_server/sockets.py

Lines changed: 45 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,9 @@ def _resolve_websocket_session_api_key(
8585
return None
8686

8787

88+
# Give clients 10 seconds to send auth frame after connection opens.
89+
# This balances security (don't hold connections indefinitely) with
90+
# accommodating slow networks and client startup time.
8891
_FIRST_MESSAGE_AUTH_TIMEOUT_SECONDS = 10
8992

9093

@@ -124,31 +127,61 @@ async def _accept_authenticated_websocket(
124127
await websocket.close(code=4001, reason="Authentication failed")
125128
return False
126129

127-
# First-message auth: accept the connection, then read the first frame.
130+
# First-message auth: we must accept() before reading frames because the
131+
# WebSocket protocol requires the handshake to complete first. The legacy
132+
# path above can reject *before* accepting (close on an un-accepted socket
133+
# sends an HTTP 403-style response), but here we need to read a frame.
128134
await websocket.accept()
129135
try:
130136
raw = await asyncio.wait_for(
131137
websocket.receive_text(),
132138
timeout=_FIRST_MESSAGE_AUTH_TIMEOUT_SECONDS,
133139
)
134140
data = json.loads(raw)
135-
except (TimeoutError, json.JSONDecodeError, WebSocketDisconnect):
136-
logger.warning("WebSocket first-message auth failed: bad or missing payload")
141+
except TimeoutError:
142+
logger.warning(
143+
"WebSocket first-message auth failed: timeout waiting for auth frame"
144+
)
145+
await _safe_close_websocket(
146+
websocket, code=4001, reason="Authentication failed"
147+
)
148+
return False
149+
except json.JSONDecodeError:
150+
logger.warning("WebSocket first-message auth failed: malformed JSON")
151+
await _safe_close_websocket(
152+
websocket, code=4001, reason="Authentication failed"
153+
)
154+
return False
155+
except WebSocketDisconnect:
156+
logger.warning("WebSocket first-message auth failed: client disconnected")
137157
await _safe_close_websocket(
138158
websocket, code=4001, reason="Authentication failed"
139159
)
140160
return False
141161

142-
if (
143-
isinstance(data, dict)
144-
and data.get("type") == "auth"
145-
and data.get("session_api_key") in config.session_api_keys
146-
):
147-
return True
162+
if not isinstance(data, dict):
163+
logger.warning(
164+
"WebSocket first-message auth failed: payload is not a JSON object"
165+
)
166+
await _safe_close_websocket(
167+
websocket, code=4001, reason="Authentication failed"
168+
)
169+
return False
170+
if data.get("type") != "auth":
171+
logger.warning("WebSocket first-message auth failed: wrong message type")
172+
await _safe_close_websocket(
173+
websocket, code=4001, reason="Authentication failed"
174+
)
175+
return False
176+
if data.get("session_api_key") not in config.session_api_keys:
177+
logger.warning("WebSocket first-message auth failed: invalid API key")
178+
await _safe_close_websocket(
179+
websocket, code=4001, reason="Authentication failed"
180+
)
181+
return False
148182

149-
logger.warning("WebSocket first-message auth failed: invalid key or payload")
150-
await _safe_close_websocket(websocket, code=4001, reason="Authentication failed")
151-
return False
183+
logger.info("WebSocket authenticated via first-message auth")
184+
return True
152185

153186

154187
@sockets_router.websocket("/events/{conversation_id}")

tests/agent_server/test_websocket_first_message_auth.py

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,25 @@ async def test_legacy_query_param_invalid_key():
6969
ws.accept.assert_not_called()
7070

7171

72+
@pytest.mark.asyncio
73+
async def test_legacy_query_param_takes_precedence_over_first_message():
74+
"""When both query param and first-message auth could apply, query param wins."""
75+
ws = _make_mock_websocket()
76+
ws.receive_text.return_value = json.dumps(
77+
{"type": "auth", "session_api_key": "sk-oh-different"}
78+
)
79+
with patch("openhands.agent_server.sockets.get_default_config") as mock_config:
80+
mock_config.return_value.session_api_keys = ["sk-oh-valid"]
81+
result = await _accept_authenticated_websocket(
82+
ws, session_api_key="sk-oh-valid"
83+
)
84+
85+
assert result is True
86+
ws.accept.assert_called_once()
87+
# Should NOT read first message because query param already authenticated.
88+
ws.receive_text.assert_not_called()
89+
90+
7291
# -- Legacy header auth (deprecated) --
7392

7493

0 commit comments

Comments
 (0)