Skip to content

fix(agent-server): negotiate WebSocket subprotocols through the app-backend bridge - #5763

Open
enyst wants to merge 2 commits into
mainfrom
claude/umbrella-bug-fixes-tgz9ai-bridge-ws-subprotocol
Open

enyst wants to merge 2 commits into
mainfrom
claude/umbrella-bug-fixes-tgz9ai-bridge-ws-subprotocol

Conversation

@enyst

@enyst enyst commented Oct 13, 2026 •

Copy link
Copy Markdown
Member

HUMAN:
This PR proposes a fix for a bug uncovered during the first full agentic run of verify-agent-server.


AGENT:

Why

The app-backend bridge accepted the browser’s WebSocket before negotiating upstream, so the selected subprotocol was lost and browsers could reject the connection.

Summary

 browser offers protocols
      ↓ bridge forwards offers
 app backend selects protocol
      ↓ bridge accepts browser with that selection
 bidirectional frames

 upstream handshake fails → accept without protocol → close 1011

Let the app bridge defer client acceptance until upstream negotiation succeeds. The shared bridge keeps its existing default for authenticated Docker callers; the app route opts into deferred acceptance.

Source: bridge.py · proxy.py.

Issue Number

Fixes #5648

How to Test

Previously recorded verification
  • New live tests in tests/agent_server/canvas_extensions/test_canvas_extension_bridge.py use real uvicorn and websockets servers. test_live_websocket_negotiates_backend_subprotocol covers four cases: the backend selects the offered qa.v1; it picks qa.v1 from several offers; the offer is one the backend does not select, so the result is none; there is no offer. test_live_websocket_upstream_refusal_closes_with_1011 covers an upstream refusal. On main, 3 of the 5 fail; on this branch all pass. uv run pytest tests/agent_server/canvas_extensions/test_canvas_extension_bridge.py tests/agent_server/docker_runtime: 123 passed.
  • Map proof, control-agent-server map run --file F29 --only F29.ws-subprotocol --fresh --repeat 2:
    • main (--checkout, dfd8fe8): xfail both times.
    • this branch: xpass both times.
    • after the marker was removed: map check is ok and the bullet is pass.
  • Neighbouring bullets on this branch all pass: F29.ws-bridge (root route still closes with 1011), F29.ws-handshake-rejections, F29.session-revoke, F29.backend-lifecycle-revokes and F29.uninstall-revokes.
  • uv run pre-commit run --files <changed files>: all checks pass.

Video/Screenshots

n/a

Type

  • Bug fix
  • Feature
  • Refactor
  • Breaking change
  • Docs / chore

Notes

Failure deliberately retains accept-then-close-1011 behavior rather than refusing the handshake with 403. There is no new reconnect behavior or TypeScript WebSocket client. Previously recorded verification includes an unrelated process-recovery test failure reproduced on main.

Visual explanation by Codex using HumanLayer show-me.


🐳 Agent Server images for this PR — GHCR package, pull/run commands, and all pushed tags (click to expand)

• GHCR package: https://github.com/OpenHands/agent-sdk/pkgs/container/agent-server

Variants & Base Images

Variant Architectures Base Image Docs / Tags
java amd64, arm64 eclipse-temurin:17-jdk Link
python-slim amd64, arm64 python-node-runtime Link
python-minimal amd64, arm64 python-node-runtime Link
python amd64, arm64 python-node-runtime Link
golang amd64, arm64 golang:1.21-bookworm Link

Pull (multi-arch manifest)

# Each variant is a multi-arch manifest supporting both amd64 and arm64
docker pull ghcr.io/openhands/agent-server:9cae98a-python

Run

docker run -it --rm \
  -p 8000:8000 \
  --name agent-server-9cae98a-python \
  ghcr.io/openhands/agent-server:9cae98a-python

All tags pushed for this build

ghcr.io/openhands/agent-server:9cae98a-golang-amd64
ghcr.io/openhands/agent-server:9cae98a15d4d467547974498b8ff68f926e5dfde-golang-amd64
ghcr.io/openhands/agent-server:claude-umbrella-bug-fixes-tgz9ai-bridge-ws-subprotocol-golang-amd64
ghcr.io/openhands/agent-server:9cae98a-golang_tag_1.21-bookworm-amd64
ghcr.io/openhands/agent-server:9cae98a-golang-arm64
ghcr.io/openhands/agent-server:9cae98a15d4d467547974498b8ff68f926e5dfde-golang-arm64
ghcr.io/openhands/agent-server:claude-umbrella-bug-fixes-tgz9ai-bridge-ws-subprotocol-golang-arm64
ghcr.io/openhands/agent-server:9cae98a-golang_tag_1.21-bookworm-arm64
ghcr.io/openhands/agent-server:9cae98a-java-amd64
ghcr.io/openhands/agent-server:9cae98a15d4d467547974498b8ff68f926e5dfde-java-amd64
ghcr.io/openhands/agent-server:claude-umbrella-bug-fixes-tgz9ai-bridge-ws-subprotocol-java-amd64
ghcr.io/openhands/agent-server:9cae98a-eclipse-temurin_tag_17-jdk-amd64
ghcr.io/openhands/agent-server:9cae98a-java-arm64
ghcr.io/openhands/agent-server:9cae98a15d4d467547974498b8ff68f926e5dfde-java-arm64
ghcr.io/openhands/agent-server:claude-umbrella-bug-fixes-tgz9ai-bridge-ws-subprotocol-java-arm64
ghcr.io/openhands/agent-server:9cae98a-eclipse-temurin_tag_17-jdk-arm64
ghcr.io/openhands/agent-server:9cae98a-python-amd64
ghcr.io/openhands/agent-server:9cae98a15d4d467547974498b8ff68f926e5dfde-python-amd64
ghcr.io/openhands/agent-server:claude-umbrella-bug-fixes-tgz9ai-bridge-ws-subprotocol-python-amd64
ghcr.io/openhands/agent-server:9cae98a-python-node-runtime-amd64
ghcr.io/openhands/agent-server:9cae98a-python-arm64
ghcr.io/openhands/agent-server:9cae98a15d4d467547974498b8ff68f926e5dfde-python-arm64
ghcr.io/openhands/agent-server:claude-umbrella-bug-fixes-tgz9ai-bridge-ws-subprotocol-python-arm64
ghcr.io/openhands/agent-server:9cae98a-python-node-runtime-arm64
ghcr.io/openhands/agent-server:9cae98a-python-minimal-amd64
ghcr.io/openhands/agent-server:9cae98a15d4d467547974498b8ff68f926e5dfde-python-minimal-amd64
ghcr.io/openhands/agent-server:claude-umbrella-bug-fixes-tgz9ai-bridge-ws-subprotocol-python-minimal-amd64
ghcr.io/openhands/agent-server:9cae98a-python-node-runtime-minimal-amd64
ghcr.io/openhands/agent-server:9cae98a-python-minimal-arm64
ghcr.io/openhands/agent-server:9cae98a15d4d467547974498b8ff68f926e5dfde-python-minimal-arm64
ghcr.io/openhands/agent-server:claude-umbrella-bug-fixes-tgz9ai-bridge-ws-subprotocol-python-minimal-arm64
ghcr.io/openhands/agent-server:9cae98a-python-node-runtime-minimal-arm64
ghcr.io/openhands/agent-server:9cae98a-python-slim-amd64
ghcr.io/openhands/agent-server:9cae98a15d4d467547974498b8ff68f926e5dfde-python-slim-amd64
ghcr.io/openhands/agent-server:claude-umbrella-bug-fixes-tgz9ai-bridge-ws-subprotocol-python-slim-amd64
ghcr.io/openhands/agent-server:9cae98a-python-node-runtime-slim-amd64
ghcr.io/openhands/agent-server:9cae98a-python-slim-arm64
ghcr.io/openhands/agent-server:9cae98a15d4d467547974498b8ff68f926e5dfde-python-slim-arm64
ghcr.io/openhands/agent-server:claude-umbrella-bug-fixes-tgz9ai-bridge-ws-subprotocol-python-slim-arm64
ghcr.io/openhands/agent-server:9cae98a-python-node-runtime-slim-arm64
ghcr.io/openhands/agent-server:9cae98a-golang
ghcr.io/openhands/agent-server:9cae98a15d4d467547974498b8ff68f926e5dfde-golang
ghcr.io/openhands/agent-server:claude-umbrella-bug-fixes-tgz9ai-bridge-ws-subprotocol-golang
ghcr.io/openhands/agent-server:9cae98a-golang_tag_1.21-bookworm
ghcr.io/openhands/agent-server:9cae98a-java
ghcr.io/openhands/agent-server:9cae98a15d4d467547974498b8ff68f926e5dfde-java
ghcr.io/openhands/agent-server:claude-umbrella-bug-fixes-tgz9ai-bridge-ws-subprotocol-java
ghcr.io/openhands/agent-server:9cae98a-eclipse-temurin_tag_17-jdk
ghcr.io/openhands/agent-server:9cae98a-python-minimal
ghcr.io/openhands/agent-server:9cae98a15d4d467547974498b8ff68f926e5dfde-python-minimal
ghcr.io/openhands/agent-server:claude-umbrella-bug-fixes-tgz9ai-bridge-ws-subprotocol-python-minimal
ghcr.io/openhands/agent-server:9cae98a-python-node-runtime-minimal
ghcr.io/openhands/agent-server:9cae98a-python-slim
ghcr.io/openhands/agent-server:9cae98a15d4d467547974498b8ff68f926e5dfde-python-slim
ghcr.io/openhands/agent-server:claude-umbrella-bug-fixes-tgz9ai-bridge-ws-subprotocol-python-slim
ghcr.io/openhands/agent-server:9cae98a-python-node-runtime-slim
ghcr.io/openhands/agent-server:9cae98a-python
ghcr.io/openhands/agent-server:9cae98a15d4d467547974498b8ff68f926e5dfde-python
ghcr.io/openhands/agent-server:claude-umbrella-bug-fixes-tgz9ai-bridge-ws-subprotocol-python
ghcr.io/openhands/agent-server:9cae98a-python-node-runtime

About Multi-Architecture Support

  • Each variant tag (e.g., 9cae98a-python) is a multi-arch manifest supporting both amd64 and arm64
  • Docker automatically pulls the correct architecture for your platform
  • Individual architecture tags (e.g., 9cae98a-python-amd64) are also available if needed

…ackend bridge

The /app-backends WebSocket bridge accepted the client before connecting
upstream and never forwarded the Sec-WebSocket-Protocol offer, so the 101
selected no subprotocol and browsers clients that require one failed the
handshake. The bridge now connects to the backend first with the client's
offer and accepts the client with the subprotocol the backend selected.
Upstream failures still accept and close with 1011. The Docker-runtime
socket routes keep their accept-before-bridge behaviour.

Drops the known-bug marker on F29.ws-subprotocol in the verify-agent-server map.

Fixes #5648

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014MKndVj7XWvRQmvW9w2bDg
@github-actions

github-actions Bot commented Oct 13, 2026 •

Copy link
Copy Markdown
Contributor

REST API breakage checks (OpenAPI) — ✅ PASSED

Result: ✅ PASSED

Action log

@github-actions

github-actions Bot commented Oct 13, 2026 •

Copy link
Copy Markdown
Contributor

Coverage

Coverage Report •
FileStmtsMissCoverMissing
openhands-agent-server/openhands/agent_server/canvas_extensions
   bridge.py2892691%113, 129, 144, 160–162, 227–228, 235, 241, 251, 260, 263, 266, 312, 320, 341, 354, 360, 386, 401, 456, 509–511, 516
openhands-agent-server/openhands/agent_server/docker_runtime
   proxy.py1271191%121, 171, 233, 255, 258, 275, 279, 285, 289, 306–307
TOTAL516602044160% 

@enyst
enyst marked this pull request as ready for review October 13, 2026 21:36
@enyst
enyst requested a review from all-hands-bot October 13, 2026 21:38

@all-hands-bot all-hands-bot left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This review was posted by an AI agent (OpenHands).

Scope

In-scope. The change is in the Agent Server's own WebSocket bridge (openhands-agent-server/.../docker_runtime/proxy.py and canvas_extensions/bridge.py), which this repository owns per root AGENTS.md. The one design choice the PR raises — what the client sees when the upstream backend refuses the socket — is resolved in-repo: keep existing behaviour (accept with no subprotocol, close 1011), which is documented in the code docstring and the F29.ws-subprotocol/F29.ws-bridge map bullets. That does not require a cross-repository product decision.

What I verified on head 5fe7262

  • bridge_websocket gains an opt-in accept_client flag. With it set, the client's offered subprotocols are forwarded via websockets.connect(subprotocols=...), the upstream connection is established first, and the client is accepted with upstream_ws.subprotocol — None when the backend selects none. This is the fix the linked issue #5648 root-caused.
  • The docker-runtime callers (docker_runtime/routers.py) do not set the flag, so their accept-during-auth path is unchanged; subprotocols=None and the guarded if accept_client: keep that path equivalent.
  • proxy_app_backend_websocket no longer accepts early and passes accept_client=True instead.
  • The new _close_client_after_upstream_failure preserves the documented upstream-failure behaviour: it accepts a still-CONNECTING client and closes 1011, so the pre-change observable behaviour (open, then close 1011) is retained even though accept now happens later. Existing callers with an already-accepted socket hit the simple close(1011) branch.
  • Linked issue #5648 is OPEN with bug and ready-for-dev; its four acceptance criteria (bridge forwards the offer; selects none when the backend does; real-server test for both; F29.ws-subprotocol marker removed and passing) are all met by this head. The map bullet drops the known bug label and # bug marker and its prose matches the implemented behaviour.

Test and CI evidence

  • Ran uv run pytest tests/agent_server/canvas_extensions/test_canvas_extension_bridge.py → 13 passed, including the four new test_live_websocket_negotiates_backend_subprotocol parametrizations and test_live_websocket_upstream_refusal_closes_with_1011.
  • Ran uv run pytest tests/agent_server/docker_runtime → 110 passed.
  • Current-head GitHub Actions: 54 check runs, 0 non-success, 0 non-completed (including agent-server-tests, sdk-tests, cross-tests, windows-tests, endpoint-audit, coverage-report).
  • The new live tests use real uvicorn/websockets servers rather than mocks, and the PR supplies before/after map evidence (main xfail, head xpass, map check ok), which is adequate live evidence for this bug.

No material correctness, security, compatibility, or acceptance-criterion defect found on the current head.

✅ APPROVED

…ubprotocol

Co-authored-by: openhands <openhands@all-hands.dev>
@github-actions

Copy link
Copy Markdown
Contributor

📁 PR Artifacts Notice

This PR contains a .pr/ directory with temporary PR-specific documents. The directory will be automatically removed after this PR merges to main through a cleanup PR.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Bug]: /app-backends WebSocket bridge negotiates no subprotocol (expected the backend's choice, qa.v1)

3 participants