Compare commits

..

3 Commits

Author SHA1 Message Date
DanielWalnut 2b795265e7 fix: align auth-disabled mode and mock history loading (#3471)
* fix: align auth-disabled mode and mock history loading

* fix: address auth-disabled review feedback

* test: cover auth-disabled backend contract

* style: format frontend tests

* fix: address follow-up review comments
2026-06-10 16:11:00 +08:00
Nan Gao a57d05fe0a fix runtime journal run lifecycle events (#3470) 2026-06-10 08:33:29 +08:00
Lucy Shen ae9e8bc0bf fix(sandbox): make missing sandbox.mounts host_path a loud ERROR (#3244) (#3250)
In Docker production deployments, LocalSandboxProvider runs inside the
deer-flow-gateway container, so any `sandbox.mounts[].host_path` from
config.yaml is resolved against the gateway container's filesystem — not
the host machine. When the path isn't also bind-mounted into the gateway
service, the mount was silently dropped with only a WARNING log, leaving
agents reading an empty directory in production while the same config
worked under `make dev`.

Escalate the missing-host_path branch to logger.error with explicit
guidance about Docker bind mounts and docker-compose, so the failure is
hard to miss in default log configurations. Skip behaviour is preserved
to avoid breaking existing deployments.

Also clarify the misleading `VolumeMountConfig.host_path` field
description so it documents reality for both providers:

  - LocalSandboxProvider checks host_path from inside the gateway process
    (host in `make dev`, container in `make up`).
  - AioSandboxProvider (DooD) passes host_path straight to `docker -v`
    for the sandbox container, where the host Docker daemon resolves it
    from the host machine's perspective.

config.example.yaml's `sandbox.mounts` comment gets a Note: block
pointing operators at the docker-compose bind-mount requirement so the
Docker-mode gotcha is discoverable from the canonical template.

Adds a regression test that:
  - confirms missing host_path is still skipped (no behaviour break);
  - asserts an ERROR record is emitted referencing the offending paths;
  - asserts the message contains actionable Docker/gateway/docker-compose
    keywords so future refactors can't quietly downgrade it.

Refs: https://github.com/bytedance/deer-flow/issues/3244
2026-06-09 23:16:14 +08:00
24 changed files with 630 additions and 61 deletions
+2
View File
@@ -6,6 +6,7 @@ from contextlib import asynccontextmanager
from fastapi import FastAPI from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.cors import CORSMiddleware
from app.gateway.auth_disabled import warn_if_auth_disabled_enabled
from app.gateway.auth_middleware import AuthMiddleware from app.gateway.auth_middleware import AuthMiddleware
from app.gateway.config import get_gateway_config from app.gateway.config import get_gateway_config
from app.gateway.csrf_middleware import CSRFMiddleware, get_configured_cors_origins from app.gateway.csrf_middleware import CSRFMiddleware, get_configured_cors_origins
@@ -172,6 +173,7 @@ async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
startup_config = get_app_config() startup_config = get_app_config()
apply_logging_level(startup_config.log_level) apply_logging_level(startup_config.log_level)
logger.info("Configuration loaded successfully") logger.info("Configuration loaded successfully")
warn_if_auth_disabled_enabled()
except Exception as e: except Exception as e:
error_msg = f"Failed to load configuration during gateway startup: {e}" error_msg = f"Failed to load configuration during gateway startup: {e}"
logger.exception(error_msg) logger.exception(error_msg)
+54
View File
@@ -0,0 +1,54 @@
"""Shared helpers for local/E2E auth-disabled mode."""
from __future__ import annotations
import logging
import os
from types import SimpleNamespace
AUTH_DISABLED_ENV_VAR = "DEER_FLOW_AUTH_DISABLED"
AUTH_DISABLED_USER_ID = "e2e-user"
AUTH_DISABLED_USER_EMAIL = "e2e@test.local"
AUTH_SOURCE_SESSION = "session"
AUTH_SOURCE_INTERNAL = "internal"
AUTH_SOURCE_AUTH_DISABLED = "auth_disabled"
_PRODUCTION_ENV_VARS: tuple[str, ...] = ("DEER_FLOW_ENV", "ENVIRONMENT")
_PRODUCTION_ENV_VALUES: frozenset[str] = frozenset({"prod", "production"})
logger = logging.getLogger(__name__)
def is_explicit_production_environment() -> bool:
return any(os.environ.get(name, "").strip().lower() in _PRODUCTION_ENV_VALUES for name in _PRODUCTION_ENV_VARS)
def is_auth_disabled_requested() -> bool:
return os.environ.get(AUTH_DISABLED_ENV_VAR) == "1"
def is_auth_disabled() -> bool:
return is_auth_disabled_requested() and not is_explicit_production_environment()
def warn_if_auth_disabled_enabled() -> None:
if not is_auth_disabled():
return
logger.warning(
"%s=1 is active: authentication is bypassed and anonymous requests run as synthetic admin user %r. Do not enable this in shared or production deployments.",
AUTH_DISABLED_ENV_VAR,
AUTH_DISABLED_USER_ID,
)
def get_auth_disabled_user():
return SimpleNamespace(
id=AUTH_DISABLED_USER_ID,
email=AUTH_DISABLED_USER_EMAIL,
password_hash=None,
system_role="admin",
needs_setup=False,
token_version=0,
)
+39 -22
View File
@@ -17,6 +17,13 @@ from starlette.responses import JSONResponse
from starlette.types import ASGIApp from starlette.types import ASGIApp
from app.gateway.auth.errors import AuthErrorCode, AuthErrorResponse from app.gateway.auth.errors import AuthErrorCode, AuthErrorResponse
from app.gateway.auth_disabled import (
AUTH_SOURCE_AUTH_DISABLED,
AUTH_SOURCE_INTERNAL,
AUTH_SOURCE_SESSION,
get_auth_disabled_user,
is_auth_disabled,
)
from app.gateway.authz import _ALL_PERMISSIONS, AuthContext from app.gateway.authz import _ALL_PERMISSIONS, AuthContext
from app.gateway.internal_auth import INTERNAL_AUTH_HEADER_NAME, get_internal_user, is_valid_internal_auth_token from app.gateway.internal_auth import INTERNAL_AUTH_HEADER_NAME, get_internal_user, is_valid_internal_auth_token
from deerflow.runtime.user_context import reset_current_user, set_current_user from deerflow.runtime.user_context import reset_current_user, set_current_user
@@ -80,8 +87,38 @@ class AuthMiddleware(BaseHTTPMiddleware):
if is_valid_internal_auth_token(request.headers.get(INTERNAL_AUTH_HEADER_NAME)): if is_valid_internal_auth_token(request.headers.get(INTERNAL_AUTH_HEADER_NAME)):
internal_user = get_internal_user() internal_user = get_internal_user()
auth_source = AUTH_SOURCE_SESSION
access_token = request.cookies.get("access_token")
# Non-public path: require session cookie # Non-public path: require session cookie
if internal_user is None and not request.cookies.get("access_token"): if internal_user is not None:
user = internal_user
auth_source = AUTH_SOURCE_INTERNAL
elif access_token:
# Strict JWT validation: reject junk/expired tokens with 401
# right here instead of silently passing through. This closes
# the "junk cookie bypass" gap (AUTH_TEST_PLAN test 7.5.8):
# without this, non-isolation routes like /api/models would
# accept any cookie-shaped string as authentication.
#
# We call the *strict* resolver so that fine-grained error
# codes (token_expired, token_invalid, user_not_found, …)
# propagate from AuthErrorCode, not get flattened into one
# generic code. BaseHTTPMiddleware doesn't let HTTPException
# bubble up, so we catch and render it as JSONResponse here.
from app.gateway.deps import get_current_user_from_request
try:
user = await get_current_user_from_request(request)
except HTTPException as exc:
if not is_auth_disabled():
return JSONResponse(status_code=exc.status_code, content={"detail": exc.detail})
user = get_auth_disabled_user()
auth_source = AUTH_SOURCE_AUTH_DISABLED
elif is_auth_disabled():
user = get_auth_disabled_user()
auth_source = AUTH_SOURCE_AUTH_DISABLED
else:
return JSONResponse( return JSONResponse(
status_code=401, status_code=401,
content={ content={
@@ -92,32 +129,12 @@ class AuthMiddleware(BaseHTTPMiddleware):
}, },
) )
# Strict JWT validation: reject junk/expired tokens with 401
# right here instead of silently passing through. This closes
# the "junk cookie bypass" gap (AUTH_TEST_PLAN test 7.5.8):
# without this, non-isolation routes like /api/models would
# accept any cookie-shaped string as authentication.
#
# We call the *strict* resolver so that fine-grained error
# codes (token_expired, token_invalid, user_not_found, …)
# propagate from AuthErrorCode, not get flattened into one
# generic code. BaseHTTPMiddleware doesn't let HTTPException
# bubble up, so we catch and render it as JSONResponse here.
from app.gateway.deps import get_current_user_from_request
if internal_user is not None:
user = internal_user
else:
try:
user = await get_current_user_from_request(request)
except HTTPException as exc:
return JSONResponse(status_code=exc.status_code, content={"detail": exc.detail})
# Stamp both request.state.user (for the contextvar pattern) # Stamp both request.state.user (for the contextvar pattern)
# and request.state.auth (so @require_permission's "auth is # and request.state.auth (so @require_permission's "auth is
# None" branch short-circuits instead of running the entire # None" branch short-circuits instead of running the entire
# JWT-decode + DB-lookup pipeline a second time per request). # JWT-decode + DB-lookup pipeline a second time per request).
request.state.user = user request.state.user = user
request.state.auth_source = auth_source
request.state.auth = AuthContext(user=user, permissions=_ALL_PERMISSIONS) request.state.auth = AuthContext(user=user, permissions=_ALL_PERMISSIONS)
token = set_current_user(user) token = set_current_user(user)
try: try:
+5
View File
@@ -14,6 +14,8 @@ from starlette.middleware.base import BaseHTTPMiddleware
from starlette.responses import JSONResponse from starlette.responses import JSONResponse
from starlette.types import ASGIApp from starlette.types import ASGIApp
from app.gateway.auth_disabled import is_auth_disabled
CSRF_COOKIE_NAME = "csrf_token" CSRF_COOKIE_NAME = "csrf_token"
CSRF_HEADER_NAME = "X-CSRF-Token" CSRF_HEADER_NAME = "X-CSRF-Token"
CSRF_TOKEN_LENGTH = 64 # bytes CSRF_TOKEN_LENGTH = 64 # bytes
@@ -38,6 +40,9 @@ def should_check_csrf(request: Request) -> bool:
if request.method not in ("POST", "PUT", "DELETE", "PATCH"): if request.method not in ("POST", "PUT", "DELETE", "PATCH"):
return False return False
if is_auth_disabled():
return False
path = request.url.path.rstrip("/") path = request.url.path.rstrip("/")
# Exempt /api/v1/auth/me endpoint # Exempt /api/v1/auth/me endpoint
if path == "/api/v1/auth/me": if path == "/api/v1/auth/me":
+11
View File
@@ -331,6 +331,17 @@ async def get_current_user_from_request(request: Request):
Raises HTTPException 401 if not authenticated. Raises HTTPException 401 if not authenticated.
""" """
state = getattr(request, "state", None)
state_user = getattr(state, "user", None)
from app.gateway.auth_disabled import AUTH_SOURCE_AUTH_DISABLED, AUTH_SOURCE_INTERNAL, AUTH_SOURCE_SESSION
if state_user is not None and getattr(state, "auth_source", None) in {
AUTH_SOURCE_SESSION,
AUTH_SOURCE_AUTH_DISABLED,
AUTH_SOURCE_INTERNAL,
}:
return state_user
from app.gateway.auth import decode_token from app.gateway.auth import decode_token
from app.gateway.auth.errors import AuthErrorCode, AuthErrorResponse, TokenError, token_error_to_code from app.gateway.auth.errors import AuthErrorCode, AuthErrorResponse, TokenError, token_error_to_code
+7
View File
@@ -20,6 +20,7 @@ from langgraph_sdk import Auth
from app.gateway.auth.errors import TokenError from app.gateway.auth.errors import TokenError
from app.gateway.auth.jwt import decode_token from app.gateway.auth.jwt import decode_token
from app.gateway.auth_disabled import AUTH_DISABLED_USER_ID, is_auth_disabled
from app.gateway.deps import get_local_provider from app.gateway.deps import get_local_provider
auth = Auth() auth = Auth()
@@ -38,6 +39,9 @@ def _check_csrf(request) -> None:
if method.upper() not in _CSRF_METHODS: if method.upper() not in _CSRF_METHODS:
return return
if is_auth_disabled():
return
cookie_token = request.cookies.get("csrf_token") cookie_token = request.cookies.get("csrf_token")
header_token = request.headers.get("x-csrf-token") header_token = request.headers.get("x-csrf-token")
@@ -66,6 +70,9 @@ async def authenticate(request):
# are rejected early, even if the cookie carries a valid JWT. # are rejected early, even if the cookie carries a valid JWT.
_check_csrf(request) _check_csrf(request)
if is_auth_disabled():
return AUTH_DISABLED_USER_ID
token = request.cookies.get("access_token") token = request.cookies.get("access_token")
if not token: if not token:
raise Auth.exceptions.HTTPException( raise Auth.exceptions.HTTPException(
+10
View File
@@ -341,9 +341,19 @@ async def change_password(request: Request, response: Response, body: ChangePass
- Re-issues session cookie with new token_version - Re-issues session cookie with new token_version
""" """
from app.gateway.auth.password import hash_password_async, verify_password_async from app.gateway.auth.password import hash_password_async, verify_password_async
from app.gateway.auth_disabled import AUTH_SOURCE_AUTH_DISABLED
user = await get_current_user_from_request(request) user = await get_current_user_from_request(request)
if getattr(request.state, "auth_source", None) == AUTH_SOURCE_AUTH_DISABLED:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=AuthErrorResponse(
code=AuthErrorCode.INVALID_CREDENTIALS,
message="Password changes are not available when DEER_FLOW_AUTH_DISABLED=1.",
).model_dump(),
)
if user.password_hash is None: if user.password_hash is None:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=AuthErrorResponse(code=AuthErrorCode.INVALID_CREDENTIALS, message="OAuth users cannot change password").model_dump()) raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=AuthErrorResponse(code=AuthErrorCode.INVALID_CREDENTIALS, message="OAuth users cannot change password").model_dump())
@@ -4,7 +4,20 @@ from pydantic import BaseModel, ConfigDict, Field
class VolumeMountConfig(BaseModel): class VolumeMountConfig(BaseModel):
"""Configuration for a volume mount.""" """Configuration for a volume mount."""
host_path: str = Field(..., description="Path on the host machine") host_path: str = Field(
...,
description=(
"Source path for the mount. Resolution depends on the active provider: "
"``LocalSandboxProvider`` checks this path from the gateway process — in "
"``make dev`` that is the host machine, but in Docker deployments "
"(``make up`` / docker-compose) it is the path *inside* the "
"``deer-flow-gateway`` container, so the host directory must also be "
"bind-mounted into the gateway service for the mount to take effect. "
"``AioSandboxProvider`` (DooD) passes this value straight to ``docker -v`` "
"for the sandbox container, where it is resolved by the host Docker daemon "
"from the host machine's perspective."
),
)
container_path: str = Field(..., description="Path inside the container") container_path: str = Field(..., description="Path inside the container")
read_only: bool = Field(default=False, description="Whether the mount is read-only") read_only: bool = Field(default=False, description="Whether the mount is read-only")
@@ -164,7 +164,18 @@ class RunJournal(BaseCallbackHandler):
metadata={"caller": caller, **(metadata or {})}, metadata={"caller": caller, **(metadata or {})},
) )
def on_chain_end(self, outputs: Any, *, run_id: UUID, **kwargs: Any) -> None: def on_chain_end(
self,
outputs: Any,
*,
run_id: UUID,
parent_run_id: UUID | None = None,
**kwargs: Any,
) -> None:
# Nested chain ends fire for internal graph nodes; only the root chain
# represents the user-visible run lifecycle.
if parent_run_id is not None:
return
self._put(event_type="run.end", category="outputs", content=outputs, metadata={"status": "success"}) self._put(event_type="run.end", category="outputs", content=outputs, metadata={"status": "success"})
self._flush_sync() self._flush_sync()
@@ -147,7 +147,17 @@ class LocalSandboxProvider(SandboxProvider):
mount.container_path, mount.container_path,
) )
continue continue
# Ensure the host path exists before adding mapping # Ensure the host path exists before adding mapping.
#
# ``host_path`` is resolved against the filesystem of the
# process running this provider — for ``make dev`` that is
# the host machine, but for ``make up`` it is the
# ``deer-flow-gateway`` container, so any host path that
# isn't bind-mounted into the gateway image will be missing
# here. Skipping silently makes this a high-cost-to-debug
# silent failure (sandbox skill / tool reads an empty dir
# instead of the configured mount), so escalate to ERROR
# and include actionable guidance. See #3244.
if host_path.exists(): if host_path.exists():
mappings.append( mappings.append(
PathMapping( PathMapping(
@@ -157,10 +167,16 @@ class LocalSandboxProvider(SandboxProvider):
) )
) )
else: else:
logger.warning( logger.error(
"Mount host_path does not exist, skipping: %s -> %s", "sandbox.mounts entry %s -> %s ignored: host_path %s does not exist from the "
"perspective of the gateway process. In Docker deployments (make up / docker-compose), "
"this path must also be bind-mounted into the gateway container — add a matching "
"volume entry under services.gateway.volumes in docker/docker-compose.yaml (and use "
"the in-container path here), or run in local mode (make dev) where the gateway sees "
"the host filesystem directly.",
mount.host_path, mount.host_path,
mount.container_path, mount.container_path,
mount.host_path,
) )
except Exception as e: except Exception as e:
# Log but don't fail if config loading fails # Log but don't fail if config loading fails
+187 -4
View File
@@ -4,6 +4,7 @@ import pytest
from starlette.testclient import TestClient from starlette.testclient import TestClient
from app.gateway.auth_middleware import AuthMiddleware, _is_public from app.gateway.auth_middleware import AuthMiddleware, _is_public
from app.gateway.csrf_middleware import CSRFMiddleware
# ── _is_public unit tests ───────────────────────────────────────────────── # ── _is_public unit tests ─────────────────────────────────────────────────
@@ -88,7 +89,9 @@ def test_unknown_api_path_is_protected():
def _make_app(): def _make_app():
"""Create a minimal FastAPI app with AuthMiddleware for testing.""" """Create a minimal FastAPI app with AuthMiddleware for testing."""
from fastapi import FastAPI from fastapi import FastAPI, Request
from deerflow.runtime.user_context import get_effective_user_id
app = FastAPI() app = FastAPI()
app.add_middleware(AuthMiddleware) app.add_middleware(AuthMiddleware)
@@ -98,8 +101,16 @@ def _make_app():
return {"status": "ok"} return {"status": "ok"}
@app.get("/api/v1/auth/me") @app.get("/api/v1/auth/me")
async def auth_me(): async def auth_me(request: Request):
return {"id": "1", "email": "test@test.com"} from app.gateway.deps import get_current_user_from_request
user = await get_current_user_from_request(request)
return {
"id": str(user.id),
"email": user.email,
"system_role": user.system_role,
"needs_setup": user.needs_setup,
}
@app.get("/api/v1/auth/setup-status") @app.get("/api/v1/auth/setup-status")
async def setup_status(): async def setup_status():
@@ -109,6 +120,29 @@ def _make_app():
async def models_get(): async def models_get():
return {"models": []} return {"models": []}
@app.get("/api/whoami")
async def whoami(request: Request):
user = request.state.user
return {
"id": str(user.id),
"email": getattr(user, "email", None),
"system_role": getattr(user, "system_role", None),
"context_user_id": get_effective_user_id(),
}
@app.get("/api/current-user-from-dep")
async def current_user_from_dep(request: Request):
from app.gateway.deps import get_current_user_from_request
user = await get_current_user_from_request(request)
state_user = request.state.user
return {
"id": str(user.id),
"state_id": str(state_user.id),
"auth_source": request.state.auth_source,
"context_user_id": get_effective_user_id(),
}
@app.put("/api/mcp/config") @app.put("/api/mcp/config")
async def mcp_put(): async def mcp_put():
return {"ok": True} return {"ok": True}
@@ -132,8 +166,24 @@ def _make_app():
return app return app
def _make_auth_csrf_app():
"""Create a minimal app with production middleware ordering."""
from fastapi import FastAPI
app = FastAPI()
app.add_middleware(AuthMiddleware)
app.add_middleware(CSRFMiddleware)
@app.post("/api/threads/abc/runs/stream")
async def protected_mutation():
return {"ok": True}
return app
@pytest.fixture @pytest.fixture
def client(): def client(monkeypatch):
monkeypatch.delenv("DEER_FLOW_AUTH_DISABLED", raising=False)
return TestClient(_make_app()) return TestClient(_make_app())
@@ -161,6 +211,139 @@ def test_protected_path_no_cookie_returns_401(client):
assert body["detail"]["code"] == "not_authenticated" assert body["detail"]["code"] == "not_authenticated"
def test_auth_disabled_allows_protected_path_without_cookie(monkeypatch):
monkeypatch.setenv("DEER_FLOW_AUTH_DISABLED", "1")
client = TestClient(_make_app())
res = client.get("/api/models")
assert res.status_code == 200
assert res.json() == {"models": []}
def test_auth_disabled_stamps_e2e_admin_user_without_cookie(monkeypatch):
monkeypatch.setenv("DEER_FLOW_AUTH_DISABLED", "1")
client = TestClient(_make_app())
res = client.get("/api/whoami")
assert res.status_code == 200
assert res.json() == {
"id": "e2e-user",
"email": "e2e@test.local",
"system_role": "admin",
"context_user_id": "e2e-user",
}
def test_auth_disabled_auth_me_reuses_middleware_user_without_cookie(monkeypatch):
monkeypatch.setenv("DEER_FLOW_AUTH_DISABLED", "1")
client = TestClient(_make_app())
res = client.get("/api/v1/auth/me")
assert res.status_code == 200
assert res.json() == {
"id": "e2e-user",
"email": "e2e@test.local",
"system_role": "admin",
"needs_setup": False,
}
def test_auth_disabled_does_not_clobber_valid_session_cookie(monkeypatch):
from types import SimpleNamespace
async def fake_current_user(request):
return SimpleNamespace(
id="session-user",
email="session@test.local",
system_role="user",
needs_setup=False,
)
monkeypatch.setenv("DEER_FLOW_AUTH_DISABLED", "1")
monkeypatch.setattr("app.gateway.deps.get_current_user_from_request", fake_current_user)
client = TestClient(_make_app())
res = client.get("/api/whoami", cookies={"access_token": "valid-session"})
assert res.status_code == 200
assert res.json() == {
"id": "session-user",
"email": "session@test.local",
"system_role": "user",
"context_user_id": "session-user",
}
def test_auth_disabled_does_not_clobber_internal_auth_identity(monkeypatch):
from app.gateway.internal_auth import create_internal_auth_headers
from deerflow.runtime.user_context import DEFAULT_USER_ID
monkeypatch.setenv("DEER_FLOW_AUTH_DISABLED", "1")
client = TestClient(_make_app())
res = client.get(
"/api/current-user-from-dep",
headers=create_internal_auth_headers(),
)
assert res.status_code == 200
assert res.json() == {
"id": DEFAULT_USER_ID,
"state_id": DEFAULT_USER_ID,
"auth_source": "internal",
"context_user_id": DEFAULT_USER_ID,
}
def test_auth_disabled_skips_csrf_for_state_changing_requests(monkeypatch):
monkeypatch.setenv("DEER_FLOW_AUTH_DISABLED", "1")
client = TestClient(_make_auth_csrf_app())
res = client.post("/api/threads/abc/runs/stream")
assert res.status_code == 200
assert res.json() == {"ok": True}
def test_auth_disabled_is_ignored_in_explicit_production_env(monkeypatch):
monkeypatch.setenv("DEER_FLOW_AUTH_DISABLED", "1")
monkeypatch.setenv("DEER_FLOW_ENV", "production")
client = TestClient(_make_app())
res = client.get("/api/models")
assert res.status_code == 401
def test_auth_disabled_startup_warning_when_effective(monkeypatch, caplog):
from app.gateway.auth_disabled import warn_if_auth_disabled_enabled
monkeypatch.setenv("DEER_FLOW_AUTH_DISABLED", "1")
monkeypatch.delenv("DEER_FLOW_ENV", raising=False)
monkeypatch.delenv("ENVIRONMENT", raising=False)
with caplog.at_level("WARNING", logger="app.gateway.auth_disabled"):
warn_if_auth_disabled_enabled()
assert "authentication is bypassed" in caplog.text
assert "e2e-user" in caplog.text
def test_auth_disabled_startup_warning_suppressed_in_explicit_production_env(monkeypatch, caplog):
from app.gateway.auth_disabled import warn_if_auth_disabled_enabled
monkeypatch.setenv("DEER_FLOW_AUTH_DISABLED", "1")
monkeypatch.setenv("ENVIRONMENT", "production")
with caplog.at_level("WARNING", logger="app.gateway.auth_disabled"):
warn_if_auth_disabled_enabled()
assert "authentication is bypassed" not in caplog.text
def test_protected_path_with_junk_cookie_rejected(client): def test_protected_path_with_junk_cookie_rejected(client):
"""Junk cookie → 401. Middleware strictly validates the JWT now """Junk cookie → 401. Middleware strictly validates the JWT now
(AUTH_TEST_PLAN test 7.5.8); it no longer silently passes bad (AUTH_TEST_PLAN test 7.5.8); it no longer silently passes bad
+9
View File
@@ -21,6 +21,7 @@ from langgraph_sdk import Auth
from app.gateway.auth.config import AuthConfig, set_auth_config from app.gateway.auth.config import AuthConfig, set_auth_config
from app.gateway.auth.jwt import create_access_token, decode_token from app.gateway.auth.jwt import create_access_token, decode_token
from app.gateway.auth.models import User from app.gateway.auth.models import User
from app.gateway.auth_disabled import AUTH_DISABLED_USER_ID
from app.gateway.langgraph_auth import add_owner_filter, authenticate from app.gateway.langgraph_auth import add_owner_filter, authenticate
# ── Helpers ─────────────────────────────────────────────────────────────── # ── Helpers ───────────────────────────────────────────────────────────────
@@ -59,6 +60,14 @@ def test_no_cookie_raises_401():
assert "Not authenticated" in str(exc.value.detail) assert "Not authenticated" in str(exc.value.detail)
def test_auth_disabled_skips_csrf_and_authenticates_e2e_user(monkeypatch):
monkeypatch.setenv("DEER_FLOW_AUTH_DISABLED", "1")
identity = asyncio.run(authenticate(_req(method="POST")))
assert identity == AUTH_DISABLED_USER_ID
def test_invalid_jwt_raises_401(): def test_invalid_jwt_raises_401():
with pytest.raises(Auth.exceptions.HTTPException) as exc: with pytest.raises(Auth.exceptions.HTTPException) as exc:
asyncio.run(authenticate(_req({"access_token": "garbage"}))) asyncio.run(authenticate(_req({"access_token": "garbage"})))
@@ -612,6 +612,54 @@ class TestLocalSandboxProviderMounts:
assert [m.container_path for m in provider._path_mappings] == ["/mnt/skills"] assert [m.container_path for m in provider._path_mappings] == ["/mnt/skills"]
def test_setup_path_mappings_logs_actionable_error_for_missing_host_path(self, tmp_path, caplog):
"""Regression for #3244.
When ``sandbox.mounts[].host_path`` is absent from the gateway process's
filesystem (the typical symptom in Docker production mode: host_path is a
host machine path that is not bind-mounted into the gateway container),
the mount is still skipped — but the failure must be a hard-to-miss ERROR
log with explicit, actionable guidance about Docker bind mounts, not the
old DEBUG/WARNING that buried the silent failure.
"""
skills_dir = tmp_path / "skills"
skills_dir.mkdir()
missing_host_path = tmp_path / "does-not-exist"
from deerflow.config.sandbox_config import SandboxConfig, VolumeMountConfig
sandbox_config = SandboxConfig(
use="deerflow.sandbox.local:LocalSandboxProvider",
mounts=[
VolumeMountConfig(host_path=str(missing_host_path), container_path="/mnt/knowledge", read_only=True),
],
)
config = SimpleNamespace(
skills=SimpleNamespace(container_path="/mnt/skills", get_skills_path=lambda: skills_dir, use="deerflow.skills.storage.local_skill_storage:LocalSkillStorage"),
sandbox=sandbox_config,
)
with caplog.at_level("ERROR", logger="deerflow.sandbox.local.local_sandbox_provider"):
with patch("deerflow.config.get_app_config", return_value=config):
provider = LocalSandboxProvider()
# Silent-skip behaviour is preserved (no breaking change for existing deployments).
assert [m.container_path for m in provider._path_mappings] == ["/mnt/skills"]
# The failure must be observable at ERROR level and reference the offending paths.
error_records = [r for r in caplog.records if r.levelname == "ERROR"]
assert error_records, "expected an ERROR log when host_path is missing"
message = "\n".join(r.getMessage() for r in error_records)
assert str(missing_host_path) in message
assert "/mnt/knowledge" in message
# And it must include actionable Docker guidance so users don't lose hours
# to a silent empty-mount failure in production.
lowered = message.lower()
assert "docker" in lowered
assert "gateway" in lowered
assert "docker-compose" in lowered
def test_write_file_resolves_container_paths_in_content(self, tmp_path): def test_write_file_resolves_container_paths_in_content(self, tmp_path):
"""write_file should replace container paths in file content with local paths.""" """write_file should replace container paths in file content with local paths."""
data_dir = tmp_path / "data" data_dir = tmp_path / "data"
+4 -3
View File
@@ -179,15 +179,16 @@ class TestLifecycleCallbacks:
assert "run.end" in types assert "run.end" in types
@pytest.mark.anyio @pytest.mark.anyio
async def test_nested_chain_no_run_start(self, journal_setup): async def test_nested_chain_no_run_lifecycle_events(self, journal_setup):
"""Nested chains (parent_run_id set) should NOT produce run.start.""" """Nested chains (parent_run_id set) should NOT produce root run lifecycle events."""
j, store = journal_setup j, store = journal_setup
parent_id = uuid4() parent_id = uuid4()
j.on_chain_start({}, {}, run_id=uuid4(), parent_run_id=parent_id) j.on_chain_start({}, {}, run_id=uuid4(), parent_run_id=parent_id)
j.on_chain_end({}, run_id=uuid4()) j.on_chain_end({}, run_id=uuid4(), parent_run_id=parent_id)
await j.flush() await j.flush()
events = await store.list_events("t1", "r1") events = await store.list_events("t1", "r1")
assert not any(e["event_type"] == "run.start" for e in events) assert not any(e["event_type"] == "run.start" for e in events)
assert not any(e["event_type"] == "run.end" for e in events)
class TestToolCallbacks: class TestToolCallbacks:
+5 -1
View File
@@ -768,8 +768,12 @@ sandbox:
allow_host_bash: false allow_host_bash: false
# Optional: Mount additional host directories into the sandbox. # Optional: Mount additional host directories into the sandbox.
# Each mount maps a host path to a virtual container path accessible by the agent. # Each mount maps a host path to a virtual container path accessible by the agent.
# Note: with LocalSandboxProvider under `make up` (docker-compose), host_path is
# checked from inside the deer-flow-gateway container — you must also bind-mount
# the same directory into services.gateway.volumes in docker/docker-compose.yaml
# for this mount to take effect (see issue #3244).
# mounts: # mounts:
# - host_path: /home/user/my-project # Absolute path on the host machine # - host_path: /home/user/my-project # Absolute path; see note above for Docker mode
# container_path: /mnt/my-project # Virtual path inside the sandbox # container_path: /mnt/my-project # Virtual path inside the sandbox
# read_only: true # Whether the mount is read-only (default: false) # read_only: true # Whether the mount is read-only (default: false)
+7 -3
View File
@@ -7,8 +7,9 @@ import { defineConfig, devices } from "@playwright/test";
* so the mock-based suite is untouched. * so the mock-based suite is untouched.
* *
* Two webServers are started: the replay gateway (:8011) and the frontend * Two webServers are started: the replay gateway (:8011) and the frontend
* (:3000, pointed at the gateway). Auth uses a throwaway test account the spec * (:3000, pointed at the gateway). Auth-disabled mode is enabled on both
* registers at runtime — no secrets. * servers so the no-cookie e2e contract is covered; specs that need session
* cookies still register a throwaway test account at runtime.
*/ */
export default defineConfig({ export default defineConfig({
testDir: "./tests/e2e-real-backend", testDir: "./tests/e2e-real-backend",
@@ -38,7 +39,10 @@ export default defineConfig({
// Mount the test-only run/message seeder used by multi-run-order.spec.ts // Mount the test-only run/message seeder used by multi-run-order.spec.ts
// (#3352). The endpoint exists only on this replay gateway, never in the // (#3352). The endpoint exists only on this replay gateway, never in the
// production app. // production app.
env: { DEERFLOW_ENABLE_TEST_SEED: "1" }, env: {
DEERFLOW_ENABLE_TEST_SEED: "1",
DEER_FLOW_AUTH_DISABLED: "1",
},
}, },
{ {
command: "pnpm build && pnpm start", command: "pnpm build && pnpm start",
@@ -0,0 +1,23 @@
import type { User } from "./types";
export const AUTH_DISABLED_USER: User = {
id: "e2e-user",
email: "e2e@test.local",
system_role: "admin",
needs_setup: false,
};
const PRODUCTION_ENV_VALUES = new Set(["prod", "production"]);
function isExplicitProductionEnvironment() {
return ["DEER_FLOW_ENV", "ENVIRONMENT"].some((name) =>
PRODUCTION_ENV_VALUES.has((process.env[name] ?? "").trim().toLowerCase()),
);
}
export function isAuthDisabledMode() {
return (
process.env.DEER_FLOW_AUTH_DISABLED === "1" &&
!isExplicitProductionEnvironment()
);
}
+3 -7
View File
@@ -2,6 +2,7 @@ import { cookies } from "next/headers";
import { isStaticWebsiteOnly } from "../static-mode"; import { isStaticWebsiteOnly } from "../static-mode";
import { AUTH_DISABLED_USER, isAuthDisabledMode } from "./auth-disabled-user";
import { getGatewayConfig } from "./gateway-config"; import { getGatewayConfig } from "./gateway-config";
import { STATIC_WEBSITE_USER } from "./static-user"; import { STATIC_WEBSITE_USER } from "./static-user";
import { type AuthResult, userSchema } from "./types"; import { type AuthResult, userSchema } from "./types";
@@ -20,15 +21,10 @@ export async function getServerSideUser(): Promise<AuthResult> {
}; };
} }
if (process.env.DEER_FLOW_AUTH_DISABLED === "1") { if (isAuthDisabledMode()) {
return { return {
tag: "authenticated", tag: "authenticated",
user: { user: AUTH_DISABLED_USER,
id: "e2e-user",
email: "e2e@test.local",
system_role: "admin",
needs_setup: false,
},
}; };
} }
+41 -14
View File
@@ -364,7 +364,7 @@ export function useThreadStream({
loadMore: loadMoreHistory, loadMore: loadMoreHistory,
loading: isHistoryLoading, loading: isHistoryLoading,
appendMessages, appendMessages,
} = useThreadHistory(onStreamThreadId ?? ""); } = useThreadHistory(onStreamThreadId ?? "", { enabled: !isMock });
// Keep listeners ref updated with latest callbacks // Keep listeners ref updated with latest callbacks
useEffect(() => { useEffect(() => {
@@ -854,8 +854,15 @@ export function useThreadStream({
} as const; } as const;
} }
export function useThreadHistory(threadId: string) { type ThreadHistoryOptions = {
const runs = useThreadRuns(threadId); enabled?: boolean;
};
export function useThreadHistory(
threadId: string,
{ enabled = true }: ThreadHistoryOptions = {},
) {
const runs = useThreadRuns(threadId, { enabled });
const threadIdRef = useRef(threadId); const threadIdRef = useRef(threadId);
const runsRef = useRef(runs.data ?? []); const runsRef = useRef(runs.data ?? []);
const indexRef = useRef(-1); const indexRef = useRef(-1);
@@ -864,10 +871,15 @@ export function useThreadHistory(threadId: string) {
const loadingRunIdRef = useRef<string | null>(null); const loadingRunIdRef = useRef<string | null>(null);
const loadedRunIdsRef = useRef<Set<string>>(new Set()); const loadedRunIdsRef = useRef<Set<string>>(new Set());
const runBeforeSeqRef = useRef<Map<string, number>>(new Map()); const runBeforeSeqRef = useRef<Map<string, number>>(new Map());
const loadGenerationRef = useRef(0);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [messages, setMessages] = useState<Message[]>([]); const [messages, setMessages] = useState<Message[]>([]);
const loadMessages = useCallback(async () => { const loadMessages = useCallback(async () => {
if (!enabled) {
return;
}
const loadGeneration = loadGenerationRef.current;
if (loadingRef.current) { if (loadingRef.current) {
const pendingRunIndex = findLatestUnloadedRunIndex( const pendingRunIndex = findLatestUnloadedRunIndex(
runsRef.current, runsRef.current,
@@ -921,12 +933,15 @@ export function useThreadHistory(threadId: string) {
}).then((res) => { }).then((res) => {
return res.json(); return res.json();
}); });
if (
loadGenerationRef.current !== loadGeneration ||
threadIdRef.current !== requestThreadId
) {
return;
}
const _messages = result.data const _messages = result.data
.filter((m) => !m.metadata.caller?.startsWith("middleware:")) .filter((m) => !m.metadata.caller?.startsWith("middleware:"))
.map((m) => m.content); .map((m) => m.content);
if (threadIdRef.current !== requestThreadId) {
return;
}
setMessages((prev) => setMessages((prev) =>
dedupeMessagesByIdentity([..._messages, ...prev]), dedupeMessagesByIdentity([..._messages, ...prev]),
); );
@@ -961,16 +976,19 @@ export function useThreadHistory(threadId: string) {
} catch (err) { } catch (err) {
console.error(err); console.error(err);
} finally { } finally {
loadingRef.current = false; if (loadGenerationRef.current === loadGeneration) {
loadingRunIdRef.current = null; loadingRef.current = false;
setLoading(false); loadingRunIdRef.current = null;
setLoading(false);
}
} }
}, []); }, [enabled]);
useEffect(() => { useEffect(() => {
const threadChanged = threadIdRef.current !== threadId; const threadChanged = threadIdRef.current !== threadId;
threadIdRef.current = threadId; threadIdRef.current = threadId;
if (threadChanged) { if (!enabled || threadChanged) {
loadGenerationRef.current += 1;
runsRef.current = []; runsRef.current = [];
indexRef.current = -1; indexRef.current = -1;
pendingLoadRef.current = false; pendingLoadRef.current = false;
@@ -982,6 +1000,10 @@ export function useThreadHistory(threadId: string) {
setMessages([]); setMessages([]);
} }
if (!enabled) {
return;
}
if (runs.data && runs.data.length > 0) { if (runs.data && runs.data.length > 0) {
runsRef.current = runs.data ?? []; runsRef.current = runs.data ?? [];
indexRef.current = findLatestUnloadedRunIndex( indexRef.current = findLatestUnloadedRunIndex(
@@ -992,14 +1014,15 @@ export function useThreadHistory(threadId: string) {
loadMessages().catch(() => { loadMessages().catch(() => {
toast.error("Failed to load thread history."); toast.error("Failed to load thread history.");
}); });
}, [threadId, runs.data, loadMessages]); }, [enabled, threadId, runs.data, loadMessages]);
const appendMessages = useCallback((_messages: Message[]) => { const appendMessages = useCallback((_messages: Message[]) => {
setMessages((prev) => { setMessages((prev) => {
return dedupeMessagesByIdentity([...prev, ..._messages]); return dedupeMessagesByIdentity([...prev, ..._messages]);
}); });
}, []); }, []);
const hasMore = indexRef.current >= 0 || !runs.data; const hasMore =
enabled && Boolean(threadId) && (indexRef.current >= 0 || !runs.data);
return { return {
runs: runs.data, runs: runs.data,
messages, messages,
@@ -1077,7 +1100,10 @@ export function useThreads(
}); });
} }
export function useThreadRuns(threadId?: string) { export function useThreadRuns(
threadId?: string,
{ enabled = true }: { enabled?: boolean } = {},
) {
const apiClient = getAPIClient(); const apiClient = getAPIClient();
return useQuery<Run[]>({ return useQuery<Run[]>({
queryKey: ["thread", threadId], queryKey: ["thread", threadId],
@@ -1088,6 +1114,7 @@ export function useThreadRuns(threadId?: string) {
const response = await apiClient.runs.list(threadId); const response = await apiClient.runs.list(threadId);
return response; return response;
}, },
enabled: enabled && Boolean(threadId),
refetchOnWindowFocus: false, refetchOnWindowFocus: false,
}); });
} }
@@ -0,0 +1,16 @@
import { expect, test } from "@playwright/test";
import { AUTH_DISABLED_USER } from "../../src/core/auth/auth-disabled-user";
const APP = "http://localhost:3000";
test.describe("auth-disabled contract (real backend)", () => {
test("gateway /auth/me returns the frontend synthetic user without a cookie", async ({
context,
}) => {
const resp = await context.request.get(`${APP}/api/v1/auth/me`);
expect(resp.status(), await resp.text()).toBe(200);
await expect(resp.json()).resolves.toEqual(AUTH_DISABLED_USER);
});
});
@@ -101,10 +101,11 @@ test.describe("real backend render (replay, no API key)", () => {
EXPECTED_SUGGESTION, EXPECTED_SUGGESTION,
"fixture should contain a suggestions turn (re-record; the record spec waits for /suggestions)", "fixture should contain a suggestions turn (re-record; the record spec waits for /suggestions)",
).not.toBe(""); ).not.toBe("");
await expect(page.getByText(EXPECTED_TITLE)).toBeVisible({ const chat = page.locator("#chat");
await expect(chat.getByText(EXPECTED_TITLE)).toBeVisible({
timeout: 60_000, timeout: 60_000,
}); });
await expect(page.getByText(EXPECTED_SUGGESTION)).toBeVisible({ await expect(chat.getByText(EXPECTED_SUGGESTION)).toBeVisible({
timeout: 30_000, timeout: 30_000,
}); });
+1
View File
@@ -12,6 +12,7 @@ test.describe("Chat workspace", () => {
const textarea = page.getByPlaceholder(/how can i assist you/i); const textarea = page.getByPlaceholder(/how can i assist you/i);
await expect(textarea).toBeVisible({ timeout: 15_000 }); await expect(textarea).toBeVisible({ timeout: 15_000 });
await expect(page.getByRole("button", { name: /load more/i })).toBeHidden();
}); });
test("can type a message in the input box", async ({ page }) => { test("can type a message in the input box", async ({ page }) => {
+79
View File
@@ -18,6 +18,7 @@ const THREADS = [
updated_at: "2025-06-02T12:00:00Z", updated_at: "2025-06-02T12:00:00Z",
}, },
]; ];
const DEMO_THREAD_ID = "7cfa5f8f-a2f8-47ad-acbd-da7137baf990";
test.describe("Thread history", () => { test.describe("Thread history", () => {
test("sidebar shows existing threads", async ({ page }) => { test("sidebar shows existing threads", async ({ page }) => {
@@ -61,6 +62,84 @@ test.describe("Thread history", () => {
).toBeVisible({ timeout: 15_000 }); ).toBeVisible({ timeout: 15_000 });
}); });
test("mock thread does not load real backend run history", async ({
page,
}) => {
mockLangGraphAPI(page, {
threads: [
{
thread_id: DEMO_THREAD_ID,
title: "Forecasting 2026 Trends and Opportunities",
updated_at: "2025-06-01T12:00:00Z",
messages: [
{
type: "human",
id: `run-human-${DEMO_THREAD_ID}`,
content: [
{
type: "text",
text: "This run-message endpoint should not be called.",
},
],
},
],
},
],
});
const backendRunHistoryUrls: string[] = [];
await page.route(
/\/api\/langgraph\/threads\/[^/]+\/runs(?:\?|$)/,
(route) => {
if (
route.request().method() === "GET" &&
route
.request()
.url()
.includes(`/api/langgraph/threads/${DEMO_THREAD_ID}/runs`)
) {
backendRunHistoryUrls.push(route.request().url());
return route.fulfill({
status: 500,
contentType: "application/json",
body: JSON.stringify({
error: "mock=true must not load real runs",
}),
});
}
return route.fallback();
},
);
await page.route(
/\/api\/threads\/[^/]+\/runs\/[^/]+\/messages(?:\?|$)/,
(route) => {
if (
route.request().method() === "GET" &&
route.request().url().includes(`/api/threads/${DEMO_THREAD_ID}/runs/`)
) {
backendRunHistoryUrls.push(route.request().url());
return route.fulfill({
status: 500,
contentType: "application/json",
body: JSON.stringify({
error: "mock=true must not load real run messages",
}),
});
}
return route.fallback();
},
);
await page.goto(`/workspace/chats/${DEMO_THREAD_ID}?mock=true`);
await expect(
page.getByText("What might be the trends and opportunities in 2026?"),
).toBeVisible({ timeout: 15_000 });
await expect(
page.getByText("I've created a modern, minimalist website"),
).toBeVisible();
expect(backendRunHistoryUrls).toEqual([]);
});
test("chats list page shows all threads", async ({ page }) => { test("chats list page shows all threads", async ({ page }) => {
mockLangGraphAPI(page, { threads: THREADS }); mockLangGraphAPI(page, { threads: THREADS });
@@ -1,5 +1,6 @@
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import { AUTH_DISABLED_USER } from "@/core/auth/auth-disabled-user";
import { STATIC_WEBSITE_USER } from "@/core/auth/static-user"; import { STATIC_WEBSITE_USER } from "@/core/auth/static-user";
vi.mock("next/headers", () => ({ vi.mock("next/headers", () => ({
@@ -10,6 +11,8 @@ vi.mock("next/headers", () => ({
const ENV_KEYS = [ const ENV_KEYS = [
"DEER_FLOW_AUTH_DISABLED", "DEER_FLOW_AUTH_DISABLED",
"DEER_FLOW_ENV",
"ENVIRONMENT",
"NEXT_PUBLIC_STATIC_WEBSITE_ONLY", "NEXT_PUBLIC_STATIC_WEBSITE_ONLY",
] as const; ] as const;
@@ -51,6 +54,8 @@ describe("getServerSideUser", () => {
beforeEach(() => { beforeEach(() => {
saved = snapshotEnv(); saved = snapshotEnv();
setEnv("DEER_FLOW_AUTH_DISABLED", undefined); setEnv("DEER_FLOW_AUTH_DISABLED", undefined);
setEnv("DEER_FLOW_ENV", undefined);
setEnv("ENVIRONMENT", undefined);
setEnv("NEXT_PUBLIC_STATIC_WEBSITE_ONLY", undefined); setEnv("NEXT_PUBLIC_STATIC_WEBSITE_ONLY", undefined);
}); });
@@ -74,4 +79,30 @@ describe("getServerSideUser", () => {
}); });
expect(fetchSpy).not.toHaveBeenCalled(); expect(fetchSpy).not.toHaveBeenCalled();
}); });
test("bypasses gateway auth in auth-disabled mode", async () => {
setEnv("DEER_FLOW_AUTH_DISABLED", "1");
const fetchSpy = vi.fn(() => {
throw new Error("fetch should not be called in auth-disabled mode");
});
vi.stubGlobal("fetch", fetchSpy);
const { getServerSideUser } = await loadFreshServerAuth();
await expect(getServerSideUser()).resolves.toEqual({
tag: "authenticated",
user: AUTH_DISABLED_USER,
});
expect(fetchSpy).not.toHaveBeenCalled();
});
test("does not enable auth-disabled mode in explicit production environments", async () => {
setEnv("DEER_FLOW_AUTH_DISABLED", "1");
setEnv("DEER_FLOW_ENV", "production");
const { isAuthDisabledMode } =
await import("@/core/auth/auth-disabled-user");
expect(isAuthDisabledMode()).toBe(false);
});
}); });