fix(helm): forward Live Browser WebSocket upgrades (#7148)

* fix(helm): proxy Live Browser WebSocket upgrades

* fix(helm): use default browser connect and send timeouts
This commit is contained in:
Lucien 2026-10-20 13:17:54 +08:00 committed by GitHub
parent 53f09dc152
commit 2c880a3133
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 86 additions and 0 deletions

View File

@ -31,6 +31,11 @@ Nginx is the single public entry: it proxies `/api/*` to the Gateway, rewriting
HTML and configured textual assets, deliberately leaving SSE, fonts, images, audio, and
video uncompressed at the proxy layer.
Keep the dedicated `/api/threads/{thread_id}/browser/stream` WebSocket regex before
the generic threads regex in Docker, local and Helm nginx configs. Upgrade headers
and the 600s read timeout are pinned by `backend/tests/test_nginx_browser_websocket.py`.
Helm uses nginx's default connect/send timeouts for the in-cluster Gateway.
Both Compose files publish nginx at `"${BIND_HOST:-127.0.0.1}:${PORT:-2026}:2026"`
(README's **loopback default**); bare `"${PORT}:2026"` binds `0.0.0.0`. Root `PORT`
controls Docker ingress only; local Next.js stays on `3000` so `.env` cannot

View File

@ -587,6 +587,8 @@ The unified nginx endpoint is same-origin by default and does not emit browser C
When fine-grained authorization is enabled, Live Browser connections require `threads:write` as well as ownership of the thread, even when only viewing frames: the same connection can control the browser. Permission checks run when connecting. Restart Gateway after upgrading to disconnect sessions admitted by older code.
The bundled Docker, local and Helm nginx configs proxy Live Browser WebSocket upgrades with a 600-second read timeout for quiet streams. Helm uses nginx's default connection and send timeouts for the in-cluster Gateway. Any additional Ingress or reverse proxy must also allow WebSocket upgrades.
Browser login uses `HttpOnly` session cookies. The login page offers a "keep me signed in" option that extends the browser session when the request is HTTPS (including trusted `X-Forwarded-Proto: https`) or localhost HTTP. The localhost exception uses the direct request `Host` and ignores forwarded host headers. Public HTTP deployments, including many temporary sandbox URLs, fall back to session cookies by default. DeerFlow never stores the password in browser storage; the UI may remember only the email address.
OIDC/SSO authorization endpoints may include provider query parameters such as `?prompt=login`. DeerFlow preserves those parameters when adding the login request, including for configured `authorization_endpoint` overrides.

View File

@ -0,0 +1,61 @@
"""Live Browser upgrades must reach Gateway in every maintained nginx config."""
from __future__ import annotations
import re
import shutil
import subprocess
from pathlib import Path
import pytest
import yaml
from support.nginx_conf import NGINX_CONFIGS, extract_location_block, read_config
CHART = Path(__file__).resolve().parents[2] / "deploy" / "helm" / "deer-flow"
BROWSER_PATTERN = r"^/api/threads/[^/]+/browser/stream"
def _assert_browser_websocket_config(config: str, *, helm_defaults: bool = False) -> None:
block = extract_location_block(config, f"~ {BROWSER_PATTERN}")
# nginx selects the first matching regex, so the generic threads location
# must not swallow the WebSocket handshake before this location is reached.
regex_locations = re.findall(r"^\s*location\s+~\s+([^\{]+)\{", config, re.M)
first_match = next((pattern.strip() for pattern in regex_locations if re.match(pattern.strip(), "/api/threads/thread-42/browser/stream")), None)
assert first_match == BROWSER_PATTERN
assert re.search(r"^\s*proxy_pass http://\$?gateway(?:_upstream)?;\s*$", block, re.M)
assert re.search(r"^\s*proxy_set_header X-Forwarded-Proto \$(?:forwarded_proto|scheme);\s*$", block, re.M)
for directive in (
"proxy_http_version 1.1;",
"proxy_set_header Host $http_host;",
"proxy_set_header X-Real-IP $remote_addr;",
"proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;",
"proxy_set_header Upgrade $http_upgrade;",
"proxy_set_header Connection 'upgrade';",
"proxy_cache_bypass $http_upgrade;",
"proxy_read_timeout 600s;",
):
assert re.search(r"^\s*" + re.escape(directive) + r"\s*$", block, re.M), f"Live Browser location is missing {directive}"
if helm_defaults:
# Connecting to Gateway and sending the upgrade request use nginx's 60s
# defaults; the read timeout above keeps quiet WebSockets open.
for directive in ("proxy_connect_timeout", "proxy_send_timeout"):
assert not re.search(r"^\s*" + directive + r"\s+", block, re.M), f"Helm Live Browser location should use the default {directive}"
@pytest.mark.parametrize("path", NGINX_CONFIGS)
def test_live_browser_websocket_config(path: str) -> None:
_assert_browser_websocket_config(read_config(path), helm_defaults=path.startswith("deploy/helm/"))
@pytest.mark.parametrize("provisioner_enabled", [True, False])
def test_rendered_helm_live_browser_websocket_config(provisioner_enabled: bool) -> None:
helm = shutil.which("helm")
if helm is None:
pytest.skip("helm is unavailable")
command = [helm, "template", "deer-flow", str(CHART), "--show-only", "templates/configmap-nginx.yaml", "--set", f"provisioner.enabled={str(provisioner_enabled).lower()}"]
rendered = subprocess.run(command, check=True, capture_output=True, text=True, encoding="utf-8").stdout
config_map = yaml.safe_load(rendered)
_assert_browser_websocket_config(config_map["data"]["nginx.conf"], helm_defaults=True)

View File

@ -204,6 +204,24 @@ data:
proxy_read_timeout 600s;
}
# Live browser stream is a WebSocket upgrade. Match it before the
# generic /api/threads regex so the handshake reaches Gateway with
# the upgrade headers intact.
location ~ ^/api/threads/[^/]+/browser/stream {
proxy_pass http://gateway_upstream;
proxy_http_version 1.1;
proxy_set_header Host $http_host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $forwarded_proto;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection 'upgrade';
proxy_cache_bypass $http_upgrade;
# Allow quiet streams; connection and send timeouts use nginx defaults.
proxy_read_timeout 600s;
}
location ~ ^/api/threads {
proxy_pass http://gateway_upstream;
proxy_http_version 1.1;