fix: normalize /p/<project> prefix on WebSocket upgrades so the Responses WS route is not rejected with 403 (#2379)

## Description

A Responses WebSocket upgrade to a project-prefixed URL
(`ws://127.0.0.1:8787/p/<project>/v1/responses`) was rejected with `403
Forbidden`, so the client fell back to HTTP transport. The `/p/<name>`
base-URL prefix is stripped by
`strip_project_path_prefix(request.scope)` inside
`@app.middleware("http")`, but Starlette runs `@app.middleware("http")`
for `http` scopes only, never `websocket` scopes. So an HTTP `POST
/p/<project>/v1/responses` has its prefix stripped and matches
`/v1/responses`, while the WS upgrade keeps the prefix, matches no
registered WebSocket route (`OPENAI_RESPONSES_WEBSOCKET_PATHS` are all
unprefixed), and Starlette rejects the unmatched WebSocket with `403`.
This normalizes the prefix for WebSocket scopes before routing so the
upgrade reaches the existing Responses WS handler and stays attributed
to the project.

Closes #2355

## Type of Change

- [x] Bug fix (non-breaking change that fixes an issue)
- [ ] New feature (non-breaking change that adds functionality)
- [ ] Breaking change (fix or feature that would cause existing
functionality to change)
- [ ] Documentation update
- [ ] Performance improvement
- [ ] Code refactoring (no functional changes)

## Changes Made

- `headroom/proxy/server.py` — added a small pure-ASGI
`WebSocketProjectPrefixMiddleware` (registered in `create_app`) that,
for `websocket` scopes only, strips the `/p/<name>` prefix via the
existing `strip_project_path_prefix` and binds the project context,
mirroring the HTTP middleware. HTTP and lifespan scopes pass through
untouched (no double-strip).
- `headroom/proxy/handlers/openai.py` — `handle_openai_responses_ws`
previously called `set_current_project(classify_project(ws_headers))`
unconditionally, clearing the middleware-bound project for prefix-only
clients (no `X-Headroom-Project` header). It now falls back to the
already-bound path-prefix project (`classify_project(ws_headers) or
get_current_project()`), so prefix-only WebSocket clients (aider,
Copilot BYOK, Cursor and other `/p/<name>` base-URL wraps) stay
attributed, exactly as on the HTTP path.
- `tests/test_provider_proxy_routes.py` — added a regression test.

## Testing

- [x] Unit tests pass (`pytest`)
- [x] Linting passes (`ruff check .`)
- [x] Type checking passes (`mypy headroom`)
- [x] New tests added for new functionality
- [x] Manual testing performed

### Test Output

```text
$ pytest tests/test_provider_proxy_routes.py -q
21 passed, 1 warning in 23.95s

$ ruff check headroom/proxy/server.py headroom/proxy/handlers/openai.py
All checks passed!

$ mypy --python-version 3.13 headroom/proxy/server.py headroom/proxy/handlers/openai.py
Success: no issues found in 2 source files
```

## Real Behavior Proof

- Environment: local, `uv` venv, Python 3.14, `uv run pytest`.
- Exact command / steps: added
`test_project_prefixed_openai_response_websocket_delegates_to_openai_ws_handler`,
which connects a WebSocket to `/p/test-project/v1/responses`.
- Observed result: the connection is accepted (no 403), the handler is
reached with the canonical `/v1/responses` path, and the request is
attributed to project `test-project`.
- Not tested: live end-to-end against a real upstream Responses
WebSocket server (validated via the routing/attribution regression test
only).

## Review Readiness

- [x] I have performed a self-review
- [x] This PR is ready for human review

## Checklist

- [x] My code follows the project's style guidelines
- [x] I have performed a self-review of my code
- [x] I have commented my code, particularly in hard-to-understand areas
- [ ] I have made corresponding changes to the documentation
- [x] My changes generate no new warnings
- [x] I have added tests that prove my fix is effective or that my
feature works
- [x] New and existing unit tests pass locally with my changes
- [x] I did **not** edit `CHANGELOG.md` — it is generated by
release-please from my Conventional Commit PR title (a CI guard enforces
this)

## Screenshots (if applicable)

N/A — backend routing change with no user-facing UI.

## Additional Notes

Documentation checklist item is N/A: this is an internal routing fix
with no configuration or public-API surface change. The fix mirrors the
existing HTTP prefix-strip behavior so project-prefixed WebSocket
clients behave identically to their HTTP counterparts.

Co-authored-by: Matt Van Horn <455140+mvanhorn@users.noreply.github.com>
This commit is contained in:
Matt Van Horn 2026-08-03 11:15:40 -07:00 committed by GitHub
parent f9db5b5060
commit 789a4f3060
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 53 additions and 3 deletions

View file

@ -75,7 +75,11 @@ from headroom.proxy.outcome import RequestOutcome
from headroom.proxy.passthrough import ( from headroom.proxy.passthrough import (
custom_base_passthrough_telemetry as _custom_base_passthrough_telemetry, custom_base_passthrough_telemetry as _custom_base_passthrough_telemetry,
) )
from headroom.proxy.project_context import classify_project, set_current_project from headroom.proxy.project_context import (
classify_project,
get_current_project,
set_current_project,
)
from headroom.proxy.token_counting import gemini_output_tokens from headroom.proxy.token_counting import gemini_output_tokens
logger = logging.getLogger("headroom.proxy") logger = logging.getLogger("headroom.proxy")
@ -5726,8 +5730,11 @@ class OpenAIHandlerMixin:
# Captured in closure so per-turn RequestOutcome can stamp it. # Captured in closure so per-turn RequestOutcome can stamp it.
client = classify_client(ws_headers) client = classify_client(ws_headers)
# WS sessions bypass the HTTP middleware, so bind the project here; # WS sessions bypass the HTTP middleware, so bind the project here;
# per-turn outcome emission inside this task inherits the context. # per-turn outcome emission inside this task inherits the context. An
set_current_project(classify_project(ws_headers)) # explicit X-Headroom-Project header wins; otherwise fall back to the
# /p/<name> path prefix already bound by WebSocketProjectPrefixMiddleware
# so prefix-only clients (aider, Copilot BYOK, Cursor) stay attributed.
set_current_project(classify_project(ws_headers) or get_current_project())
metrics_for_inbound_ws = getattr(self, "metrics", None) metrics_for_inbound_ws = getattr(self, "metrics", None)
if metrics_for_inbound_ws is not None and hasattr( if metrics_for_inbound_ws is not None and hasattr(
metrics_for_inbound_ws, "record_inbound_request" metrics_for_inbound_ws, "record_inbound_request"

View file

@ -2364,6 +2364,22 @@ _is_known_websocket_callback_failure = is_known_websocket_callback_failure
_tool_schema_saved_from_tags = tool_schema_saved_from_tags _tool_schema_saved_from_tags = tool_schema_saved_from_tags
class WebSocketProjectPrefixMiddleware:
"""Normalize project-prefixed WebSocket paths before route matching."""
def __init__(self, app: Any) -> None:
self.app = app
async def __call__(self, scope: Any, receive: Any, send: Any) -> None:
if scope["type"] == "websocket":
prefix_project = strip_project_path_prefix(scope)
headers = {
name.decode("latin-1"): value.decode("latin-1") for name, value in scope["headers"]
}
set_current_project(classify_project(headers) or prefix_project)
await self.app(scope, receive, send)
def create_app(config: ProxyConfig | None = None) -> FastAPI: def create_app(config: ProxyConfig | None = None) -> FastAPI:
"""Create FastAPI application.""" """Create FastAPI application."""
if not FASTAPI_AVAILABLE: if not FASTAPI_AVAILABLE:
@ -2573,6 +2589,7 @@ def create_app(config: ProxyConfig | None = None) -> FastAPI:
version=__version__, version=__version__,
lifespan=lifespan, lifespan=lifespan,
) )
app.add_middleware(WebSocketProjectPrefixMiddleware)
loop_health_state: LoopHealthState = { loop_health_state: LoopHealthState = {
"status": "healthy", "status": "healthy",
"known_failures": 0, "known_failures": 0,

View file

@ -10,6 +10,7 @@ from fastapi.responses import JSONResponse
from fastapi.testclient import TestClient from fastapi.testclient import TestClient
from headroom.providers.codex.runtime import CodexRoutingDecision from headroom.providers.codex.runtime import CodexRoutingDecision
from headroom.proxy.project_context import get_current_project
from headroom.proxy.server import HeadroomProxy, ProxyConfig, create_app from headroom.proxy.server import HeadroomProxy, ProxyConfig, create_app
@ -481,6 +482,31 @@ def test_openai_response_websocket_aliases_delegate_to_openai_ws_handler(monkeyp
] ]
def test_project_prefixed_openai_response_websocket_delegates_to_openai_ws_handler(
monkeypatch,
) -> None:
seen_paths: list[str] = []
seen_projects: list[str | None] = []
async def fake_ws(self, websocket): # type: ignore[no-untyped-def]
seen_paths.append(websocket.url.path)
seen_projects.append(get_current_project())
await websocket.accept()
await websocket.send_json({"path": websocket.url.path})
await websocket.close()
monkeypatch.setattr(HeadroomProxy, "handle_openai_responses_ws", fake_ws)
with TestClient(_app()) as client:
with client.websocket_connect("/p/test-project/v1/responses") as websocket:
assert websocket.receive_json() == {"path": "/v1/responses"}
assert seen_paths == ["/v1/responses"]
# The /p/<name> prefix is bound as the project even without a header, so a
# prefix-only Codex WS client is still attributed (not just routed).
assert seen_projects == ["test-project"]
def test_openai_response_subpath_passthrough_returns_502_on_http_failure() -> None: def test_openai_response_subpath_passthrough_returns_502_on_http_failure() -> None:
class FailingAsyncClient: class FailingAsyncClient:
async def request(self, method, url, **kwargs): # type: ignore[no-untyped-def] async def request(self, method, url, **kwargs): # type: ignore[no-untyped-def]