From d39c6618a89e6907b158dbe9bb73413821745c8a Mon Sep 17 00:00:00 2001 From: Engel Nyst Date: Thu, 22 Jan 2026 07:39:37 +0100 Subject: [PATCH 1/6] agent-server: accept WS auth via headers Support X-Session-API-Key and Authorization: Bearer for WebSocket clients (query param remains for browser clients). This unblocks oh-tab-h3g (stop leaking session_api_key in WS URLs). --- .../openhands/agent_server/dependencies.py | 17 +++++-- .../openhands/agent_server/sockets.py | 32 ++++++++++++-- .../agent_server/test_agent_server_wsproto.py | 43 ++++++++++++++++++ tests/agent_server/test_api_authentication.py | 44 +++++++++++-------- 4 files changed, 111 insertions(+), 25 deletions(-) diff --git a/openhands-agent-server/openhands/agent_server/dependencies.py b/openhands-agent-server/openhands/agent_server/dependencies.py index c68d94e4ce..c3fecd4df6 100644 --- a/openhands-agent-server/openhands/agent_server/dependencies.py +++ b/openhands-agent-server/openhands/agent_server/dependencies.py @@ -1,6 +1,6 @@ from uuid import UUID -from fastapi import Depends, HTTPException, Query, Request, status +from fastapi import Depends, HTTPException, Query, Request, WebSocket, status from fastapi.security import APIKeyHeader from openhands.agent_server.config import Config @@ -30,14 +30,25 @@ def create_websocket_session_api_key_dependency(config: Config): """Create a WebSocket session API key dependency with the given config. WebSocket connections cannot send custom headers directly from browsers, - so we use query parameters instead. + so we support query parameters. For non-browser clients we also accept + `X-Session-API-Key` and `Authorization: Bearer ...` headers. """ def check_websocket_session_api_key( + websocket: WebSocket, session_api_key: str | None = Query(None, alias="session_api_key"), ): """Check the session API key from query parameter for WebSocket connections.""" - if config.session_api_keys and session_api_key not in config.session_api_keys: + candidate = session_api_key + if not candidate: + candidate = websocket.headers.get("x-session-api-key") + if not candidate: + auth = websocket.headers.get("authorization") or "" + prefix = "bearer " + if auth.lower().startswith(prefix): + candidate = auth[len(prefix) :].strip() or None + + if config.session_api_keys and candidate not in config.session_api_keys: raise HTTPException(status.HTTP_401_UNAUTHORIZED) return check_websocket_session_api_key diff --git a/openhands-agent-server/openhands/agent_server/sockets.py b/openhands-agent-server/openhands/agent_server/sockets.py index c13e80716a..f8323812a5 100644 --- a/openhands-agent-server/openhands/agent_server/sockets.py +++ b/openhands-agent-server/openhands/agent_server/sockets.py @@ -2,8 +2,9 @@ WebSocket endpoints for OpenHands SDK. These endpoints are separate from the main API routes to handle WebSocket-specific -authentication using query parameters instead of headers, since browsers cannot -send custom HTTP headers directly with WebSocket connections. +authentication. Browsers cannot send custom HTTP headers directly with WebSocket +connections, so we support the `session_api_key` query param. For non-browser +clients (e.g. Python/Node), we also support authenticating via headers. """ import logging @@ -35,6 +36,27 @@ logger = logging.getLogger(__name__) +def _resolve_websocket_session_api_key( + websocket: WebSocket, + session_api_key: str | None, +) -> str | None: + if session_api_key: + return session_api_key + + header_key = websocket.headers.get("x-session-api-key") + if header_key: + return header_key + + auth = websocket.headers.get("authorization") + if not auth: + return None + prefix = "bearer " + if auth.lower().startswith(prefix): + token = auth[len(prefix) :].strip() + return token or None + return None + + @sockets_router.websocket("/events/{conversation_id}") async def events_socket( conversation_id: UUID, @@ -45,7 +67,8 @@ async def events_socket( """WebSocket endpoint for conversation events.""" # Perform authentication check before accepting the WebSocket connection config = get_default_config() - if config.session_api_keys and session_api_key not in config.session_api_keys: + resolved_key = _resolve_websocket_session_api_key(websocket, session_api_key) + if config.session_api_keys and resolved_key not in config.session_api_keys: # Close the WebSocket connection with an authentication error code await websocket.close(code=4001, reason="Authentication failed") return @@ -99,7 +122,8 @@ async def bash_events_socket( """WebSocket endpoint for bash events.""" # Perform authentication check before accepting the WebSocket connection config = get_default_config() - if config.session_api_keys and session_api_key not in config.session_api_keys: + resolved_key = _resolve_websocket_session_api_key(websocket, session_api_key) + if config.session_api_keys and resolved_key not in config.session_api_keys: # Close the WebSocket connection with an authentication error code await websocket.close(code=4001, reason="Authentication failed") return diff --git a/tests/agent_server/test_agent_server_wsproto.py b/tests/agent_server/test_agent_server_wsproto.py index 3e0d8044f3..3afc178a2e 100644 --- a/tests/agent_server/test_agent_server_wsproto.py +++ b/tests/agent_server/test_agent_server_wsproto.py @@ -104,3 +104,46 @@ async def test_agent_server_websocket_with_wsproto(agent_server): await ws.send( json.dumps({"role": "user", "content": "Hello from wsproto test"}) ) + + +@pytest.mark.asyncio +async def test_agent_server_websocket_with_wsproto_header_auth(agent_server): + port = agent_server["port"] + api_key = agent_server["api_key"] + + response = requests.post( + f"http://127.0.0.1:{port}/api/conversations", + headers={"X-Session-API-Key": api_key}, + json={ + "agent": { + "llm": { + "usage_id": "test-llm", + "model": "test-provider/test-model", + "api_key": "test-key", + }, + "tools": [], + }, + "workspace": {"working_dir": "/tmp/test-workspace"}, + }, + ) + assert response.status_code in [200, 201] + conversation_id = response.json()["id"] + + ws_url = f"ws://127.0.0.1:{port}/sockets/events/{conversation_id}?resend_all=true" + + async with websockets.connect( + ws_url, + open_timeout=5, + additional_headers={"X-Session-API-Key": api_key}, + ) as ws: + try: + response = await asyncio.wait_for(ws.recv(), timeout=2) + assert response is not None + except TimeoutError: + pass + + await ws.send( + json.dumps( + {"role": "user", "content": "Hello from wsproto header auth test"} + ) + ) diff --git a/tests/agent_server/test_api_authentication.py b/tests/agent_server/test_api_authentication.py index 86865c82bd..16f4e827bb 100644 --- a/tests/agent_server/test_api_authentication.py +++ b/tests/agent_server/test_api_authentication.py @@ -213,29 +213,37 @@ def test_api_websocket_authentication(): app = create_app(config) client = TestClient(app) - # Test WebSocket connection without authentication - should fail - try: + # Without authentication -> should fail + with pytest.raises(Exception): with client.websocket_connect("/sockets/bash-events"): - # If we get here, the connection was established without auth - # (should not happen) - assert False, ( - "WebSocket connection should have failed without authentication" - ) - except Exception: - # WebSocket connection should fail without proper authentication + assert False, "WebSocket connection should have failed without auth" + + # Query-param authentication -> should work (browser-compatible) + with client.websocket_connect("/sockets/bash-events?session_api_key=test-key"): + pass + + # Header authentication -> should work for non-browser clients + with client.websocket_connect( + "/sockets/bash-events", + headers={"X-Session-API-Key": "test-key"}, + ): pass - # Test WebSocket connection with authentication via query parameter - should work - try: - with client.websocket_connect("/sockets/bash-events?session_api_key=test-key"): - # If we get here, the connection was established with proper auth - pass - except Exception: - # Connection might fail for other reasons (like missing conversation ID for - # events endpoint) - # This test mainly ensures the auth mechanism works + # Authorization header authentication -> should work for non-browser clients + with client.websocket_connect( + "/sockets/bash-events", + headers={"Authorization": "Bearer test-key"}, + ): pass + # Wrong header -> should fail + with pytest.raises(Exception): + with client.websocket_connect( + "/sockets/bash-events", + headers={"X-Session-API-Key": "wrong-key"}, + ): + assert False, "WebSocket connection should have failed with wrong key" + def test_api_options_requests(): """Test that OPTIONS requests work for CORS preflight.""" From d6e2f05fd6a0c87a60c82d70a6c3e3d1b9ca742c Mon Sep 17 00:00:00 2001 From: Engel Nyst Date: Thu, 22 Jan 2026 08:17:04 +0100 Subject: [PATCH 2/6] agent-server: dedupe websocket auth helper Co-authored-by: openhands --- .../openhands/agent_server/dependencies.py | 30 +------------------ .../openhands/agent_server/sockets.py | 30 ++++++++++--------- 2 files changed, 17 insertions(+), 43 deletions(-) diff --git a/openhands-agent-server/openhands/agent_server/dependencies.py b/openhands-agent-server/openhands/agent_server/dependencies.py index c3fecd4df6..9b55e3e434 100644 --- a/openhands-agent-server/openhands/agent_server/dependencies.py +++ b/openhands-agent-server/openhands/agent_server/dependencies.py @@ -1,6 +1,6 @@ from uuid import UUID -from fastapi import Depends, HTTPException, Query, Request, WebSocket, status +from fastapi import Depends, HTTPException, Request, status from fastapi.security import APIKeyHeader from openhands.agent_server.config import Config @@ -26,34 +26,6 @@ def check_session_api_key( return check_session_api_key -def create_websocket_session_api_key_dependency(config: Config): - """Create a WebSocket session API key dependency with the given config. - - WebSocket connections cannot send custom headers directly from browsers, - so we support query parameters. For non-browser clients we also accept - `X-Session-API-Key` and `Authorization: Bearer ...` headers. - """ - - def check_websocket_session_api_key( - websocket: WebSocket, - session_api_key: str | None = Query(None, alias="session_api_key"), - ): - """Check the session API key from query parameter for WebSocket connections.""" - candidate = session_api_key - if not candidate: - candidate = websocket.headers.get("x-session-api-key") - if not candidate: - auth = websocket.headers.get("authorization") or "" - prefix = "bearer " - if auth.lower().startswith(prefix): - candidate = auth[len(prefix) :].strip() or None - - if config.session_api_keys and candidate not in config.session_api_keys: - raise HTTPException(status.HTTP_401_UNAUTHORIZED) - - return check_websocket_session_api_key - - def get_conversation_service(request: Request): """Get the conversation service from app state. diff --git a/openhands-agent-server/openhands/agent_server/sockets.py b/openhands-agent-server/openhands/agent_server/sockets.py index f8323812a5..eaa72145da 100644 --- a/openhands-agent-server/openhands/agent_server/sockets.py +++ b/openhands-agent-server/openhands/agent_server/sockets.py @@ -57,6 +57,20 @@ def _resolve_websocket_session_api_key( return None +async def _accept_authenticated_websocket( + websocket: WebSocket, + session_api_key: str | None, +) -> bool: + """Authenticate and accept the socket, or close with an auth error.""" + config = get_default_config() + resolved_key = _resolve_websocket_session_api_key(websocket, session_api_key) + if config.session_api_keys and resolved_key not in config.session_api_keys: + await websocket.close(code=4001, reason="Authentication failed") + return False + await websocket.accept() + return True + + @sockets_router.websocket("/events/{conversation_id}") async def events_socket( conversation_id: UUID, @@ -65,15 +79,9 @@ async def events_socket( resend_all: Annotated[bool, Query()] = False, ): """WebSocket endpoint for conversation events.""" - # Perform authentication check before accepting the WebSocket connection - config = get_default_config() - resolved_key = _resolve_websocket_session_api_key(websocket, session_api_key) - if config.session_api_keys and resolved_key not in config.session_api_keys: - # Close the WebSocket connection with an authentication error code - await websocket.close(code=4001, reason="Authentication failed") + if not await _accept_authenticated_websocket(websocket, session_api_key): return - await websocket.accept() logger.info(f"Event Websocket Connected: {conversation_id}") event_service = await conversation_service.get_event_service(conversation_id) if event_service is None: @@ -120,15 +128,9 @@ async def bash_events_socket( resend_all: Annotated[bool, Query()] = False, ): """WebSocket endpoint for bash events.""" - # Perform authentication check before accepting the WebSocket connection - config = get_default_config() - resolved_key = _resolve_websocket_session_api_key(websocket, session_api_key) - if config.session_api_keys and resolved_key not in config.session_api_keys: - # Close the WebSocket connection with an authentication error code - await websocket.close(code=4001, reason="Authentication failed") + if not await _accept_authenticated_websocket(websocket, session_api_key): return - await websocket.accept() logger.info("Bash Websocket Connected") subscriber_id = await bash_event_service.subscribe_to_events( _BashWebSocketSubscriber(websocket) From 78174befe7f24965258a4c63eaa51c97a49aa0cd Mon Sep 17 00:00:00 2001 From: Engel Nyst Date: Thu, 22 Jan 2026 08:25:32 +0100 Subject: [PATCH 3/6] agent-server: test WS auth precedence Co-authored-by: openhands --- tests/agent_server/test_api_authentication.py | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/tests/agent_server/test_api_authentication.py b/tests/agent_server/test_api_authentication.py index 16f4e827bb..1c3d5f995e 100644 --- a/tests/agent_server/test_api_authentication.py +++ b/tests/agent_server/test_api_authentication.py @@ -236,6 +236,22 @@ def test_api_websocket_authentication(): ): pass + # Query param should take precedence over headers (browser-compatible escape hatch). + with client.websocket_connect( + "/sockets/bash-events?session_api_key=test-key", + headers={"X-Session-API-Key": "wrong-key"}, + ): + pass + + # If query param is present and wrong, connection should fail even if the + # header is correct. + with pytest.raises(Exception): + with client.websocket_connect( + "/sockets/bash-events?session_api_key=wrong-key", + headers={"X-Session-API-Key": "test-key"}, + ): + assert False, "WebSocket connection should have failed with wrong query key" + # Wrong header -> should fail with pytest.raises(Exception): with client.websocket_connect( From 3af6c7107c77346b24a4ac12e1bf4406ecfbabc4 Mon Sep 17 00:00:00 2001 From: Engel Nyst Date: Thu, 22 Jan 2026 08:55:33 +0100 Subject: [PATCH 4/6] Update openhands-agent-server/openhands/agent_server/sockets.py Co-authored-by: OpenHands Bot --- openhands-agent-server/openhands/agent_server/sockets.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openhands-agent-server/openhands/agent_server/sockets.py b/openhands-agent-server/openhands/agent_server/sockets.py index eaa72145da..7025f4e6f7 100644 --- a/openhands-agent-server/openhands/agent_server/sockets.py +++ b/openhands-agent-server/openhands/agent_server/sockets.py @@ -52,7 +52,7 @@ def _resolve_websocket_session_api_key( return None prefix = "bearer " if auth.lower().startswith(prefix): - token = auth[len(prefix) :].strip() + token = auth[len(prefix):].strip() return token or None return None From c399daba657ffeb7fb7f5bd8914359dd2fe19073 Mon Sep 17 00:00:00 2001 From: Engel Nyst Date: Thu, 22 Jan 2026 08:56:41 +0100 Subject: [PATCH 5/6] Update openhands-agent-server/openhands/agent_server/sockets.py Co-authored-by: OpenHands Bot --- openhands-agent-server/openhands/agent_server/sockets.py | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/openhands-agent-server/openhands/agent_server/sockets.py b/openhands-agent-server/openhands/agent_server/sockets.py index 7025f4e6f7..7af9f37cb6 100644 --- a/openhands-agent-server/openhands/agent_server/sockets.py +++ b/openhands-agent-server/openhands/agent_server/sockets.py @@ -40,6 +40,15 @@ def _resolve_websocket_session_api_key( websocket: WebSocket, session_api_key: str | None, ) -> str | None: + """Resolve the session API key from multiple sources. + + Precedence order (highest to lowest): + 1. Query parameter (session_api_key) - for browser compatibility + 2. X-Session-API-Key header - for non-browser clients + 3. Authorization: Bearer header - standard auth header + + Returns None if no valid key is found in any source. + """ if session_api_key: return session_api_key From 2be30abd4045f10aea5e7a13a53bb43eb8817586 Mon Sep 17 00:00:00 2001 From: Engel Nyst Date: Fri, 23 Jan 2026 02:46:15 +0100 Subject: [PATCH 6/6] Simplify, keep consistent with REST --- .../openhands/agent_server/sockets.py | 12 ++---------- tests/agent_server/test_api_authentication.py | 7 ------- 2 files changed, 2 insertions(+), 17 deletions(-) diff --git a/openhands-agent-server/openhands/agent_server/sockets.py b/openhands-agent-server/openhands/agent_server/sockets.py index 7af9f37cb6..ef9586b08a 100644 --- a/openhands-agent-server/openhands/agent_server/sockets.py +++ b/openhands-agent-server/openhands/agent_server/sockets.py @@ -41,12 +41,11 @@ def _resolve_websocket_session_api_key( session_api_key: str | None, ) -> str | None: """Resolve the session API key from multiple sources. - + Precedence order (highest to lowest): 1. Query parameter (session_api_key) - for browser compatibility 2. X-Session-API-Key header - for non-browser clients - 3. Authorization: Bearer header - standard auth header - + Returns None if no valid key is found in any source. """ if session_api_key: @@ -56,13 +55,6 @@ def _resolve_websocket_session_api_key( if header_key: return header_key - auth = websocket.headers.get("authorization") - if not auth: - return None - prefix = "bearer " - if auth.lower().startswith(prefix): - token = auth[len(prefix):].strip() - return token or None return None diff --git a/tests/agent_server/test_api_authentication.py b/tests/agent_server/test_api_authentication.py index 1c3d5f995e..2561d127a0 100644 --- a/tests/agent_server/test_api_authentication.py +++ b/tests/agent_server/test_api_authentication.py @@ -229,13 +229,6 @@ def test_api_websocket_authentication(): ): pass - # Authorization header authentication -> should work for non-browser clients - with client.websocket_connect( - "/sockets/bash-events", - headers={"Authorization": "Bearer test-key"}, - ): - pass - # Query param should take precedence over headers (browser-compatible escape hatch). with client.websocket_connect( "/sockets/bash-events?session_api_key=test-key",