Nan Gao f39cdd6077
fix(mcp): keep rotating OAuth refresh tokens across restarts and Gateway instances (#7207)
* fix(mcp): keep rotating OAuth refresh tokens across restarts and Gateway instances

MCP servers on the OAuth refresh_token grant lost their rotated refresh
token whenever a new process or manager started (restart, tool-cache
reset for non-task servers, every personal-connection run, a second
Gateway instance) and re-seeded with the consumed configured token,
getting invalid_grant for good.

- New table mcp_oauth_tokens (migration 0047_mcp_oauth_tokens), one row
  per refresh_token connection keyed (scope, owner_user_id, server_key):
  ('deployment', '', name) or ('personal', owner, personal runtime
  name). Access and refresh tokens are DEER_FLOW_CREDENTIALS_KEY
  ciphertext; a config fingerprint (token_url, client_id, grant, scope,
  audience, seed) makes a changed seed win.
- OAuthTokenManager re-reads the row under its process lock, adopts a
  peer's valid token, and otherwise claims the refresh with a lease plus
  a version compare-and-set (database clock), fetches outside any
  transaction, writes under the lease and releases it on failure.
- The Gateway installs the store (sync engine via to_thread) around the
  LangGraph runtime for sqlite/postgres; personal managers pass their
  owner. client_credentials stays in process; an unloadable key falls
  back to process memory; database errors fail the refresh.
- credentials_key_consumers lists refresh_token MCP OAuth servers, so a
  declared multi-instance deployment needs the shared key.

Docs: MCP_SERVER.md, CONFIGURATION.md, Helm README, README, mcp and
migrations AGENTS.md, CHANGELOG (en/zh).

* docs(changelog): link the MCP OAuth token store entry to #7207

* fix(mcp): prune removed connections' OAuth token rows and ask owners to re-authorize on invalid_grant

Review follow-up (willem-bd):
- Both MCP config writers drop mcp_oauth_tokens rows of connections a
  save removes (deployment: _commit_mcp_config_write; personal:
  _mutate, keeping each connection's enabled/disabled runtime-name
  variants so a toggle keeps its chain). Best effort; no startup sweep,
  since a missing config file would look like every server removed.
- invalid_grant on a refresh_token request raises
  McpOAuthReauthorizationRequired: the tool error names the server and
  asks for a new refresh token, plus the shared-key hint when this
  instance could not decrypt the stored chain.
- Annotate the gateway helpers' config as AppConfig.

* fix(mcp): renew the OAuth refresh lease, keep unstored rotations, and key access tokens by request parameters

Review follow-up (willem-bd, second round):
- HTTPX timeouts bound each I/O wait, not a whole response: the refresh
  holder now renews its lease every third of REFRESH_LEASE_SECONDS while
  the token request runs and abandons it after REFRESH_MAX_SECONDS (120 s);
  waiters outlast a live holder. Regression uses a real loopback endpoint
  that streams its body for three lease lengths.
- A refresh whose tokens could not be stored keeps them pending
  (unsaved_base_version): they are written back before an older stored
  token is adopted or refreshed with, retried every few seconds while the
  access token is still valid, and dropped once a peer stored a newer
  chain. A failed save releases the lease.
- New token_request_fingerprint (extra_token_params and response fields):
  a stored access token is reused only under the same request parameters;
  otherwise the stored refresh-token chain is refreshed.

---------

Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
2026-10-21 18:17:29 +08:00

1467 lines
69 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""Centralized accessors for singleton objects stored on ``app.state``.
**Getters** (used by routers): raise 503 when a required dependency is
missing, except ``get_store`` which returns ``None``.
``AppConfig`` is intentionally *not* cached on ``app.state``. Routers and the
run path resolve it through :func:`deerflow.config.app_config.get_app_config`,
which performs mtime-based hot reload, so edits to ``config.yaml`` take
effect on the next request without a process restart. The engines created in
:func:`langgraph_runtime` (stream bridge, persistence, checkpointer, store,
run-event store) accept a ``startup_config`` snapshot — they are
restart-required by design and stay bound to that snapshot to keep the live
process consistent with itself.
Initialization is handled directly in ``app.py`` via :class:`AsyncExitStack`.
"""
from __future__ import annotations
import asyncio
import logging
import os
from collections.abc import AsyncGenerator, Callable
from contextlib import AsyncExitStack, asynccontextmanager
from pathlib import Path
from typing import TYPE_CHECKING, Any, TypeVar, cast
from fastapi import FastAPI, HTTPException, Request
from langgraph.types import Checkpointer
from app.gateway.auth.errors import AuthErrorCode
from deerflow.community.browser_automation.session import browser_multi_worker_error
from deerflow.config.app_config import AppConfig, get_app_config
from deerflow.config.channel_leader_lease_config import CHANNEL_LEADER_LEASE_FIELD, ChannelLeaderLeaseConfig, ChannelLeaderLeaseConfigError, channel_leader_lease_from_app_config
from deerflow.config.credentials_key import CREDENTIALS_KEY_ENV_VAR, CREDENTIALS_KEY_FILENAME, GENERATE_KEY_COMMAND, CredentialsKeyError, parse_credentials_keys
from deerflow.config.deployment_config import multi_instance_declaration
from deerflow.persistence.feedback import FeedbackRepository
from deerflow.runtime import ORPHAN_RECOVERY_STOP_REASON, STARTUP_ORPHAN_RECOVERY_ERROR, RunContext, RunManager, StreamBridge
from deerflow.runtime.events.store.base import RunEventStore
from deerflow.runtime.runs.store.base import RunStore
from deerflow.utils.file_io import await_drained
logger = logging.getLogger(__name__)
# Upper bound (seconds) for draining in-flight runs during shutdown, before the
# AsyncExitStack tears down the checkpointer (and its connection pool). Kept
# local to avoid an app -> deps -> app import cycle. This is a *separate* budget
# from ``app.gateway.app._SHUTDOWN_HOOK_TIMEOUT_SECONDS`` (currently also 5.0s,
# which bounds channel-service stop): the two govern independent teardown steps
# and may diverge, but both count toward the lifespan shutdown window — revisit
# them together if their sum must stay within the server's graceful-shutdown
# timeout.
_RUN_DRAIN_TIMEOUT_SECONDS = 5.0
def _browser_tools_enabled_in_config(config: AppConfig) -> bool:
"""Return whether process-local agentic browser sessions are configured."""
get_tool_config = getattr(config, "get_tool_config", None)
if callable(get_tool_config):
return get_tool_config("browser_navigate") is not None
return any(getattr(tool, "name", None) == "browser_navigate" for tool in (getattr(config, "tools", None) or []))
# ``GATEWAY_WORKERS`` is the knob this project documents and the one
# ``docker/docker-compose.yaml`` forwards as ``--workers``. ``backend/Dockerfile``
# and ``scripts/serve.sh`` launch uvicorn with no worker count at all, and uvicorn
# then takes the count from ``WEB_CONCURRENCY``. Both must be read, otherwise a
# multi-process deployment starts with the safety gates inert.
# A blank value means "unset", as the compose command's ``${GATEWAY_WORKERS:-1}`` treats it.
_WORKER_COUNT_ENV_VARS = ("GATEWAY_WORKERS", "WEB_CONCURRENCY")
def _gateway_worker_count() -> tuple[int, str]:
"""Return the Gateway worker-process count and the environment variable that set it.
The name belongs in the refusal text: an operator who only ever set
``WEB_CONCURRENCY`` cannot act on a message about ``GATEWAY_WORKERS``.
"""
for name in _WORKER_COUNT_ENV_VARS:
raw = os.environ.get(name)
if not raw or not raw.strip():
continue
try:
return int(raw), name
except (TypeError, ValueError):
# Uvicorn rejects a non-numeric count itself, so this is not the place
# to fail the launch -- but an unparsable documented knob must not hide
# the count uvicorn takes from ``WEB_CONCURRENCY`` on the launches that
# pass no ``--workers``. Keep looking; report 1 only after the loop.
continue
return 1, _WORKER_COUNT_ENV_VARS[0]
def _multi_process_signal(config: AppConfig) -> tuple[str, str] | None:
"""Return ``(reason, rollback)`` when this process must assume peer Gateway processes.
``reason`` names the knob that established the topology (``GATEWAY_WORKERS=2``,
``DEER_FLOW_MULTI_INSTANCE=1`` or ``deployment.multi_instance=true``) and
``rollback`` is the single-instance remediation a refusal message offers.
When the worker count and a declaration are both active, ``rollback``
withdraws both: resetting only the worker count would bounce the operator
through a second refusal on the declaration at the next start.
The worker-count variables only see one process tree, so a Kubernetes
Deployment with ``replicas > 1`` and one worker per Pod is invisible to
them; the explicit declaration exists for exactly that topology.
"""
workers, worker_env = _gateway_worker_count()
declaration = multi_instance_declaration(config)
if workers > 1:
rollback = f"Set {worker_env}=1"
if declaration is not None:
rollback = f"{rollback} and {declaration.rollback}"
return f"{worker_env}={workers}", rollback
if declaration is None:
return None
step = declaration.rollback
return declaration.knob, f"{step[0].upper()}{step[1:]} and run a single Gateway instance"
def _stream_bridge_is_cross_process(config: AppConfig) -> bool:
"""Return whether live run events can reach SSE clients on every instance.
Mirrors ``runtime/stream_bridge/async_provider.py::_resolve_config``: the
config.yaml section wins, and an omitted section falls back to the
``DEER_FLOW_STREAM_BRIDGE_REDIS_URL`` variable the Docker and Helm
deployments inject.
"""
bridge = getattr(config, "stream_bridge", None)
if bridge is not None:
return getattr(bridge, "type", None) == "redis"
return bool((os.environ.get("DEER_FLOW_STREAM_BRIDGE_REDIS_URL") or "").strip())
def _enforce_postgres_for_multi_worker(config: AppConfig) -> None:
"""Refuse unsafe multi-process configurations before persistence starts.
A deployment counts as multi-process when ``GATEWAY_WORKERS`` /
``WEB_CONCURRENCY`` is above 1, or when the operator declares peers with
``deployment.multi_instance: true`` / ``DEER_FLOW_MULTI_INSTANCE=1`` (the
worker count cannot see other Pods, and a Pod that starts next to a peer
without these prerequisites writes the peer's live runs off as orphans).
Multi-instance scheduler recovery also needs the durable run ownership
contract even when each Pod runs a single Gateway worker.
1. The background scheduler must be disabled for ordinary multi-worker
mode. ``scheduler.multi_instance`` opts into the lease-aware path.
2. Process-local browser sessions must be disabled. Browser tools keep
Chromium and Playwright objects in one worker's memory, while ordinary
uvicorn dispatch provides no thread-id affinity.
3. The DB backend must be Postgres — SQLite write-locks cannot support
concurrent multi-process access.
4. ``run_events.backend`` must be ``db``. Memory and JSONL stores are
process-local, so workers cannot enforce a shared singleton receipt.
5. ``run_ownership.heartbeat_enabled`` must be True — without heartbeat,
every run has a NULL lease, so reconciliation treats all inflight
runs as orphans and Worker B would kill Worker A's live runs on
every rolling update or scale-up.
6. The stream bridge must be Redis. The memory bridge is process-local,
so SSE streams, reconnects and ``/wait`` only work on the owner.
7. ``sandbox.ownership.type`` must not be an explicit ``memory``: an
in-process ownership store cannot see peers, so reconciliation would
adopt and idle-destroy another instance's live containers (#4206).
8. ``channels.leader_lease.enabled`` must not be ``false`` while an IM
channel other than GitHub is enabled: every instance would open the
platform connection with the same bot credentials (Telegram rejects
the second poller with 409, Discord answers every message twice).
``auto`` (the default) and ``true`` hand each provider's connection to
the one instance holding its ``channel_leases`` row.
On a single instance only one lease rule applies: an explicit
``channels.leader_lease.enabled: true`` needs a SQL database for its lease
table, so ``database.backend: memory`` is refused while a channel the lease
arbitrates is enabled.
This gate runs once at startup before any persistence engine is
initialised so the error message is clear and the process exits
immediately.
"""
scheduler = getattr(config, "scheduler", None)
multi_instance_requested = bool(getattr(scheduler, "multi_instance", False))
multi_instance_scheduler = bool(getattr(scheduler, "enabled", False) and multi_instance_requested)
backend = getattr(config.database, "backend", None)
run_events_backend = getattr(getattr(config, "run_events", None), "backend", None)
run_ownership = getattr(config, "run_ownership", None)
if multi_instance_requested and backend != "postgres":
raise SystemExit(f"scheduler.multi_instance=true requires database.backend='postgres'. database.backend is '{backend}'. Set scheduler.multi_instance=false or configure Postgres.")
if multi_instance_requested and run_events_backend != "db":
raise SystemExit(f"scheduler.multi_instance=true requires run_events.backend='db'. run_events.backend is '{run_events_backend}'. Set scheduler.multi_instance=false or configure run_events.backend: db.")
if multi_instance_requested and (run_ownership is None or not run_ownership.heartbeat_enabled):
raise SystemExit("scheduler.multi_instance=true requires run_ownership.heartbeat_enabled=true so peer runs retain a valid lease. Set scheduler.multi_instance=false or enable run ownership heartbeats.")
# A malformed channels.leader_lease block is refused on every topology:
# ChannelService would otherwise raise the same error later in the
# lifespan, after the persistence engine is already up.
leader_lease = _channel_leader_lease(config)
signal = _multi_process_signal(config)
if signal is None:
if leader_lease.enabled is True and backend not in ("sqlite", "postgres"):
providers = _enabled_channel_transport_providers(config)
if providers:
raise SystemExit(
f"{CHANNEL_LEADER_LEASE_FIELD}.enabled=true needs the shared application database for its lease table, but database.backend is '{backend}' "
f"while IM channels {', '.join(providers)} are enabled. Set database.backend to sqlite or postgres, or set {CHANNEL_LEADER_LEASE_FIELD}.enabled to auto."
)
return
reason, rollback = signal
if config.scheduler.enabled and not multi_instance_scheduler:
raise SystemExit(f"{reason} cannot run with scheduler.enabled=true because each worker starts its own scheduler. {rollback}, scheduler.multi_instance=true, or scheduler.enabled=false.")
if _browser_tools_enabled_in_config(config):
workers, _worker_env = _gateway_worker_count()
if workers > 1 and multi_instance_declaration(config) is None:
raise SystemExit(browser_multi_worker_error(workers))
raise SystemExit(f"{reason} cannot enable agentic browser tools: browser sessions are process-local and a request can land on any instance. {rollback} or disable the browser_navigate tool.")
if backend != "postgres":
raise SystemExit(f"{reason} requires database.backend='postgres', but database.backend is '{backend}'. SQLite cannot support concurrent multi-process access. {rollback} or switch to Postgres.")
if run_events_backend != "db":
raise SystemExit(
f"{reason} requires run_events.backend='db', but run_events.backend is '{run_events_backend}'. "
"Memory and JSONL event stores are process-local, so delivery receipt singleton guarantees cannot hold across workers. "
f"{rollback} or configure run_events.backend: db."
)
if run_ownership is None or not run_ownership.heartbeat_enabled:
raise SystemExit(
f"{reason} requires run_ownership.heartbeat_enabled=true. "
"Without heartbeat, every run has a NULL lease, so reconciliation "
"treats all inflight runs as orphans — a starting instance would kill its "
"peers' live runs on every rolling update or scale-up. "
"Set run_ownership.heartbeat_enabled=true in config.yaml."
)
if not _stream_bridge_is_cross_process(config):
raise SystemExit(
f"{reason} requires stream_bridge.type='redis' (or DEER_FLOW_STREAM_BRIDGE_REDIS_URL), "
"but the stream bridge is the process-local memory bridge: SSE streams, reconnects and /wait "
f"only work on the instance that owns the run. {rollback} or configure the redis stream bridge."
)
ownership = getattr(getattr(config, "sandbox", None), "ownership", None)
if ownership is not None and getattr(ownership, "type", None) == "memory":
raise SystemExit(
f"{reason} cannot run with sandbox.ownership.type='memory': an in-process ownership store cannot see "
"peer instances, so startup reconciliation would adopt and idle-destroy containers another instance is "
"using (#4206). Set sandbox.ownership.type='redis', or omit the section so it is inferred from the redis stream bridge."
)
if leader_lease.explicitly_disabled:
providers = _enabled_channel_transport_providers(config)
if providers:
raise SystemExit(
f"{reason} cannot run with {CHANNEL_LEADER_LEASE_FIELD}.enabled=false while IM channels {', '.join(providers)} are enabled: "
"every instance would open the platform connection with the same bot credentials (Telegram rejects the second poller "
"with 409 Conflict, Discord answers every message twice, and per-process channel state diverges). "
f"Set {CHANNEL_LEADER_LEASE_FIELD}.enabled to auto (the default) or true so one instance holds each provider's connection; "
f"alternatively {rollback[0].lower()}{rollback[1:]}, or disable those channels (channels.<provider>.enabled=false and channel_connections.<provider>.enabled=false)."
)
def multi_process_deployment(config: AppConfig) -> bool:
"""Whether this process must assume peer Gateway processes (the gate's multi-process signal).
``channels.leader_lease: auto`` follows exactly this signal, so the lease
turns on wherever the gate enforced the multi-process prerequisites.
"""
return _multi_process_signal(config) is not None
def _channel_leader_lease(config: AppConfig) -> ChannelLeaderLeaseConfig:
"""Parse ``channels.leader_lease``; a malformed block exits with the dotted path in the message."""
try:
return channel_leader_lease_from_app_config(config)
except ChannelLeaderLeaseConfigError as exc:
raise SystemExit(str(exc)) from None
def _enabled_channel_transport_providers(config: AppConfig) -> list[str]:
"""Non-GitHub IM providers whose platform connection this deployment may open.
Imported lazily: ``app.channels.service`` pulls in every channel module and
the gate runs before the lifespan has any reason to load them.
"""
from app.channels.service import enabled_transport_providers
return enabled_transport_providers(config)
def credentials_key_consumers(config: AppConfig) -> list[str]:
"""Return the enabled features that store data under ``DEER_FLOW_CREDENTIALS_KEY``.
Each entry is the setting that enabled the feature, as refusal text spells
it. The Gateway loads the key at startup only when this list is non-empty,
and the multi-instance gate below refuses a missing key only for these
features, so a deployment that stores no credentials keeps booting without
one. Personal MCP OAuth connections are saved at runtime, so they cannot be
listed here; their store loads the key on first use.
"""
consumers: list[str] = []
connections = getattr(config, "channel_connections", None)
if connections is not None and getattr(connections, "enabled", False):
# ChannelConnectionRepository encrypts per-connection credentials, and
# the runtime config store the bot credentials entered in the web UI.
consumers.append("channel_connections.enabled=true")
extensions = getattr(config, "extensions", None)
if extensions is not None:
# deerflow.mcp.oauth_store keeps rotating refresh tokens in the database.
for name, server in extensions.get_enabled_mcp_servers().items():
oauth = server.oauth
if oauth is not None and oauth.enabled and oauth.grant_type == "refresh_token":
consumers.append(f"mcpServers.{name}.oauth.enabled=true")
return consumers
def _enforce_credentials_key(config: AppConfig) -> None:
"""Refuse a malformed credentials key, or a missing one where instances cannot share the generated file.
A set ``DEER_FLOW_CREDENTIALS_KEY`` must parse (any topology): starting with
a key that cannot decrypt anything would silently hide stored credentials.
Without it each instance generates ``{base_dir}/.credentials_key``. Workers
of one process tree share that file and converge on one key through its
exclusive create, so only the explicit multi-instance declaration -- Pods or
hosts that need not share a runtime home -- requires the variable, and only
when a feature from :func:`credentials_key_consumers` is enabled.
"""
raw = os.environ.get(CREDENTIALS_KEY_ENV_VAR, "")
if raw.strip():
try:
parse_credentials_keys(raw)
except CredentialsKeyError as exc:
raise SystemExit(str(exc)) from None
return
declaration = multi_instance_declaration(config)
if declaration is None:
return
consumers = credentials_key_consumers(config)
if not consumers:
return
enabled = ", ".join(consumers)
disabled = ", ".join(consumer.removesuffix("=true") + "=false" for consumer in consumers)
raise SystemExit(
f"{declaration.knob} with {enabled} requires {CREDENTIALS_KEY_ENV_VAR}: without it every instance generates its own "
f"{{base_dir}}/{CREDENTIALS_KEY_FILENAME}, and credentials one instance stores cannot be decrypted on its peers. "
f"Generate one key with {GENERATE_KEY_COMMAND} and set the same value on every instance (the Helm chart and "
f"scripts/deploy.sh generate and inject it automatically), {declaration.rollback}, or set {disabled}."
)
def _validate_agent_storage(config: AppConfig) -> None:
"""Fail fast on an agent-storage backend the database cannot support.
``agent_storage.backend: db`` needs a durable, shared SQL database — a
``memory`` database is per-process, so custom-agent and managed-subagent
definitions would silently diverge across nodes (and there is no SQL URL
to open). Mirrors deermem's create_storage fail-fast and the multi-worker
gate above.
Also warns when a multi-worker Postgres deployment leaves agent storage on
``file``: custom agents created on one node's local disk are invisible to
the others, exactly the divergence the db backend exists to fix.
"""
agent_storage = getattr(config, "agent_storage", None)
backend = getattr(agent_storage, "backend", "file")
db_backend = getattr(getattr(config, "database", None), "backend", None)
if backend == "db" and db_backend not in ("sqlite", "postgres"):
raise SystemExit(
f"agent_storage.backend='db' requires database.backend to be 'sqlite' or 'postgres', "
f"but database.backend is '{db_backend}'. A 'memory' database is per-process and cannot "
"share agent definitions across nodes. Set database.backend, or use agent_storage.backend='file'."
)
signal = _multi_process_signal(config)
if signal is not None and db_backend == "postgres" and backend == "file":
logger.warning(
"%s with database.backend='postgres' but agent_storage.backend='file': "
"custom agents and managed subagents are stored per-node on local disk and are not visible "
"across workers/nodes. Set agent_storage.backend='db' to share them.",
signal[0],
)
def _validate_login_throttle_storage(config: AppConfig) -> None:
"""Warn when a multi-process deployment counts login failures per process.
``auth.local.throttle_storage`` resolves to the shared ``login_throttle``
table whenever an application database exists, so under the multi-process
gate (which already requires Postgres) only an explicit ``memory`` lands
here. That is not fatal — the throttle still works on every replica — but
with N replicas behind one load balancer an attacker gets N x
``max_login_attempts`` guesses and a lockout on one replica is invisible
to the others, exactly the gap the shared table closes. Mirrors the
``agent_storage.backend='file'`` divergence warning above.
"""
signal = _multi_process_signal(config)
if signal is None:
return
local = getattr(getattr(config, "auth", None), "local", None)
if local is None:
return
from deerflow.config.auth_config import LocalAuthConfig, resolve_login_throttle_storage
selector = getattr(local, "throttle_storage", LocalAuthConfig.model_fields["throttle_storage"].default)
db_backend = getattr(getattr(config, "database", None), "backend", None)
if resolve_login_throttle_storage(selector, db_backend) == "memory":
logger.warning(
"%s with auth.local.throttle_storage=%s: failed-login counters and lockouts are kept per Gateway process, "
"so an attacker behind the load balancer gets N x max_login_attempts guesses and a lockout on one replica "
"is invisible to the others. Set auth.local.throttle_storage='auto' (or 'db') so the shared login_throttle "
"table in the application database enforces one limit per IP.",
signal[0],
str(getattr(selector, "value", selector)),
)
def _validate_memory_retrieval_index(config: AppConfig) -> None:
"""Warn when a declared multi-instance deployment keeps DeerMem's retrieval index on the shared memory volume.
DeerMem's derived FTS5 index is one SQLite database in WAL mode. When
``storage_path`` sits on the home volume several Gateway instances share,
the default ``{storage_path}/.retrieval`` makes every instance open that
same file over a network filesystem (where SQLite documents WAL as
unsupported), empty and refill it under its peers at startup, and delete
it from under them on corruption recovery. The index is rebuildable, so
each instance should keep its own copy on local disk through
``memory.backend_config.retrieval_index_path``. Only the explicit
declaration counts: uvicorn workers of one process tree share local disk,
where a shared WAL index is supported. Mirrors ``_validate_agent_storage``:
a warning, not a refusal, because memory still works, only slower.
"""
declaration = multi_instance_declaration(config)
if declaration is None:
return
memory = getattr(config, "memory", None)
if memory is None or not getattr(memory, "enabled", False) or getattr(memory, "manager_class", "deermem") != "deermem":
return
backend_config = dict(getattr(memory, "backend_config", None) or {})
if backend_config.get("retrieval_adapter", "fts5") != "fts5":
return # disabled, or a custom RetrievalPort factory that owns its own storage
from deerflow.agents.memory.backends.deermem.deermem.core.paths import retrieval_index_directory
from deerflow.agents.memory.manager import resolve_deermem_storage_path
storage_path = resolve_deermem_storage_path(backend_config)
index_dir = retrieval_index_directory(storage_path, backend_config.get("retrieval_index_path"))
if index_dir is None or not Path(index_dir).resolve().is_relative_to(Path(storage_path).resolve()):
return
logger.warning(
"%s but the DeerMem retrieval index at %s is inside memory storage_path %s: every Gateway instance opens the same "
"SQLite WAL index over the shared memory volume, rebuilds it under its peers at startup and deletes it from under them "
"on corruption recovery. Set memory.backend_config.retrieval_index_path to an instance-local directory (the Helm chart "
"mounts an emptyDir at /var/lib/deerflow/memory-index).",
declaration.knob,
index_dir,
storage_path,
)
async def _drain_inflight_runs(run_manager: RunManager) -> None:
"""Drain in-flight runs before the checkpointer is torn down (issue #3373).
Shields the (internally-bounded) drain so that even if the lifespan
coroutine is repeatedly cancelled mid-shutdown — e.g. signal escalation or
the server's graceful-shutdown timeout — the checkpointer pool is not closed
while run tasks are still writing checkpoints. Cancellation is remembered
and propagated only after the already-running drain reaches a safe terminal
point.
"""
drain = asyncio.create_task(run_manager.shutdown(timeout=_RUN_DRAIN_TIMEOUT_SECONDS))
cancellation: asyncio.CancelledError | None = None
while not drain.done():
try:
await asyncio.shield(drain)
except asyncio.CancelledError as exc:
if cancellation is None:
cancellation = exc
except Exception:
if cancellation is not None:
logger.exception("In-flight run drain failed after shutdown cancellation")
raise cancellation
logger.exception("Failed to drain in-flight runs during shutdown")
return
if cancellation is not None:
try:
drain.result()
except Exception:
logger.exception("In-flight run drain failed after shutdown cancellation")
raise cancellation
try:
drain.result()
except Exception:
logger.exception("Failed to drain in-flight runs during shutdown")
async def _retry_recovered_run_stream_end(
bridge: StreamBridge,
run_id: str,
*,
retry_delay: float,
cleanup_delay: float,
) -> None:
await asyncio.sleep(max(0.0, retry_delay))
await bridge.publish_end(run_id)
await bridge.cleanup(run_id, delay=cleanup_delay)
async def _publish_recovered_run_stream_end(
bridge: StreamBridge,
recovered_runs: list[RunRecord],
*,
cleanup_delay: float = 60.0,
on_cleanup_scheduled: Callable[[str, asyncio.Task[None]], None] | None = None,
) -> list[tuple[str, asyncio.Task[None]]]:
"""Terminate retained streams for runs recovered as orphaned."""
cleanup_tasks: list[tuple[str, asyncio.Task[None]]] = []
def schedule_retry(record: RunRecord) -> bool:
retry_delay = getattr(
bridge,
"producer_lease_retry_delay_seconds",
None,
)
if not isinstance(retry_delay, (int, float)) or isinstance(retry_delay, bool) or retry_delay < 0:
return False
task = asyncio.create_task(
_retry_recovered_run_stream_end(
bridge,
record.run_id,
retry_delay=float(retry_delay),
cleanup_delay=cleanup_delay,
)
)
task.add_done_callback(
lambda task, run_id=record.run_id: _log_recovered_stream_cleanup_result(
task,
run_id,
)
)
cleanup_tasks.append((record.run_id, task))
if on_cleanup_scheduled is not None:
on_cleanup_scheduled(record.run_id, task)
return True
for record in recovered_runs:
stream_exists = getattr(bridge, "stream_exists", None)
if stream_exists is not None:
try:
if not await stream_exists(record.run_id):
logger.debug("Skipping recovered stream end for %s: stream already expired", record.run_id)
cleanup_expired_producer = getattr(
bridge,
"cleanup_expired_producer",
None,
)
if callable(cleanup_expired_producer):
try:
cleaned = bool(await cleanup_expired_producer(record.run_id))
except Exception:
logger.warning(
"Failed to clean expired stream producer marker for %s",
record.run_id,
exc_info=True,
)
cleaned = False
if not cleaned:
schedule_retry(record)
continue
except Exception:
logger.debug("Failed to check recovered stream existence for %s", record.run_id, exc_info=True)
try:
await bridge.publish_end(record.run_id)
except Exception:
logger.warning(
"Failed to publish recovered run stream end for %s",
record.run_id,
exc_info=True,
)
schedule_retry(record)
continue
task = asyncio.create_task(bridge.cleanup(record.run_id, delay=cleanup_delay))
task.add_done_callback(lambda task, run_id=record.run_id: _log_recovered_stream_cleanup_result(task, run_id))
cleanup_tasks.append((record.run_id, task))
if on_cleanup_scheduled is not None:
on_cleanup_scheduled(record.run_id, task)
return cleanup_tasks
def _log_recovered_stream_cleanup_result(task: asyncio.Task[None], run_id: str) -> None:
if task.cancelled():
return
try:
task.result()
except Exception:
logger.warning("Failed to clean up recovered run stream for %s", run_id, exc_info=True)
async def _cleanup_recovered_scheduled_goals(recovered_runs: list[RunRecord], *, run_manager: RunManager, checkpointer: Checkpointer) -> None:
"""Clear only confirmed occurrence-owned goals after durable orphan recovery."""
from deerflow.runtime.runs.worker import clear_recovered_scheduled_goal
for record in recovered_runs:
metadata = getattr(record, "metadata", None) or {}
if not isinstance(metadata.get("scheduled_goal_objective"), str):
continue
try:
await clear_recovered_scheduled_goal(record, run_manager=run_manager, checkpointer=checkpointer)
except Exception:
logger.warning("Scheduled goal cleanup failed for recovered run %s; retained for guarded next-run cleanup", record.run_id, exc_info=True)
async def _flush_recovered_stream_cleanups(
bridge: StreamBridge,
cleanup_tasks: dict[asyncio.Task[None], str],
*,
timeout: float = 1.0,
) -> None:
"""Cancel delayed cleanups and delete their streams before bridge shutdown."""
pending = [(task, run_id) for task, run_id in cleanup_tasks.items() if not task.done()]
if not pending:
return
for task, _run_id in pending:
task.cancel()
await asyncio.gather(*(task for task, _run_id in pending), return_exceptions=True)
run_ids = list(dict.fromkeys(run_id for _task, run_id in pending))
try:
results = await asyncio.wait_for(
asyncio.gather(
*(bridge.cleanup(run_id, delay=0) for run_id in run_ids),
return_exceptions=True,
),
timeout=max(0.0, timeout),
)
except TimeoutError:
logger.warning(
"Immediate recovered stream cleanup exceeded %.1fs for run_ids=%s; bridge TTL remains the final safety net",
timeout,
run_ids,
)
else:
for run_id, result in zip(run_ids, results):
if isinstance(result, BaseException):
logger.warning(
"Failed to immediately clean up recovered run stream for %s: %r",
run_id,
result,
)
if TYPE_CHECKING:
from app.gateway.auth.local_provider import LocalAuthProvider
from app.gateway.auth.repositories.sqlite import SQLiteUserRepository
from deerflow.persistence.thread_meta.base import ThreadMetaStore
from deerflow.runtime import RunRecord
T = TypeVar("T")
async def _mark_latest_startup_recovered_threads_error(
run_manager: RunManager,
thread_store: ThreadMetaStore,
recovered_runs: list[RunRecord],
) -> None:
"""Project startup recovery before request-serving concurrency begins.
This helper must remain on the pre-``yield`` startup path. ``ThreadMetaStore``
has no ``latest_run_id`` column, so it cannot express an atomic conditional
update keyed by the recovered run. Periodic recovery deliberately skips this
projection; moving this helper after request serving starts would reintroduce
a read/update race with newer runs.
"""
recovered_by_thread: dict[str, set[str]] = {}
for record in recovered_runs:
recovered_by_thread.setdefault(record.thread_id, set()).add(record.run_id)
for thread_id, recovered_run_ids in recovered_by_thread.items():
try:
latest_runs = await run_manager.list_by_thread(thread_id, user_id=None, limit=1)
except Exception:
logger.warning("Failed to find latest run for thread %s during run reconciliation", thread_id, exc_info=True)
continue
if not latest_runs or latest_runs[0].run_id not in recovered_run_ids:
continue
try:
await thread_store.update_status(thread_id, "error", user_id=None)
except Exception:
logger.warning("Failed to mark thread %s as error during run reconciliation", thread_id, exc_info=True)
async def _terminalize_recovered_runs(
bridge: StreamBridge,
recovered_runs: list[RunRecord],
*,
cleanup_delay: float,
on_cleanup_scheduled: Callable[[str, asyncio.Task[None]], None] | None = None,
) -> list[tuple[str, asyncio.Task[None]]]:
"""Publish terminal markers and schedule retained-stream cleanup."""
return await _publish_recovered_run_stream_end(
bridge,
recovered_runs,
cleanup_delay=cleanup_delay,
on_cleanup_scheduled=on_cleanup_scheduled,
)
def get_config() -> AppConfig:
"""Return the freshest ``AppConfig`` for the current request.
Routes through :func:`deerflow.config.app_config.get_app_config`, which
honours runtime ``ContextVar`` overrides and reloads ``config.yaml`` from
disk when its mtime changes. ``AppConfig`` is not cached on ``app.state``
at all — the only startup-time snapshot lives as a local
``startup_config`` variable inside ``lifespan()`` and is passed
explicitly into :func:`langgraph_runtime` for the engines that are
restart-required by design. Routing every request through
:func:`get_app_config` closes the bytedance/deer-flow issue #3107 BUG-001
split-brain where the worker / lead-agent thread saw a stale startup
snapshot.
Hot-reload boundary: fields backed by startup-time singletons
(engines, sandbox provider, IM channels, logging handler) require a
process restart to change at runtime. The authoritative list lives in
:mod:`deerflow.config.reload_boundary` and is mirrored by the
standardised ``"startup-only:"`` prefix on the matching
``Field(description=...)`` in :class:`AppConfig` — IDE hover on those
fields will surface the boundary inline. See
``backend/CLAUDE.md`` "Config Hot-Reload Boundary" for the operator
summary.
Any failure to materialise the config (missing file, permission denied,
YAML parse error, validation error) is reported as 503 — semantically
"the gateway cannot serve requests without a usable configuration" — and
logged with the original exception so operators have something to debug.
"""
try:
return get_app_config()
except Exception as exc: # noqa: BLE001 - request boundary: log and degrade gracefully
logger.exception("Failed to load AppConfig at request time")
raise HTTPException(status_code=503, detail="Configuration not available") from exc
@asynccontextmanager
async def langgraph_runtime(app: FastAPI, startup_config: AppConfig) -> AsyncGenerator[None, None]:
"""Bootstrap and tear down all LangGraph runtime singletons.
``startup_config`` is the ``AppConfig`` snapshot taken once during
``lifespan()`` for one-shot infrastructure bootstrap. The engines and
stores constructed here (stream bridge, persistence engine, checkpointer,
store, run-event store) are restart-required by design — they hold live
connections, file handles, or singleton providers — so they bind to this
snapshot and survive across `config.yaml` edits. Request-time consumers
must still go through :func:`get_config` for any field that should be
hot-reloadable. See ``backend/CLAUDE.md`` "Config Hot-Reload Boundary".
The matching ``run_events_config`` is frozen onto ``app.state`` so
:func:`get_run_context` pairs a freshly-loaded ``AppConfig`` with the
*startup-time* run-events configuration the underlying ``event_store``
was built from — otherwise the runtime could end up combining a live
new ``run_events_config`` with an event store still bound to the
previous backend.
Usage in ``app.py``::
async with langgraph_runtime(app, startup_config):
yield
"""
from deerflow.persistence.engine import close_engine, get_session_factory, init_engine_from_config
from deerflow.runtime import make_store, make_stream_bridge
from deerflow.runtime.checkpoint_mode import freeze_checkpoint_channel_mode, freeze_checkpoint_snapshot_frequency
from deerflow.runtime.checkpointer.async_provider import make_checkpointer
from deerflow.runtime.events.store import make_run_event_store
# ------------------------------------------------------------------
# Multi-worker safety gate: reject SQLite when GATEWAY_WORKERS > 1.
# SQLite write-locks cannot support concurrent multi-process access.
# ------------------------------------------------------------------
_enforce_postgres_for_multi_worker(startup_config)
# Reject a malformed DEER_FLOW_CREDENTIALS_KEY, or a missing one where
# declared instances store credentials but cannot share the generated file.
_enforce_credentials_key(startup_config)
# Reject agent_storage.backend='db' on a non-durable database, and warn on
# node-divergent file storage under multi-worker Postgres.
_validate_agent_storage(startup_config)
# Warn when login lockouts stay per-process under several Gateway processes.
_validate_login_throttle_storage(startup_config)
# IM chat-to-thread bindings need no companion warning: ``resolve_channel_store``
# keeps them in the shared ``channel_thread_bindings`` table for every
# sqlite/postgres database, and the gate above already refuses a memory
# database -- the only JSON-file case -- under any multi-process signal.
# Warn when a declared multi-instance deployment shares DeerMem's SQLite
# retrieval index across instances through the memory volume.
_validate_memory_retrieval_index(startup_config)
async with AsyncExitStack() as stack:
# Lifecycle and system-model hooks can originate on isolated subagent
# loops. Bind them to the Gateway's serving loop before any runtime
# dependency starts, then reset the binding last through the exit
# stack. Registering the callback synchronously here also covers every
# startup-failure and cancellation path below.
try:
from deerflow.extensions.notify import (
drain_extension_notify_dispatches,
reset_extension_notify_loop,
set_extension_notify_loop,
)
set_extension_notify_loop(asyncio.get_running_loop())
except Exception:
logger.exception("Failed to register the extension notify loop; sync observations will be dropped")
else:
async def reset_notify_loop_safely() -> None:
try:
await drain_extension_notify_dispatches()
except Exception:
logger.debug(
"Failed to drain pending extension notifications (non-fatal)",
exc_info=True,
)
finally:
try:
reset_extension_notify_loop()
except Exception:
logger.debug(
"Failed to reset the extension notify loop (non-fatal)",
exc_info=True,
)
stack.push_async_callback(reset_notify_loop_safely)
config = startup_config
app.state.checkpoint_channel_mode = freeze_checkpoint_channel_mode(config.database.checkpoint_channel_mode)
app.state.checkpoint_snapshot_frequency = freeze_checkpoint_snapshot_frequency(config.database.checkpoint_delta.snapshot_frequency)
app.state.stream_bridge = await stack.enter_async_context(make_stream_bridge(config))
# Initialize persistence engine BEFORE checkpointer so that
# auto-create-database logic runs first (postgres backend).
# Own cleanup before initialization so partial startup and host
# cancellation cannot strand an engine created along the way.
stack.push_async_callback(close_engine)
await init_engine_from_config(config.database)
app.state.checkpointer = await stack.enter_async_context(make_checkpointer(config))
app.state.store = await stack.enter_async_context(make_store(config))
# Record the checkpointer/Store backend and the provisioner endpoint
# selected from this startup snapshot so GET /health/ready probes what
# the running process actually uses. These singletons are
# restart-required by design and are never rebuilt on config.yaml hot
# reload, so the probe must not re-resolve process-wide configuration
# per request. The stream bridge needs no snapshot: the probe pings the
# singleton stored on app.state.stream_bridge above.
from app.gateway.health import READINESS_CHECKPOINTER_CONFIG_ATTR, READINESS_PROVISIONER_URL_ATTR, resolve_checkpointer_config, resolve_provisioner_url
setattr(app.state, READINESS_CHECKPOINTER_CONFIG_ATTR, resolve_checkpointer_config(config))
setattr(app.state, READINESS_PROVISIONER_URL_ATTR, resolve_provisioner_url(config))
# Initialize repositories — one get_session_factory() call for all.
sf = get_session_factory()
# The login throttle store is resolved once per process from the startup
# snapshot and the engine above (auth.local.throttle_storage is
# startup-only); the router reads it through the same hook tests use.
from app.gateway.auth.login_throttle import install_login_throttle_store, reset_login_throttle_store, resolve_login_throttle_store
install_login_throttle_store(resolve_login_throttle_store(config, session_factory=sf))
stack.callback(reset_login_throttle_store)
if sf is not None:
from deerflow.persistence.feedback import FeedbackRepository
from deerflow.persistence.personal_access_tokens import PersonalAccessTokenRepository
from deerflow.persistence.run import RunRepository
app.state.run_store = RunRepository(sf)
app.state.feedback_repo = FeedbackRepository(sf)
from app.gateway.auth.pat import PAT_LAST_USED_WRITE_INTERVAL_SECONDS
app.state.pat_repo = PersonalAccessTokenRepository(sf, last_used_write_interval_seconds=PAT_LAST_USED_WRITE_INTERVAL_SECONDS)
else:
from deerflow.runtime.runs.store.memory import MemoryRunStore
app.state.run_store = MemoryRunStore()
app.state.feedback_repo = None
# Memory backend has no durable PAT store, so Bearer credentials
# cannot be validated there and are rejected by the middleware.
app.state.pat_repo = None
# Evidence readers are available to Gateway-lifetime extension services,
# so the configured event store must exist before those services start.
run_events_config = getattr(config, "run_events", None)
app.state.run_events_config = run_events_config
app.state.run_event_store = make_run_event_store(run_events_config)
from deerflow.extensions.run_evidence import StoreRunEvidenceReader, StoreRunEvidenceReaderFactory
# Gateway-lifetime services are trusted operator extensions without a
# request principal. None deliberately binds this app-scoped reader to
# global, cross-user visibility; event content is not secret-redacted.
app.state.run_evidence_reader = StoreRunEvidenceReader(
app.state.run_store,
app.state.run_event_store,
user_id=None,
)
app.state.run_evidence_reader_factory = StoreRunEvidenceReaderFactory(
app.state.run_store,
app.state.run_event_store,
)
# Services are app-scoped. Capture this app's immutable extension set
# once and close over the same object for teardown; the process-wide
# singleton may be replaced by another app/test before shutdown.
from deerflow.extensions import EMPTY_EXTENSIONS, record_runtime_diagnostics
from deerflow.extensions.gateway import start_services, stop_services
extensions = getattr(app.state, "extensions", EMPTY_EXTENSIONS)
from deerflow.extensions.host_capabilities import HostCapabilities
host_capabilities = HostCapabilities(config, app.state.run_store, app.state.run_event_store)
app.state.skill_mutation_host = host_capabilities
# Own partial startup and cancellation before acquiring any locks or
# worker resources. LIFO shutdown stops plugins before host recovery.
stack.push_async_callback(host_capabilities.close)
await host_capabilities.start()
attempted_services: list[tuple[str, Any]] = []
async def stop_extension_services() -> None:
record_runtime_diagnostics(
await await_drained(
stop_services(
extensions,
service_entries=attempted_services,
)
)
)
# Register cleanup before starting: start() can partially acquire
# resources and then fail or be cancelled.
stack.push_async_callback(stop_extension_services)
record_runtime_diagnostics(
await start_services(
extensions,
config,
sf,
run_evidence_reader=app.state.run_evidence_reader,
host_capabilities=host_capabilities.bindings,
attempted_services=attempted_services,
)
)
from deerflow.persistence.thread_meta import make_thread_store
app.state.thread_store = make_thread_store(sf, app.state.store)
if sf is not None:
from deerflow.persistence.mcp_tasks import McpTaskRepository
from deerflow.persistence.projects import ProjectDocumentRepository, ProjectRepository
from deerflow.persistence.scheduled_task_events import ScheduledTaskEventRepository
from deerflow.persistence.scheduled_task_runs import (
ScheduledTaskRunRepository,
)
from deerflow.persistence.scheduled_tasks import ScheduledTaskRepository
from deerflow.persistence.subagent_batches import SubagentBatchRepository
from deerflow.persistence.thread_reads import ThreadReadRepository
app.state.project_repo = ProjectRepository(sf)
app.state.project_document_repo = ProjectDocumentRepository(sf)
app.state.scheduled_task_repo = ScheduledTaskRepository(
sf,
run_repository=app.state.run_store,
)
app.state.scheduled_task_run_repo = ScheduledTaskRunRepository(
sf,
run_repository=app.state.run_store,
)
app.state.scheduled_task_event_repo = ScheduledTaskEventRepository(sf)
# Per-user unread state and the activity feed's read clock.
app.state.thread_read_repo = ThreadReadRepository(sf)
app.state.mcp_task_repo = McpTaskRepository(sf)
app.state.subagent_batch_repo = SubagentBatchRepository(sf)
else:
app.state.mcp_task_repo = None
app.state.project_repo = None
app.state.project_document_repo = None
app.state.subagent_batch_repo = None
app.state.scheduled_task_repo = None
app.state.scheduled_task_run_repo = None
app.state.scheduled_task_event_repo = None
app.state.thread_read_repo = None
# RunManager with store backing for persistence
run_ownership_config = getattr(config, "run_ownership", None)
sb_config = getattr(config, "stream_bridge", None)
cleanup_delay = getattr(sb_config, "recovered_stream_cleanup_delay_seconds", 60.0) if sb_config else 60.0
recovered_stream_cleanup_tasks: dict[asyncio.Task[None], str] = {}
def track_recovered_stream_cleanup(
run_id: str,
task: asyncio.Task[None],
) -> None:
recovered_stream_cleanup_tasks[task] = run_id
task.add_done_callback(lambda completed: recovered_stream_cleanup_tasks.pop(completed, None))
async def terminalize_recovered_runs(recovered_runs: list[RunRecord]) -> None:
await _cleanup_recovered_scheduled_goals(recovered_runs, run_manager=app.state.run_manager, checkpointer=app.state.checkpointer)
await _terminalize_recovered_runs(
app.state.stream_bridge,
recovered_runs,
cleanup_delay=cleanup_delay,
on_cleanup_scheduled=track_recovered_stream_cleanup,
)
app.state.run_manager = RunManager(
store=app.state.run_store,
run_ownership_config=run_ownership_config,
event_store=app.state.run_event_store,
on_orphans_recovered=terminalize_recovered_runs,
)
# Startup recovery: mark inflight runs whose lease has expired as error.
# In single-worker mode (SQLite / backend=memory), no run has a lease, so
# all inflight rows are reclaimed (unchanged behaviour). In multi-worker
# mode (Postgres), only runs with an expired lease are reclaimed; runs
# owned by another live worker are skipped.
from deerflow.utils.time import now_iso
recovered_runs = await app.state.run_manager.reconcile_orphaned_inflight_runs(
error=STARTUP_ORPHAN_RECOVERY_ERROR,
before=now_iso(),
stop_reason=ORPHAN_RECOVERY_STOP_REASON,
)
await terminalize_recovered_runs(recovered_runs)
await _mark_latest_startup_recovered_threads_error(
app.state.run_manager,
app.state.thread_store,
recovered_runs,
)
from app.gateway.auth.repositories.sqlite import SQLiteUserRepository
from app.gateway.extension_agent_runs import GatewayAgentRunsHost
app.state.agent_runs_host = GatewayAgentRunsHost(app, load_user=SQLiteUserRepository(sf).get_user_by_id if sf is not None else None)
stack.callback(app.state.agent_runs_host.close)
# Start the lease heartbeat if enabled (multi-worker deployments).
await app.state.run_manager.start_heartbeat()
try:
yield
finally:
app.state.agent_runs_host.close()
# Drain in-flight run tasks BEFORE the AsyncExitStack tears down the
# checkpointer (and its connection pool). A run still mid-graph would
# otherwise leak into asyncio.run() shutdown, where langgraph's
# _checkpointer_put_after_previous aput races the closed pool and
# raises PoolClosed (issue #3373).
run_manager = getattr(app.state, "run_manager", None)
if run_manager is not None:
shutdown_deadline = asyncio.get_running_loop().time() + _RUN_DRAIN_TIMEOUT_SECONDS
try:
await _drain_inflight_runs(run_manager)
finally:
await _flush_recovered_stream_cleanups(
app.state.stream_bridge,
recovered_stream_cleanup_tasks,
timeout=min(
1.0,
max(
0.0,
shutdown_deadline - asyncio.get_running_loop().time(),
),
),
)
# ---------------------------------------------------------------------------
# Getters – called by routers per-request
# ---------------------------------------------------------------------------
def _require(attr: str, label: str) -> Callable[[Request], T]:
"""Create a FastAPI dependency that returns ``app.state.<attr>`` or 503."""
def dep(request: Request) -> T:
val = getattr(request.app.state, attr, None)
if val is None:
raise HTTPException(status_code=503, detail=f"{label} not available")
return cast(T, val)
dep.__name__ = dep.__qualname__ = f"get_{attr}"
return dep
get_stream_bridge: Callable[[Request], StreamBridge] = _require("stream_bridge", "Stream bridge")
get_run_manager: Callable[[Request], RunManager] = _require("run_manager", "Run manager")
get_checkpointer: Callable[[Request], Checkpointer] = _require("checkpointer", "Checkpointer")
get_run_event_store: Callable[[Request], RunEventStore] = _require("run_event_store", "Run event store")
get_feedback_repo: Callable[[Request], FeedbackRepository] = _require("feedback_repo", "Feedback")
get_run_store: Callable[[Request], RunStore] = _require("run_store", "Run store")
get_project_repo = _require("project_repo", "Projects")
get_project_document_repo = _require("project_document_repo", "Projects")
def get_store(request: Request):
"""Return the global store (may be ``None`` if not configured)."""
return getattr(request.app.state, "store", None)
def get_thread_store(request: Request) -> ThreadMetaStore:
"""Return the thread metadata store (SQL or memory-backed)."""
val = getattr(request.app.state, "thread_store", None)
if val is None:
raise HTTPException(status_code=503, detail="Thread metadata store not available")
return val
def _scheduler_unavailable(message: str) -> HTTPException:
"""Coded 503 for the scheduled-task routes (contracts/scheduled_task_errors_contract.json)."""
from app.gateway.scheduled_task_errors import scheduler_error
return scheduler_error(503, "scheduler_unavailable", message)
def get_scheduled_task_repo(request: Request):
val = getattr(request.app.state, "scheduled_task_repo", None)
if val is None:
raise _scheduler_unavailable("Scheduled task repo not available")
return val
def get_scheduled_task_run_repo(request: Request):
val = getattr(request.app.state, "scheduled_task_run_repo", None)
if val is None:
raise _scheduler_unavailable("Scheduled task run repo not available")
return val
def get_scheduled_task_event_repo(request: Request):
val = getattr(request.app.state, "scheduled_task_event_repo", None)
if val is None:
raise HTTPException(status_code=503, detail="Scheduled task event repo not available")
return val
def get_scheduled_task_service(request: Request):
val = getattr(request.app.state, "scheduled_task_service", None)
if val is None:
raise _scheduler_unavailable("Scheduled task service not available")
return val
def is_scheduler_running(request: Request) -> bool:
"""Whether this Gateway process's scheduler poller is running.
False when the service is absent (no scheduler persistence) or was never
started (``scheduler.enabled: false``) or its poller stopped. Per process:
another worker may report differently.
"""
state = getattr(getattr(request, "app", None), "state", None)
service = getattr(state, "scheduled_task_service", None)
return bool(getattr(service, "is_running", False))
def get_mcp_task_repo(request: Request):
val = getattr(request.app.state, "mcp_task_repo", None)
if val is None:
raise HTTPException(status_code=503, detail="MCP task repo not available")
return val
def get_mcp_task_service(request: Request):
val = getattr(request.app.state, "mcp_task_service", None)
if val is None:
raise HTTPException(status_code=503, detail="MCP task service not available")
return val
def get_subagent_batch_repo(request: Request):
val = getattr(request.app.state, "subagent_batch_repo", None)
if val is None:
raise HTTPException(status_code=503, detail="Subagent batch repository not available")
return val
def get_subagent_batch_service(request: Request):
val = getattr(request.app.state, "subagent_batch_service", None)
if val is None:
raise HTTPException(status_code=503, detail="Subagent batch service not available")
return val
def _run_admission_hook(*, checkpointer: Any):
"""Prune metadata leaves inside the run's durable admission fence."""
async def handle(record: Any, config: dict[str, Any]) -> None:
try:
from app.gateway.checkpoint_retention import enforce_completed_run_retention
await enforce_completed_run_retention(
checkpointer=checkpointer,
thread_id=record.thread_id,
protect_checkpoint_ids=_selected_checkpoint_ids(config),
)
except Exception:
logger.warning(
"Checkpoint retention failed for thread %s before run %s (non-fatal)",
record.thread_id,
record.run_id,
exc_info=True,
)
return handle
def _selected_checkpoint_ids(config: dict[str, Any]) -> frozenset[str]:
"""Collect checkpoint selectors already validated for an admitted run."""
configurable = config.get("configurable", {})
selected: set[str] = set()
checkpoint_id = configurable.get("checkpoint_id")
if isinstance(checkpoint_id, str) and checkpoint_id:
selected.add(checkpoint_id)
checkpoint_map = configurable.get("checkpoint_map")
if isinstance(checkpoint_map, dict):
selected.update(value for value in checkpoint_map.values() if isinstance(value, str) and value)
return frozenset(selected)
def _run_completion_hook(*, scheduled_task_service: Any | None):
"""Notify the scheduler after ordinary run finalization."""
async def handle(record: Any) -> None:
if scheduled_task_service is not None:
await scheduled_task_service.handle_run_completion(record)
return handle
def get_run_context(request: Request) -> RunContext:
"""Build a :class:`RunContext` from ``app.state`` singletons.
Returns a *base* context with infrastructure dependencies. The
``app_config`` field is resolved live so per-run fields (e.g.
``models[*].max_tokens``) follow ``config.yaml`` edits; the
``event_store`` / ``run_events_config`` pair stays frozen to the snapshot
captured in :func:`langgraph_runtime` so callers never see a store bound to
one backend paired with a config pointing at another.
"""
host = getattr(request.app.state, "agent_runs_host", None)
# Internal/channel and PAT runs deliberately receive no retained delegation.
agent_runs = host.bind(request) if host is not None else None
checkpointer = get_checkpointer(request)
scheduled_task_service = getattr(request.app.state, "scheduled_task_service", None)
return RunContext(
agent_runs=agent_runs,
checkpointer=checkpointer,
store=get_store(request),
event_store=get_run_event_store(request),
run_events_config=getattr(request.app.state, "run_events_config", None),
checkpoint_channel_mode=getattr(request.app.state, "checkpoint_channel_mode", "full"),
checkpoint_snapshot_frequency=getattr(request.app.state, "checkpoint_snapshot_frequency", None),
thread_store=get_thread_store(request),
mcp_task_repo=getattr(request.app.state, "mcp_task_repo", None),
app_config=get_config(),
extensions=getattr(request.app.state, "extensions", None),
on_run_admitted=_run_admission_hook(checkpointer=checkpointer),
on_run_completed=_run_completion_hook(
scheduled_task_service=scheduled_task_service,
),
)
# ---------------------------------------------------------------------------
# Auth helpers (used by authz.py and auth middleware)
# ---------------------------------------------------------------------------
# Cached singletons to avoid repeated instantiation per request
_cached_local_provider: LocalAuthProvider | None = None
_cached_repo: SQLiteUserRepository | None = None
def get_user_repository() -> SQLiteUserRepository:
"""Return the cached user repository (created on first use).
Origin: admin user-management surface (RFC #4063 / #3462 gap 2). Shares
the ``get_local_provider`` cache so both surfaces see one store.
"""
get_local_provider()
assert _cached_repo is not None
return _cached_repo
def get_local_provider() -> LocalAuthProvider:
"""Get or create the cached LocalAuthProvider singleton.
Must be called after ``init_engine_from_config()`` — the shared
session factory is required to construct the user repository.
"""
global _cached_local_provider, _cached_repo
if _cached_repo is None:
from app.gateway.auth.repositories.sqlite import SQLiteUserRepository
from deerflow.persistence.engine import get_session_factory
sf = get_session_factory()
if sf is None:
raise RuntimeError("get_local_provider() called before init_engine_from_config(); cannot access users table")
_cached_repo = SQLiteUserRepository(sf)
if _cached_local_provider is None:
from app.gateway.auth.local_provider import LocalAuthProvider
_cached_local_provider = LocalAuthProvider(repository=_cached_repo)
return _cached_local_provider
def get_pat_repo(request: Request):
"""Return the personal-access-token repository from app state.
Raises 503 when the process runs on the memory backend (no durable PAT
storage), so PAT management routes fail explicitly instead of silently
accepting tokens nobody can validate.
"""
pat_repo = getattr(request.app.state, "pat_repo", None)
if pat_repo is None:
raise HTTPException(status_code=503, detail="Personal access tokens require a configured database")
return pat_repo
async def get_current_user_from_request(request: Request):
"""Get the current authenticated user from the request cookie.
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_PAT, 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,
AUTH_SOURCE_PAT,
}:
return state_user
from app.gateway.auth import decode_token
from app.gateway.auth.errors import AuthErrorCode, AuthErrorResponse, TokenError, token_error_to_code
access_token = request.cookies.get("access_token")
if not access_token:
raise HTTPException(
status_code=401,
detail=AuthErrorResponse(code=AuthErrorCode.NOT_AUTHENTICATED, message="Not authenticated").model_dump(),
)
payload = decode_token(access_token)
if isinstance(payload, TokenError):
raise HTTPException(
status_code=401,
detail=AuthErrorResponse(code=token_error_to_code(payload), message=f"Token error: {payload.value}").model_dump(),
)
provider = get_local_provider()
user = await provider.get_user(payload.sub)
if user is None:
raise HTTPException(
status_code=401,
detail=AuthErrorResponse(code=AuthErrorCode.USER_NOT_FOUND, message="User not found").model_dump(),
)
# Operator-disabled account (#3462 gap 3) and stale token versions are
# both verdicts of the shared post-lookup validator: the password and
# PAT paths reject at their own surfaces; OAuth provisioning rejects at
# resolve time.
error = validate_resolved_session_user(user, payload)
if error is not None:
raise HTTPException(
status_code=401,
detail=AuthErrorResponse(code=error, message="Account disabled" if error is AuthErrorCode.ACCOUNT_DISABLED else "Token revoked (password changed)").model_dump(),
)
return user
def validate_resolved_session_user(user, payload) -> AuthErrorCode | None:
"""Shared post-lookup session validation for EVERY JWT authenticator.
The Gateway's HTTP dependency, the WebSocket authenticator (browser
streaming bypasses AuthMiddleware), and the standalone LangGraph
``authenticate`` callback all resolve cookie → JWT → user; this helper
is the one place the post-lookup verdicts live so a lifecycle change
(token_version bump, account suspension) lands everywhere at once.
Returns the failure code, or ``None`` when the session is valid —
callers map it to their own exception surface.
"""
if user.token_version != payload.ver:
return AuthErrorCode.TOKEN_INVALID
if getattr(user, "disabled", False):
return AuthErrorCode.ACCOUNT_DISABLED
return None
async def is_admin_user(request: Request) -> bool:
"""Return whether the authenticated caller is an admin user.
``AuthMiddleware`` normally stamps ``request.state.user`` before the request
reaches a router. Falling back to the strict dependency keeps the route safe
in tests or alternative ASGI compositions that mount a router without the
global middleware.
Centralising this here means a future change to the admin definition (e.g.
allowing an internal system role, adding audit logging, or switching to a
permission-based check) lands in one place instead of drifting across the
per-router copies that previously existed in ``mcp``, ``channel_connections``
and ``channels``.
"""
# PAT credentials never carry admin capability: no scope in the PAT
# universe grants it, so an admin's automation token must not unlock
# admin-only routes (skill installs, integration credentials, MCP config).
from app.gateway.auth_disabled import AUTH_SOURCE_PAT
if getattr(request.state, "auth_source", None) == AUTH_SOURCE_PAT:
return False
user = getattr(request.state, "user", None)
if user is None:
user = await get_current_user_from_request(request)
return getattr(user, "system_role", None) == "admin"
async def require_admin_user(request: Request, *, detail: str) -> None:
"""Require the authenticated caller to be an admin user.
``detail`` is the route-specific 403 message. The shared predicate keeps
read-side redaction and write authorization on the same admin definition.
"""
if not await is_admin_user(request):
raise HTTPException(status_code=403, detail=detail)
async def get_optional_user_from_request(request: Request):
"""Get optional authenticated user from request.
Returns None if not authenticated.
"""
try:
return await get_current_user_from_request(request)
except HTTPException:
return None
async def get_current_user(request: Request) -> str | None:
"""Extract user_id from request cookie, or None if not authenticated.
Thin adapter that returns the string id for callers that only need
identification (e.g., ``feedback.py``). Full-user callers should use
``get_current_user_from_request`` or ``get_optional_user_from_request``.
"""
user = await get_optional_user_from_request(request)
return str(user.id) if user else None