diff --git a/AGENTS.md b/AGENTS.md index 6513dacf14..c78639d50d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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 diff --git a/README.md b/README.md index fe2db48627..5c51fb9ab9 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/backend/tests/test_nginx_browser_websocket.py b/backend/tests/test_nginx_browser_websocket.py new file mode 100644 index 0000000000..ca0fa61241 --- /dev/null +++ b/backend/tests/test_nginx_browser_websocket.py @@ -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) diff --git a/deploy/helm/deer-flow/templates/configmap-nginx.yaml b/deploy/helm/deer-flow/templates/configmap-nginx.yaml index 7da9530949..30d05bd44b 100644 --- a/deploy/helm/deer-flow/templates/configmap-nginx.yaml +++ b/deploy/helm/deer-flow/templates/configmap-nginx.yaml @@ -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;