fix(config): revalidate the extensions config singleton by file signature (#6386)

* fix(config): revalidate the extensions config singleton by file signature

`get_extensions_config()` parsed `extensions_config.json` once per process
and only the process that handled a write ever reloaded it. With several
uvicorn workers, or several Pods sharing one volume, every other process
kept its startup copy: the MCP servers the agent could use and the
local-bash absolute path allowlist derived from the filesystem MCP server
differed between replicas until each one restarted.

The singleton now records the resolved path and the file's
(mtime, size, sha256) signature and reloads when either changes, like
`get_app_config()` does for `config.yaml` and `mcp/cache.py` does for the
tools cache. After a successful load, a revision that cannot be loaded
(file gone, truncated, invalid) keeps the last-known-good configuration
and is logged once with the exception type only; the first load still
raises. `set_extensions_config()` pins the injected instance until a
reload or reset. The cached AppConfig's `extensions` snapshot is built
from the singleton instance and refreshes when that instance changes, so
it inherits the same fail-soft policy instead of parsing the file again.

* docs(changelog): link the extensions config freshness entry to #6386

* fix(config): follow an explicitly reloaded extensions config path

`reload_extensions_config(config_path=X)` recorded X, but the freshness
probe re-resolved the default path, so the next read either flipped the
cache back to the default file or pinned a stale copy of X. The cache now
remembers the explicit path and both the probe and later reloads follow
it until a reset or an argument-less reload. Also shortens the pointer in
backend/AGENTS.md so the nested guidance chains stay under the hard
size limit enforced by the agent-guidance check.

* docs: keep backend guidance within the inherited size budget

* fix(config): reload the probed extensions config path

---------

Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
This commit is contained in:
Nan Gao 2026-10-07 09:39:26 +08:00 committed by GitHub
parent 7d600e20b7
commit 4f64ca2ae0
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
13 changed files with 716 additions and 36 deletions

View File

@ -718,6 +718,20 @@ This release closes that milestone with **439 merged pull requests**.
128-token budget before later query terms. Tokens without a letter or digit are
now dropped, as the no-jieba fallback and the FTS5 query filter already did.
([#6388])
- **config:** Every Gateway process that shares one `extensions_config.json`
now sees the MCP and skill changes made by another one. The parsed file was
cached once per process and only the process that handled the write
reloaded it, so with several uvicorn workers, or several Pods on one shared
volume, the other processes kept their startup copy: the MCP servers the
agent could use and, more importantly, the local-bash absolute path
allowlist derived from the filesystem MCP server differed between replicas
until each one restarted. `get_extensions_config()` now revalidates the
cached instance against the file's path and content signature on every
read, as `get_app_config()` does for `config.yaml`, and the `extensions`
snapshot of the cached `AppConfig` follows it. A truncated or invalid
revision (for example midway through the non-atomic overwrite fallback on
a bind-mounted file) keeps the previous configuration and is logged once;
a broken file at startup still fails loudly. ([#6386])
- **middleware:** Tool-output budgeting no longer hides a failed shell exit from
subagent evidence. A bash result between `externalize_min_chars` (12,000) and
the sandbox limit (20,000) was replaced by a preview ending in its `Access:`
@ -8958,5 +8972,6 @@ with **180 merged pull requests** since the first 2.0 milestone tag.
[#6365]: https://github.com/bytedance/deer-flow/pull/6365
[#6370]: https://github.com/bytedance/deer-flow/pull/6370
[#6378]: https://github.com/bytedance/deer-flow/pull/6378
[#6386]: https://github.com/bytedance/deer-flow/pull/6386
[#6388]: https://github.com/bytedance/deer-flow/pull/6388
[#6401]: https://github.com/bytedance/deer-flow/pull/6401

View File

@ -747,7 +747,7 @@ Plugin brand icons are bundled locally. When adding or editing one personal MCP
Capability Center > Plugins adds, replaces, and deletes one MCP server at a time through targeted mutations that preserve concurrent sibling changes; deletes use a bodyless URL-addressed request. An invalid stdio command on one server no longer blocks toggling another, while enabling that invalid server remains protected by the command allowlist and surfaces the backend validation message in the UI.
Targeted updates accept both DeerFlow's `type` field and the MCP-spec `transport` field for SSE/HTTP servers.
Runtime MCP and skill updates replace `extensions_config.json` atomically, so an interrupted write cannot leave the shared configuration truncated or partially written.
Runtime MCP and skill updates replace `extensions_config.json` atomically, so an interrupted write cannot leave the shared configuration truncated or partially written. Every Gateway worker or instance that reads the same file picks up a new revision on its next read (the cache checks the file's content signature), so MCP and skill changes made through one replica apply to the others without a restart; a missing, truncated, or invalid revision keeps the previous configuration until a complete one lands, including when the file disappears during a reload.
The admin MCP cache reset advances a durable generation marker in the writable config directory. Every Gateway worker mounting that same directory retires its own cached tools and pooled sessions before the next lookup; replicas with independent filesystems are not implicitly covered. If no config path is available, the API reports a process-local reset instead.
`extensions_config.json` accepts UTF-8 with or without a leading byte-order mark (BOM), including files saved as UTF-8 with BOM by an editor.
MCP routing hints can also prefer a specific MCP tool for matching requests without forbidding other tools. When `tool_search` defers MCP schemas, matching routing metadata can auto-promote up to `tool_search.auto_promote_top_k` deferred schemas before the model call.

View File

@ -24,7 +24,7 @@ The backend runs a LangGraph-based super agent with sandbox execution, persisten
- Scheduled-task dispatch permits one active occurrence per task via `uq_scheduled_task_run_active` (`task_id WHERE status IN ('queued','launching','running')`). Durable `queued` rows survive restarts; only lease-fenced `launching` may call Gateway launch; `running` references the durable run. Stable admission idempotency keys reuse that run after recovery. Reused-thread `ConflictError` returns `launching` to `queued`; other launch errors become `failed`. Atomic queue claims enforce `max_concurrent_runs`, excluding waiting rows; the budget count and its UPDATE are separate statements, so writers must serialize before the count (Postgres advisory lock; SQLite `BEGIN IMMEDIATE`, whose deferred transaction otherwise reserves the writer only at the UPDATE) or claims on distinct rows overshoot the cap. Repeated triggers coalesce; same-thread FIFO blocks behind older active rows. Queue admission, PATCH/resume, pause and delete lock the parent before the occurrence, freezing active task definitions. Pause/delete atomically cancel `queued` work but reject `launching`/`running`; PATCH/resume reject all active states. Only queued conflicts offer pause cancellation. Manual triggers may queue/run while paused. Recovery locks task/run pairs in task-id/run-id order and restores `run_id`, `started_at` and live errors before releasing launch claims. Launch/failure/timeout updates use one parent-first transaction to prevent interleaved claims. Queue timeout fails the occurrence and advances scheduled work to prevent immediate requeue. Repository boundaries coerce serialized timestamps before SQL `DateTime` binding.
- Scheduled-task lifecycle: [persistence guide](packages/harness/deerflow/persistence/AGENTS.md#scheduled-task-lifecycle).
- `POST /api/scheduled-tasks/preview-cron` requires authenticated `threads:read`. Bounded cron previews call the shared scheduler calculator in `asyncio.to_thread`, preserving its DST semantics. Capture the optional aware reference once; return UTC and offset-bearing local occurrences without acquiring task/thread/run stores or dispatching work. This advisory API does not reserve execution.
- `extensions_config.json` is written at runtime by the Gateway (`PUT`/`PATCH /api/mcp/config`, the MCP enable switch, skill updates), so the production compose mounts it read-write while `config.yaml` stays `:ro`; Helm copies its ConfigMap seed into a writable home-volume directory before Gateway starts. Every read-modify-write holds both `extensions_config_write_lock` and the sidecar advisory `extensions_config_file_lock`, because the process-local lock alone loses updates across workers. Docker mounts the compose file as its own mount point, and Linux refuses `rename()` over a mount point with `EBUSY` even when the mount is writable — so `atomic_write_extensions_config` keeps the temp-file-plus-rename path and falls back to an in-place overwrite only on `EBUSY`. That fallback is deliberately non-atomic (a crash mid-write truncates the file); it exists because the alternative is a write that can never succeed, and only its first occurrence per target is logged at warning level. Any other `errno` still propagates. Pinned by `tests/test_compose_extensions_config_writable.py`, `tests/test_extensions_config_atomic_write.py`, and `tests/test_helm_extensions_config_writable.py`.
- Gateway MCP/skill updates write `extensions_config.json` at runtime. Production Compose mounts it read-write (`config.yaml` stays `:ro`); Helm seeds a writable home-volume copy. Hold both `extensions_config_write_lock` and the sidecar advisory `extensions_config_file_lock` during read-modify-write to prevent lost updates across workers. `atomic_write_extensions_config` uses temp-file replacement; only mount-point `EBUSY` permits a non-atomic in-place overwrite (crashes can truncate). Warn about this fallback once per target; propagate other errors. `get_extensions_config()` revalidates reads by file signature; see [config caching](packages/harness/deerflow/config/AGENTS.md). Tests: `tests/test_compose_extensions_config_writable.py`, `tests/test_extensions_config_atomic_write.py`, `tests/test_helm_extensions_config_writable.py`.
- MCP cache reset scope and discovery fencing: [MCP guide](packages/harness/deerflow/mcp/AGENTS.md).
**Project Structure**:

View File

@ -122,6 +122,16 @@ preference hints for requests that should prefer a specific MCP server or tool.
See [MCP Server Configuration](MCP_SERVER.md#routing-hints) for the schema,
example, and soft-vs-hard routing boundary.
Runtime edits to `extensions_config.json` (the MCP and skills APIs, the web UI,
`DeerFlowClient`, or an editor) are picked up by every Gateway process that reads
the same file: the process cache revalidates the file's path and content
signature on each read, so uvicorn workers and multi-instance Pods sharing one
volume converge without a restart or a per-Pod reload call. A change made through
an API call on one instance is visible to the others on their next request. A
missing, partially written or invalid file keeps the previously loaded configuration
until a complete revision lands, including when it disappears during a reload;
the Gateway logs one warning per such revision.
### Recursion Limits
Gateway runs use the top-level `recursion_limit` as their LangGraph super-step

View File

@ -50,7 +50,7 @@ Zero per-tool overrides disable externalization only; a positive global
fallback budget still truncates oversized output. Regression tests exercise
YAML loading and both middleware tool-call paths in `test_tool_output_config_limits.py`.
**Config Caching**: `get_app_config()` caches the parsed config, but automatically reloads it when the resolved config path or file content signature changes. The signature includes file metadata and a content digest, so Gateway and LangGraph reads stay aligned with `config.yaml` edits even on object-store or network mounts where mtime can remain stale. The loader reads the file once through `file_signature.read_config_with_signature` and parses those same bytes, so the recorded signature always describes the parsed content: a write that races the load can only cause one extra reload, never a cache that holds one revision under another revision's signature (which the comparison could never detect).
**Config Caching**: `get_app_config()` caches the parsed config, but automatically reloads it when the resolved config path or file content signature changes. The signature includes file metadata and a content digest, so Gateway and LangGraph reads stay aligned with `config.yaml` edits even on object-store or network mounts where mtime can remain stale. The loader reads the file once through `file_signature.read_config_with_signature` and parses those same bytes, so the recorded signature always describes the parsed content: a write that races the load can only cause one extra reload, never a cache that holds one revision under another revision's signature (which the comparison could never detect). The cached `extensions` snapshot follows the process extensions singleton: `get_app_config()` also reloads when `get_extensions_config()` returns a new instance (see **Extensions Config Caching** below), and the loader takes that instance before reading `config.yaml`, so it can only be older than the YAML it merges with, never newer.
**Config Hot-Reload Boundary**: Gateway dependencies route through `get_app_config()` on every request, so per-run fields like `models[*].max_tokens`, `summarization.*`, `title.*`, `memory.*`, `subagents.*`, `verification.*`, `tools[*]`, and the agent system prompt pick up `config.yaml` edits on the next message. `AppConfig` is intentionally **not** cached on `app.state` — `lifespan()` keeps a local `startup_config` variable for one-shot bootstrap work and passes it to `langgraph_runtime(app, startup_config)`.
@ -106,6 +106,12 @@ Configuration priority:
Extensions are optional only in the fallback *search* mode (priority 3-4 above): `ExtensionsConfig.resolve_config_path()` returns `None` when neither an explicit `config_path` nor `DEER_FLOW_EXTENSIONS_CONFIG_PATH` is given and the search locations find nothing. An explicit `config_path` argument or a set `DEER_FLOW_EXTENSIONS_CONFIG_PATH` (priority 1-2) is an operator assertion that one particular file must be used, so a missing file in either of those modes raises `FileNotFoundError` instead — including when the file existed earlier and has since been deleted. The MCP tools cache's staleness check (`deerflow.mcp.cache._resolve_config_path`) is a narrow, deliberate exception to that rule: it catches that `FileNotFoundError` locally and treats it as "unconfigured" so a previously-valid config disappearing mid-run degrades the cache to serving its last-known-good tools instead of raising out of a per-request hot path (see the MCP System section below).
**Extensions Config Caching**: `get_extensions_config()` caches the parsed file but revalidates it on every call against the resolved path and the `(mtime, size, sha256)` signature from `file_signature.get_config_signature`, so an edit made by another Gateway worker, or by another instance sharing the file (the Helm home volume, the compose bind mount), is visible to this process on its next read without a restart or an explicit reload. Writers (MCP router, skill toggle, `DeerFlowClient`) still call `reload_extensions_config()` after their write, which records the written revision so the next read does not reload it again. Once a configuration has been loaded, a revision that cannot be loaded — the file vanished, or it is truncated or invalid, for example midway through the non-atomic `EBUSY` overwrite fallback — keeps the last-known-good configuration and is logged once at warning level with the exception type only (validation messages can embed resolved `$VAR` secrets); the first load still raises, so a broken file at startup stays loud. `reload_extensions_config(config_path=...)` makes the cache follow that file (later edits included) until a reset or an argument-less reload. `set_extensions_config()` pins an injected instance until `reload_extensions_config()` or `reset_extensions_config()`; tests that inject one must reset it afterwards, because the `extensions` snapshot of `get_app_config()` follows the pinned instance too. The local-bash absolute path allowlist (`sandbox/tools.py::_get_mcp_allowed_paths`) is derived from this singleton, which is why the revalidation is a security property rather than an optimization. Pinned by `tests/test_extensions_config_freshness.py` and the extensions cases in `tests/test_app_config_reload.py`.
Freshness reloads parse the probed path explicitly. A search-mode file vanishing
between signature collection and parsing must retain the last-known-good cache,
including AppConfig's middleware snapshot, until a readable revision returns.
### Config Schema
**`config.yaml`** key sections:

View File

@ -21,7 +21,7 @@ from deerflow.config.checkpointer_config import CheckpointerConfig, load_checkpo
from deerflow.config.database_config import DatabaseConfig
from deerflow.config.dedupe_storage_config import DedupeStorageConfig
from deerflow.config.deployment_config import DeploymentConfig
from deerflow.config.extensions_config import ExtensionsConfig
from deerflow.config.extensions_config import ExtensionsConfig, get_extensions_config
from deerflow.config.file_signature import ConfigSignature as _ConfigSignature
from deerflow.config.file_signature import get_config_signature as _get_config_signature
from deerflow.config.file_signature import read_config_with_signature as _read_config_with_signature
@ -490,12 +490,13 @@ class AppConfig(BaseModel):
return cls._from_yaml_text(f.read(), resolved_path)
@classmethod
def _from_yaml_text(cls, text: str, resolved_path: Path) -> Self:
def _from_yaml_text(cls, text: str, resolved_path: Path, extensions: ExtensionsConfig | None = None) -> Self:
"""Build the config from already-read YAML *text* of *resolved_path*.
Split out of :meth:`from_file` so the process-wide cache can parse the
exact bytes it signed (see ``_load_and_cache_app_config``) instead of
reading the file a second time.
reading the file a second time. *extensions* is the already-loaded
``extensions_config.json`` to merge; when omitted the file is read.
"""
config_data = yaml.safe_load(text) or {}
@ -512,9 +513,11 @@ class AppConfig(BaseModel):
# Load extensions config separately (it's in a different file), while
# preserving any config.yaml-backed extension fields. config.yaml wins
# when it explicitly declares a field because those values are part of
# the main AppConfig hot-reload contract.
# the main AppConfig hot-reload contract. The process-wide cache passes
# its revalidated ``get_extensions_config()`` instance in; a direct
# ``from_file`` call reads the file itself.
yaml_extensions = config_data.get("extensions")
extensions_config = ExtensionsConfig.from_file()
extensions_config = ExtensionsConfig.from_file() if extensions is None else extensions
extensions_data = extensions_config.model_dump(by_alias=True)
if isinstance(yaml_extensions, Mapping):
yaml_extensions_config = ExtensionsConfig.model_validate(yaml_extensions)
@ -728,6 +731,11 @@ _app_config_path: Path | None = None
_app_config_mtime: float | None = None
_app_config_signature: _ConfigSignature | None = None
_app_config_is_custom = False
# The ``get_extensions_config()`` instance the cached AppConfig's ``extensions``
# snapshot was built from. That singleton is replaced only when the shared
# ``extensions_config.json`` changes (or a writer reloads it), so identity is
# the freshness signal for the snapshot.
_app_config_extensions_source: ExtensionsConfig | None = None
_current_app_config: ContextVar[AppConfig | None] = ContextVar("deerflow_current_app_config", default=None)
_current_app_config_stack: ContextVar[tuple[AppConfig | None, ...]] = ContextVar("deerflow_current_app_config_stack", default=())
@ -740,7 +748,7 @@ def _get_config_mtime(config_path: Path) -> float | None:
return None
def _load_and_cache_app_config(config_path: str | None = None) -> AppConfig:
def _load_and_cache_app_config(config_path: str | None = None, *, extensions_source: ExtensionsConfig | None = None) -> AppConfig:
"""Load config from disk and refresh cache metadata.
The file is read exactly once and the recorded signature is computed from
@ -750,17 +758,30 @@ def _load_and_cache_app_config(config_path: str | None = None) -> AppConfig:
because the on-disk signature already matches, so the edit would only be
picked up by the *next* edit. Signing the parsed bytes makes any write that
races the load show up as a signature mismatch on the next call instead.
The ``extensions`` snapshot comes from the process extensions singleton
rather than from a second parse of ``extensions_config.json``: that
singleton revalidates by path and content signature and keeps its
last-known-good revision while the file is mid-write, so the AppConfig
cache inherits that policy instead of failing on a revision the singleton
rejected. It is taken before ``config.yaml`` is read, so it can only be
older than the YAML it is merged with, never newer; a change in between
is one extra reload on the next call.
"""
global _app_config, _app_config_path, _app_config_mtime, _app_config_signature, _app_config_is_custom
global _app_config_extensions_source
resolved_path = AppConfig.resolve_config_path(config_path)
if extensions_source is None:
extensions_source = get_extensions_config()
raw, signature = _read_config_with_signature(resolved_path)
config = AppConfig._from_yaml_text(raw.decode("utf-8"), resolved_path)
config = AppConfig._from_yaml_text(raw.decode("utf-8"), resolved_path, extensions_source)
_app_config = config
_app_config_path = resolved_path
_app_config_mtime = signature[0]
_app_config_signature = signature
_app_config_is_custom = False
_app_config_extensions_source = extensions_source
return _app_config
@ -768,9 +789,11 @@ def get_app_config() -> AppConfig:
"""Get the DeerFlow config instance.
Returns a cached singleton instance and automatically reloads it when the
underlying config file path or content signature changes. Use
`reload_app_config()` to force a reload, or `reset_app_config()` to clear
the cache.
underlying config file path or content signature changes, or when the
process extensions config (`get_extensions_config()`) was reloaded because
the shared ``extensions_config.json`` changed, so the ``extensions``
snapshot follows that file too. Use `reload_app_config()` to force a
reload, or `reset_app_config()` to clear the cache.
"""
global _app_config, _app_config_path, _app_config_mtime, _app_config_signature
@ -784,8 +807,9 @@ def get_app_config() -> AppConfig:
resolved_path = AppConfig.resolve_config_path()
current_mtime = _get_config_mtime(resolved_path)
current_signature = _get_config_signature(resolved_path)
current_extensions_source = get_extensions_config()
should_reload = _app_config is None or _app_config_path != resolved_path or _app_config_signature != current_signature
should_reload = _app_config is None or _app_config_path != resolved_path or _app_config_signature != current_signature or current_extensions_source is not _app_config_extensions_source
if should_reload:
if _app_config_path == resolved_path and _app_config_mtime is not None and current_mtime is not None and _app_config_mtime != current_mtime:
logger.info(
@ -795,7 +819,9 @@ def get_app_config() -> AppConfig:
)
elif _app_config_path == resolved_path and _app_config_signature != current_signature:
logger.info("Config file content signature changed, reloading AppConfig")
_load_and_cache_app_config(str(resolved_path))
elif _app_config_path == resolved_path:
logger.info("Extensions config changed, reloading AppConfig")
_load_and_cache_app_config(str(resolved_path), extensions_source=current_extensions_source)
from deerflow.config.managed_models import merge_managed_models
return merge_managed_models(_app_config)
@ -827,11 +853,13 @@ def reset_app_config() -> None:
or when switching between different configurations.
"""
global _app_config, _app_config_path, _app_config_mtime, _app_config_signature, _app_config_is_custom
global _app_config_extensions_source
_app_config = None
_app_config_path = None
_app_config_mtime = None
_app_config_signature = None
_app_config_is_custom = False
_app_config_extensions_source = None
def set_app_config(config: AppConfig) -> None:
@ -843,11 +871,13 @@ def set_app_config(config: AppConfig) -> None:
config: The AppConfig instance to use.
"""
global _app_config, _app_config_path, _app_config_mtime, _app_config_signature, _app_config_is_custom
global _app_config_extensions_source
_app_config = config
_app_config_path = None
_app_config_mtime = None
_app_config_signature = None
_app_config_is_custom = True
_app_config_extensions_source = None
def peek_current_app_config() -> AppConfig | None:

View File

@ -17,6 +17,7 @@ from typing import Any, Literal
from pydantic import BaseModel, ConfigDict, Field, ValidationInfo, field_validator, model_validator
from deerflow.config._boolean_guards import reject_boolean
from deerflow.config.file_signature import ConfigSignature, get_config_signature
from deerflow.config.runtime_paths import existing_project_file
from deerflow.constants import (
DEFAULT_MCP_SESSION_INIT_TIMEOUT,
@ -608,7 +609,38 @@ class ExtensionsConfig(BaseModel):
return skill_config.enabled
# Process-wide cache of the parsed ``extensions_config.json``. The file is
# edited at runtime (MCP and skill updates through the Gateway API or
# ``DeerFlowClient``), and in a multi-worker or multi-instance deployment the
# process that writes it is usually not the one that later reads it: every
# Gateway process shares one ``extensions_config.json`` on the same volume.
# Writers reload their own process explicitly; every other process has to
# notice the change by itself. ``get_extensions_config()`` therefore
# revalidates the cached instance against the resolved path and the file's
# ``(mtime, size, sha256)`` signature, the same freshness contract
# ``get_app_config()`` applies to ``config.yaml`` and ``deerflow.mcp.cache``
# applies to its tools cache. The local-bash absolute path allowlist is
# derived from this cache, so a stale copy is a security drift between
# replicas, not merely a stale tool list.
_extensions_config: ExtensionsConfig | None = None
_extensions_config_path: Path | None = None
_extensions_config_signature: ConfigSignature | None = None
_extensions_config_is_custom = False
# The explicit ``config_path`` the cached revision was loaded from, when
# ``reload_extensions_config(config_path=...)`` chose a file that default
# resolution would not. The probe and later reloads follow that file until
# ``reset_extensions_config()`` or an argument-less reload; probing the
# default resolution instead would either flip the cache back to the default
# file or pin a stale copy of the explicit one.
_extensions_config_source: str | None = None
# ``(path, signature)`` of the on-disk revision that most recently could not
# be loaded after a successful load (file gone, truncated or invalid). The
# cache keeps serving the last-known-good configuration and warns once per
# such revision rather than on every call.
_extensions_config_rejected: tuple[Path | None, ConfigSignature | None] | None = None
# Serializes probe-compare-reload so two concurrent reloads cannot leave the
# cache holding one revision's content under another revision's signature.
_extensions_config_lock = threading.Lock()
def _fsync_directory_best_effort(directory: Path) -> None:
@ -761,19 +793,130 @@ def set_raw_skill_enabled(raw_data: dict[str, Any], skill_name: str, enabled: bo
skills[skill_name] = {"enabled": enabled}
def _probe_extensions_config_state() -> tuple[Path | None, ConfigSignature | None]:
"""Return the currently resolved config path and its content signature.
Only used once a configuration has been loaded. The explicit path the
cache was reloaded from, if any, takes precedence over default
resolution. An explicit path or ``DEER_FLOW_EXTENSIONS_CONFIG_PATH`` that
no longer exists makes
``resolve_config_path`` raise; for a loaded cache that means "no usable
file right now" and is reported as ``(None, None)`` so the caller keeps
the last-known-good configuration instead of failing a hot path. The
first load does not use this probe and still raises for a missing
explicit file.
"""
try:
path = ExtensionsConfig.resolve_config_path(_extensions_config_source)
except FileNotFoundError:
return None, None
if path is None:
return None, None
return path, get_config_signature(path)
def _load_and_cache_extensions_config(config_path: str | None = None) -> ExtensionsConfig:
"""Load the config from disk and record the revision it came from.
The caller holds ``_extensions_config_lock``. The signature is probed
*before* parsing, so it describes the parsed revision or an older one,
never a newer one: a write that lands between the probe and the parse
then shows up as one extra reload on the next call. Probing afterwards
could record the newer revision's signature against the older content, a
stale state the comparison could never detect.
"""
global _extensions_config, _extensions_config_path, _extensions_config_signature
global _extensions_config_is_custom, _extensions_config_source, _extensions_config_rejected
resolved_path = ExtensionsConfig.resolve_config_path(config_path)
signature = get_config_signature(resolved_path) if resolved_path is not None else None
# ``from_file`` keeps resolving the path itself (and is called without an
# argument when none was given) so callers that substitute it see the
# same call shape as before.
loaded = ExtensionsConfig.from_file(config_path) if config_path else ExtensionsConfig.from_file()
_extensions_config = loaded
_extensions_config_path = resolved_path
_extensions_config_signature = signature
_extensions_config_is_custom = False
_extensions_config_source = config_path or None
_extensions_config_rejected = None
return loaded
def _describe_load_failure(exc: BaseException) -> str:
"""Name the failure without its message.
``from_file`` resolves ``$VAR`` placeholders before validation, so a
validation message can embed resolved credentials; only exception types
are safe to log.
"""
cause = exc.__cause__
if cause is None:
return type(exc).__name__
return f"{type(exc).__name__} from {type(cause).__name__}"
def _keep_last_known_good(current_path: Path | None, current_signature: ConfigSignature | None, message: str, *args: object) -> None:
"""Warn once per unusable on-disk revision; the caller keeps the cache."""
global _extensions_config_rejected
rejected = (current_path, current_signature)
if _extensions_config_rejected == rejected:
return
_extensions_config_rejected = rejected
logger.warning(message + "; keeping the previously loaded configuration", *args)
def get_extensions_config() -> ExtensionsConfig:
"""Get the extensions config instance.
Returns a cached singleton instance. Use `reload_extensions_config()` to reload
from file, or `reset_extensions_config()` to clear the cache.
Returns a cached singleton instance and reloads it when the resolved
config file path or the file's content signature changes, so a write made
by another Gateway worker or instance that shares the file is visible on
the next call without an explicit reload. An instance injected with
`set_extensions_config()` is pinned until `reload_extensions_config()` or
`reset_extensions_config()`.
Once a configuration has been loaded, a revision that cannot be loaded
(the file vanished, or it is truncated or invalid, for example midway
through the non-atomic overwrite fallback of
:func:`atomic_write_extensions_config`) keeps the last-known-good
configuration and is logged once. The first load still raises, so a
broken configuration at startup stays loud.
Returns:
The cached ExtensionsConfig instance.
"""
global _extensions_config
if _extensions_config is None:
_extensions_config = ExtensionsConfig.from_file()
return _extensions_config
global _extensions_config, _extensions_config_path, _extensions_config_signature, _extensions_config_rejected
with _extensions_config_lock:
if _extensions_config is None:
return _load_and_cache_extensions_config()
if _extensions_config_is_custom:
return _extensions_config
current_path, current_signature = _probe_extensions_config_state()
if current_path == _extensions_config_path and current_signature == _extensions_config_signature:
return _extensions_config
if current_path is None:
_keep_last_known_good(current_path, current_signature, "Extensions config at %s is no longer available", _extensions_config_path)
return _extensions_config
try:
# Parse the probed file: a second search could publish an empty
# config if that file disappeared before parsing.
loaded = ExtensionsConfig.from_file(str(current_path))
except Exception as exc:
_keep_last_known_good(current_path, current_signature, "Extensions config at %s changed but could not be loaded (%s)", current_path, _describe_load_failure(exc))
return _extensions_config
logger.info("Extensions config at %s changed on disk; reloaded", current_path)
_extensions_config = loaded
_extensions_config_path = current_path
_extensions_config_signature = current_signature
_extensions_config_rejected = None
return loaded
#: Serializes read-modify-write cycles on ``extensions_config.json`` across every
@ -846,12 +989,17 @@ def reload_extensions_config(config_path: str | None = None) -> ExtensionsConfig
config_path: Optional path to extensions config file. If not provided,
uses the default resolution strategy.
The loaded revision is recorded, so a following `get_extensions_config()`
does not reload it again. When *config_path* is given, the cache keeps
following that file (its later edits are picked up and default
resolution is not consulted) until `reset_extensions_config()` or an
argument-less reload.
Returns:
The newly loaded ExtensionsConfig instance.
"""
global _extensions_config
_extensions_config = ExtensionsConfig.from_file(config_path)
return _extensions_config
with _extensions_config_lock:
return _load_and_cache_extensions_config(config_path)
def reset_extensions_config() -> None:
@ -861,17 +1009,35 @@ def reset_extensions_config() -> None:
`get_extensions_config()` to reload from file. Useful for testing
or when switching between different configurations.
"""
global _extensions_config
_extensions_config = None
global _extensions_config, _extensions_config_path, _extensions_config_signature
global _extensions_config_is_custom, _extensions_config_source, _extensions_config_rejected
with _extensions_config_lock:
_extensions_config = None
_extensions_config_path = None
_extensions_config_signature = None
_extensions_config_is_custom = False
_extensions_config_source = None
_extensions_config_rejected = None
def set_extensions_config(config: ExtensionsConfig) -> None:
"""Set a custom extensions config instance.
This allows injecting a custom or mock config for testing purposes.
This allows injecting a custom or mock config for testing purposes. The
instance is pinned: `get_extensions_config()` returns it without consulting
the file until `reload_extensions_config()` or `reset_extensions_config()`.
Args:
config: The ExtensionsConfig instance to use.
"""
global _extensions_config
_extensions_config = config
global _extensions_config, _extensions_config_path, _extensions_config_signature
global _extensions_config_is_custom, _extensions_config_source, _extensions_config_rejected
with _extensions_config_lock:
_extensions_config = config
_extensions_config_path = None
_extensions_config_signature = None
_extensions_config_is_custom = True
_extensions_config_source = None
_extensions_config_rejected = None

View File

@ -13,6 +13,7 @@ from app.gateway.deps import _validate_agent_storage
from deerflow.config.agent_storage_config import AgentStorageConfig
from deerflow.config.app_config import reset_app_config
from deerflow.config.database_config import DatabaseConfig
from deerflow.config.extensions_config import reset_extensions_config
from deerflow.persistence.agents import get_agent_store, make_agent_store
from deerflow.persistence.agents.file import FileAgentStore
from deerflow.persistence.agents.model import AgentRow
@ -338,7 +339,12 @@ def test_get_agent_store_does_not_fallback_when_extensions_config_is_missing(tmp
monkeypatch.setenv("DEER_FLOW_EXTENSIONS_CONFIG_PATH", str(tmp_path / "missing-extensions.json"))
try:
reset_app_config()
# Startup scenario: an already-loaded extensions singleton would keep
# its last-known-good copy when the file disappears, so clear it to
# exercise the first load, which must still fail loudly.
reset_extensions_config()
with pytest.raises(FileNotFoundError, match="Extensions config"):
get_agent_store()
finally:
reset_app_config()
reset_extensions_config()

View File

@ -9,11 +9,19 @@ import yaml
from pydantic import ValidationError
import deerflow.config.app_config as app_config_module
import deerflow.config.extensions_config as extensions_config_module
from deerflow.config.acp_config import load_acp_config_from_dict
from deerflow.config.agents_api_config import get_agents_api_config, load_agents_api_config_from_dict
from deerflow.config.app_config import AppConfig, get_app_config, peek_loaded_app_config, reset_app_config
from deerflow.config.checkpointer_config import get_checkpointer_config, load_checkpointer_config_from_dict
from deerflow.config.database_config import DatabaseConfig
from deerflow.config.extensions_config import (
ExtensionsConfig,
atomic_write_extensions_config,
reload_extensions_config,
reset_extensions_config,
set_extensions_config,
)
from deerflow.config.guardrails_config import get_guardrails_config, load_guardrails_config_from_dict
from deerflow.config.memory_config import get_memory_config, load_memory_config_from_dict
from deerflow.config.stream_bridge_config import get_stream_bridge_config, load_stream_bridge_config_from_dict
@ -27,6 +35,7 @@ from deerflow.storage import BlobNotConfiguredError, get_blob_store, get_blob_st
def _reset_config_singletons() -> None:
reset_extensions_config()
load_title_config_from_dict({})
load_summarization_config_from_dict({})
load_memory_config_from_dict({})
@ -102,8 +111,28 @@ def _write_config_with_sections(path: Path, sections: dict | None = None) -> Non
path.write_text(yaml.safe_dump(config), encoding="utf-8")
def _write_extensions_config(path: Path) -> None:
path.write_text(json.dumps({"mcpServers": {}, "skills": {}}), encoding="utf-8")
def _write_extensions_config(path: Path, *, middlewares: list[str] | None = None) -> None:
payload: dict = {"mcpServers": {}, "skills": {}}
if middlewares is not None:
payload["middlewares"] = middlewares
path.write_text(json.dumps(payload), encoding="utf-8")
def _stage_singleton_configs(tmp_path: Path, monkeypatch, *, middlewares: list[str]) -> Path:
"""Stage config.yaml plus an extensions file and point the singletons at them."""
config_path = tmp_path / "config.yaml"
extensions_path = tmp_path / "extensions_config.json"
_write_extensions_config(extensions_path, middlewares=middlewares)
_write_config(config_path, model_name="model-a", supports_thinking=False)
monkeypatch.setenv("DEER_FLOW_CONFIG_PATH", str(config_path))
monkeypatch.setenv("DEER_FLOW_EXTENSIONS_CONFIG_PATH", str(extensions_path))
_reset_config_singletons()
return extensions_path
def _peer_writes_extensions(path: Path, *, middlewares: list[str], skills: dict | None = None) -> None:
"""Another Gateway process rewrites the shared file without reloading this one."""
atomic_write_extensions_config(path, {"mcpServers": {}, "skills": skills or {}, "middlewares": middlewares})
def test_checkpoint_channel_mode_defaults_to_full() -> None:
@ -958,3 +987,123 @@ def test_an_unquoted_off_mode_loads_and_rolls_back(tmp_path, monkeypatch):
assert rolled_back.memory.signal_classification.mode == "off"
finally:
_reset_config_singletons()
def test_get_app_config_refreshes_extensions_snapshot_after_peer_extensions_write(tmp_path, monkeypatch):
extensions_path = _stage_singleton_configs(tmp_path, monkeypatch, middlewares=["pkg.first:FirstMiddleware"])
try:
initial = get_app_config()
loaded = peek_loaded_app_config()
yaml_signature = app_config_module._app_config_signature
assert initial.extensions.middlewares == ["pkg.first:FirstMiddleware"]
get_app_config()
assert peek_loaded_app_config() is loaded, "neither file changed: no reload"
_peer_writes_extensions(extensions_path, middlewares=["pkg.second:SecondMiddleware"], skills={"demo": {"enabled": False}})
refreshed = get_app_config()
assert refreshed.extensions.middlewares == ["pkg.second:SecondMiddleware"]
assert refreshed.extensions.skills["demo"].enabled is False
assert peek_loaded_app_config() is not loaded
assert app_config_module._app_config_signature == yaml_signature, "config.yaml itself was not re-signed"
finally:
_reset_config_singletons()
def test_get_app_config_keeps_extensions_snapshot_while_extensions_file_is_half_written(tmp_path, monkeypatch):
extensions_path = _stage_singleton_configs(tmp_path, monkeypatch, middlewares=["pkg.first:FirstMiddleware"])
try:
get_app_config()
loaded = peek_loaded_app_config()
# The non-atomic overwrite fallback can expose a truncated document.
extensions_path.write_text('{"mcpServers": {', encoding="utf-8")
assert get_app_config().extensions.middlewares == ["pkg.first:FirstMiddleware"]
assert peek_loaded_app_config() is loaded
_peer_writes_extensions(extensions_path, middlewares=["pkg.second:SecondMiddleware"])
assert get_app_config().extensions.middlewares == ["pkg.second:SecondMiddleware"]
finally:
_reset_config_singletons()
def test_get_app_config_keeps_extensions_snapshot_when_search_file_disappears_during_reload(tmp_path, monkeypatch):
extensions_path = _stage_singleton_configs(tmp_path, monkeypatch, middlewares=["pkg.first:FirstMiddleware"])
monkeypatch.delenv("DEER_FLOW_EXTENSIONS_CONFIG_PATH")
monkeypatch.setenv("DEER_FLOW_PROJECT_ROOT", str(tmp_path))
monkeypatch.setattr(extensions_config_module, "__file__", str(tmp_path / "isolated/backend/packages/harness/deerflow/config/extensions_config.py"))
try:
initial = get_app_config()
loaded = peek_loaded_app_config()
yaml_signature = app_config_module._app_config_signature
assert initial.extensions.middlewares == ["pkg.first:FirstMiddleware"]
_peer_writes_extensions(extensions_path, middlewares=["pkg.second:SecondMiddleware"])
original_signature = extensions_config_module.get_config_signature
def signature_with_peer_deletion(path: Path):
assert path == extensions_path
signature = original_signature(path)
path.unlink()
return signature
with monkeypatch.context() as race:
race.setattr(extensions_config_module, "get_config_signature", signature_with_peer_deletion)
assert get_app_config().extensions.middlewares == ["pkg.first:FirstMiddleware"]
assert peek_loaded_app_config() is loaded
assert app_config_module._app_config_signature == yaml_signature
assert get_app_config().extensions.middlewares == ["pkg.first:FirstMiddleware"]
_peer_writes_extensions(extensions_path, middlewares=["pkg.second:SecondMiddleware"])
assert get_app_config().extensions.middlewares == ["pkg.second:SecondMiddleware"]
assert peek_loaded_app_config() is not loaded
assert app_config_module._app_config_signature == yaml_signature
finally:
_reset_config_singletons()
def test_get_app_config_follows_writer_reload_and_injected_extensions_config(tmp_path, monkeypatch):
extensions_path = _stage_singleton_configs(tmp_path, monkeypatch, middlewares=["pkg.first:FirstMiddleware"])
try:
assert get_app_config().extensions.middlewares == ["pkg.first:FirstMiddleware"]
# A writer in this process reloads after its own write.
_peer_writes_extensions(extensions_path, middlewares=["pkg.second:SecondMiddleware"])
reload_extensions_config()
assert get_app_config().extensions.middlewares == ["pkg.second:SecondMiddleware"]
# An injected instance is pinned, and the AppConfig snapshot follows it.
set_extensions_config(ExtensionsConfig(middlewares=["pkg.injected:InjectedMiddleware"]))
assert get_app_config().extensions.middlewares == ["pkg.injected:InjectedMiddleware"]
finally:
_reset_config_singletons()
def test_get_app_config_keeps_config_yaml_extension_override_after_extensions_reload(tmp_path, monkeypatch):
extensions_path = _stage_singleton_configs(tmp_path, monkeypatch, middlewares=["pkg.first:FirstMiddleware"])
config_path = tmp_path / "config.yaml"
config_path.write_text(
yaml.safe_dump(
{
"sandbox": {"use": "deerflow.sandbox.local:LocalSandboxProvider"},
"models": [{"name": "model-a", "use": "langchain_openai:ChatOpenAI", "model": "gpt-test"}],
"extensions": {"middlewares": ["pkg.from_yaml:YamlMiddleware"]},
}
),
encoding="utf-8",
)
try:
assert get_app_config().extensions.middlewares == ["pkg.from_yaml:YamlMiddleware"]
_peer_writes_extensions(extensions_path, middlewares=["pkg.second:SecondMiddleware"])
assert get_app_config().extensions.middlewares == ["pkg.from_yaml:YamlMiddleware"]
finally:
_reset_config_singletons()

View File

@ -0,0 +1,298 @@
"""Freshness of the ``extensions_config.json`` process singleton.
Every Gateway process that shares one ``extensions_config.json`` (several
uvicorn workers, or several Pods on one shared volume) must observe the same
configuration. A writer reloads its own process after writing, but a peer
process only learns about the change if ``get_extensions_config()``
revalidates the cached singleton against the file. The local-bash absolute
path allowlist is derived from that singleton, so a stale copy is a security
drift between replicas, not merely a stale tool list.
"""
from __future__ import annotations
import logging
import os
from collections.abc import Iterator
from pathlib import Path
import pytest
import deerflow.config.extensions_config as extensions_config_module
from deerflow.config.extensions_config import (
ExtensionsConfig,
McpServerConfig,
atomic_write_extensions_config,
get_extensions_config,
reload_extensions_config,
reset_extensions_config,
set_extensions_config,
)
from deerflow.sandbox.tools import _get_mcp_allowed_paths
_LOGGER_NAME = "deerflow.config.extensions_config"
_KEEPING_PREVIOUS = "keeping the previously loaded configuration"
def _filesystem_server_payload(*paths: str, enabled: bool = True) -> dict:
return {
"mcpServers": {
"filesystem": {
"enabled": enabled,
"type": "stdio",
"command": "npx",
"args": ["-y", "@modelcontextprotocol/server-filesystem", *paths],
}
},
"skills": {},
}
def _write_from_peer_process(path: Path, payload: dict) -> None:
"""Simulate a peer Gateway process: atomic write, no in-process reload."""
atomic_write_extensions_config(path, payload)
def _allowed_paths_of(config: ExtensionsConfig) -> list[str]:
return [arg for arg in config.mcp_servers["filesystem"].args if arg.startswith("/")]
def _warnings(caplog: pytest.LogCaptureFixture) -> list[logging.LogRecord]:
return [record for record in caplog.records if record.name == _LOGGER_NAME and record.levelno >= logging.WARNING]
@pytest.fixture
def config_path(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Iterator[Path]:
path = tmp_path / "extensions_config.json"
_write_from_peer_process(path, _filesystem_server_payload("/data/alpha"))
monkeypatch.setenv("DEER_FLOW_EXTENSIONS_CONFIG_PATH", str(path))
reset_extensions_config()
yield path
reset_extensions_config()
def test_get_extensions_config_reflects_peer_atomic_write(config_path: Path) -> None:
first = get_extensions_config()
assert _allowed_paths_of(first) == ["/data/alpha"]
_write_from_peer_process(config_path, _filesystem_server_payload("/data/beta"))
second = get_extensions_config()
assert _allowed_paths_of(second) == ["/data/beta"]
assert second is not first
def test_get_extensions_config_keeps_instance_while_file_is_unchanged(config_path: Path) -> None:
first = get_extensions_config()
assert get_extensions_config() is first
assert get_extensions_config() is first
def test_same_size_edit_with_unchanged_mtime_is_detected(config_path: Path) -> None:
get_extensions_config()
stat_before = config_path.stat()
# Same byte length, and the mtime pinned back, so neither stat field can
# reveal the edit: only the content digest does.
_write_from_peer_process(config_path, _filesystem_server_payload("/data/alphb"))
os.utime(config_path, ns=(stat_before.st_atime_ns, stat_before.st_mtime_ns))
assert config_path.stat().st_size == stat_before.st_size
assert config_path.stat().st_mtime_ns == stat_before.st_mtime_ns
assert _allowed_paths_of(get_extensions_config()) == ["/data/alphb"]
def test_local_bash_allowlist_follows_peer_write(config_path: Path) -> None:
assert _get_mcp_allowed_paths() == ["/data/alpha/"]
_write_from_peer_process(config_path, _filesystem_server_payload("/data/beta"))
assert _get_mcp_allowed_paths() == ["/data/beta/"]
_write_from_peer_process(config_path, _filesystem_server_payload("/data/beta", enabled=False))
assert _get_mcp_allowed_paths() == []
def test_half_written_file_keeps_last_known_good_and_warns_once(config_path: Path, caplog: pytest.LogCaptureFixture) -> None:
first = get_extensions_config()
# atomic_write_extensions_config falls back to an in-place overwrite on a
# bind-mounted file, so a peer can observe a truncated document.
config_path.write_text('{"mcpServers": {"filesystem": {"enabled": true, "type": "stdio", "command": "npx", "args": ["-y", "@modelcontextprotocol/serv', encoding="utf-8")
with caplog.at_level(logging.WARNING, logger=_LOGGER_NAME):
assert get_extensions_config() is first
assert get_extensions_config() is first
warnings = _warnings(caplog)
assert len(warnings) == 1
assert _KEEPING_PREVIOUS in warnings[0].getMessage()
assert str(config_path) in warnings[0].getMessage()
# Once the writer finishes, the new revision is adopted.
_write_from_peer_process(config_path, _filesystem_server_payload("/data/beta"))
assert _allowed_paths_of(get_extensions_config()) == ["/data/beta"]
def test_invalid_revision_warning_does_not_leak_resolved_values(config_path: Path, monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture) -> None:
get_extensions_config()
monkeypatch.setenv("EXT_FRESHNESS_SECRET", "hunter2-secret")
# Valid JSON, invalid schema: ``args`` must be a list. The resolved
# placeholder is the failing input, which pydantic embeds in its message.
config_path.write_text(
'{"mcpServers": {"filesystem": {"enabled": true, "type": "stdio", "command": "npx", "args": "$EXT_FRESHNESS_SECRET"}}, "skills": {}}',
encoding="utf-8",
)
with caplog.at_level(logging.WARNING, logger=_LOGGER_NAME):
assert _allowed_paths_of(get_extensions_config()) == ["/data/alpha"]
assert len(_warnings(caplog)) == 1
assert "hunter2-secret" not in caplog.text
def test_invalid_file_on_first_load_still_raises(config_path: Path) -> None:
config_path.write_text("{", encoding="utf-8")
with pytest.raises(ValueError):
get_extensions_config()
def test_missing_explicit_file_on_first_load_still_raises(config_path: Path) -> None:
config_path.unlink()
with pytest.raises(FileNotFoundError):
get_extensions_config()
def test_deleted_file_keeps_last_known_good_until_it_returns(config_path: Path, caplog: pytest.LogCaptureFixture) -> None:
first = get_extensions_config()
config_path.unlink()
with caplog.at_level(logging.WARNING, logger=_LOGGER_NAME):
assert get_extensions_config() is first
assert get_extensions_config() is first
warnings = _warnings(caplog)
assert len(warnings) == 1
assert _KEEPING_PREVIOUS in warnings[0].getMessage()
_write_from_peer_process(config_path, _filesystem_server_payload("/data/beta"))
assert _allowed_paths_of(get_extensions_config()) == ["/data/beta"]
@pytest.mark.parametrize("deletion_stage", ["before-signature", "after-signature"])
def test_search_mode_deletion_during_reload_keeps_last_known_good(config_path: Path, tmp_path: Path, monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture, deletion_stage: str) -> None:
monkeypatch.delenv("DEER_FLOW_EXTENSIONS_CONFIG_PATH")
monkeypatch.setenv("DEER_FLOW_PROJECT_ROOT", str(tmp_path))
# Keep search-mode fallback locations isolated from the checkout's config.
monkeypatch.setattr(extensions_config_module, "__file__", str(tmp_path / "isolated/backend/packages/harness/deerflow/config/extensions_config.py"))
assert ExtensionsConfig.resolve_config_path() == config_path
first = get_extensions_config()
_write_from_peer_process(config_path, _filesystem_server_payload("/data/beta"))
original_signature = extensions_config_module.get_config_signature
def signature_with_peer_deletion(path: Path):
assert path == config_path
if deletion_stage == "before-signature":
path.unlink()
signature = original_signature(path)
if deletion_stage == "after-signature":
path.unlink()
return signature
with monkeypatch.context() as race:
race.setattr(extensions_config_module, "get_config_signature", signature_with_peer_deletion)
with caplog.at_level(logging.WARNING, logger=_LOGGER_NAME):
assert get_extensions_config() is first
assert len(_warnings(caplog)) == 1
assert _KEEPING_PREVIOUS in _warnings(caplog)[0].getMessage()
assert ExtensionsConfig.resolve_config_path() is None
assert get_extensions_config() is first
assert _get_mcp_allowed_paths() == ["/data/alpha/"]
_write_from_peer_process(config_path, _filesystem_server_payload("/data/beta"))
assert _allowed_paths_of(get_extensions_config()) == ["/data/beta"]
def test_config_appearing_after_unconfigured_start_is_adopted(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.delenv("DEER_FLOW_EXTENSIONS_CONFIG_PATH", raising=False)
path = tmp_path / "extensions_config.json"
resolved: list[Path | None] = [None]
monkeypatch.setattr(ExtensionsConfig, "resolve_config_path", classmethod(lambda cls, config_path=None: resolved[0]))
reset_extensions_config()
try:
unconfigured = get_extensions_config()
assert unconfigured.mcp_servers == {}
assert get_extensions_config() is unconfigured
_write_from_peer_process(path, _filesystem_server_payload("/data/alpha"))
resolved[0] = path
loaded = get_extensions_config()
assert _allowed_paths_of(loaded) == ["/data/alpha"]
# Search mode losing the file keeps the last-known-good configuration.
resolved[0] = None
assert get_extensions_config() is loaded
finally:
reset_extensions_config()
def test_set_extensions_config_is_pinned_until_reload_or_reset(config_path: Path) -> None:
injected = ExtensionsConfig(mcp_servers={"injected": McpServerConfig(enabled=True, type="stdio", command="echo")}, skills={})
set_extensions_config(injected)
_write_from_peer_process(config_path, _filesystem_server_payload("/data/beta"))
assert get_extensions_config() is injected
reloaded = reload_extensions_config()
assert _allowed_paths_of(reloaded) == ["/data/beta"]
assert get_extensions_config() is reloaded
set_extensions_config(injected)
assert get_extensions_config() is injected
reset_extensions_config()
assert _allowed_paths_of(get_extensions_config()) == ["/data/beta"]
def test_reload_after_own_write_records_the_written_revision(config_path: Path) -> None:
get_extensions_config()
_write_from_peer_process(config_path, _filesystem_server_payload("/data/beta"))
reloaded = reload_extensions_config()
assert _allowed_paths_of(reloaded) == ["/data/beta"]
assert get_extensions_config() is reloaded
def test_reload_with_explicit_path_follows_that_file(config_path: Path, tmp_path: Path) -> None:
get_extensions_config()
explicit = tmp_path / "other-extensions.json"
_write_from_peer_process(explicit, _filesystem_server_payload("/data/explicit"))
reloaded = reload_extensions_config(str(explicit))
assert _allowed_paths_of(reloaded) == ["/data/explicit"]
assert get_extensions_config() is reloaded, "default resolution must not pull the cache back"
# Edits to the explicitly chosen file are followed; the default file is not.
_write_from_peer_process(explicit, _filesystem_server_payload("/data/explicit-2"))
assert _allowed_paths_of(get_extensions_config()) == ["/data/explicit-2"]
_write_from_peer_process(config_path, _filesystem_server_payload("/data/beta"))
assert _allowed_paths_of(get_extensions_config()) == ["/data/explicit-2"]
explicit.unlink()
assert _allowed_paths_of(get_extensions_config()) == ["/data/explicit-2"]
# An argument-less reload returns to default resolution.
assert _allowed_paths_of(reload_extensions_config()) == ["/data/beta"]
assert _allowed_paths_of(get_extensions_config()) == ["/data/beta"]
def test_reset_forgets_an_explicitly_reloaded_path(config_path: Path, tmp_path: Path) -> None:
explicit = tmp_path / "other-extensions.json"
_write_from_peer_process(explicit, _filesystem_server_payload("/data/explicit"))
reload_extensions_config(str(explicit))
reset_extensions_config()
assert _allowed_paths_of(get_extensions_config()) == ["/data/alpha"]

View File

@ -9,7 +9,7 @@ from types import SimpleNamespace
import pytest
from deerflow.config.acp_config import ACPAgentConfig
from deerflow.config.extensions_config import ExtensionsConfig, McpServerConfig, set_extensions_config
from deerflow.config.extensions_config import ExtensionsConfig, McpServerConfig, reset_extensions_config, set_extensions_config
from deerflow.tools.builtins.invoke_acp_agent_tool import (
_build_acp_mcp_servers,
_build_mcp_servers,
@ -44,7 +44,7 @@ def test_build_mcp_servers_filters_disabled_and_maps_transports():
}
finally:
monkeypatch.undo()
set_extensions_config(ExtensionsConfig(mcp_servers={}, skills={}))
reset_extensions_config()
def test_build_acp_mcp_servers_formats_list_payload():
@ -81,7 +81,7 @@ def test_build_acp_mcp_servers_formats_list_payload():
]
finally:
monkeypatch.undo()
set_extensions_config(ExtensionsConfig(mcp_servers={}, skills={}))
reset_extensions_config()
def test_build_permission_response_prefers_allow_once():

View File

@ -154,7 +154,7 @@ The Helm chart ships `gateway.replicas: 1`. Raising it is supported for the web
- `run_ownership.heartbeat_enabled: true`, so a starting Pod only reclaims runs whose owner is really gone
- `stream_bridge.type: redis`, so SSE streams and reconnects work from any Pod (sandbox ownership leases are inferred from it)
The chart exports `DEER_FLOW_MULTI_INSTANCE=true` when `gateway.replicas > 1`, and the Gateway refuses to start if any of these is missing. If you scale with `kubectl scale` or an HPA instead of `gateway.replicas`, set `gateway.multiInstance: true` first so the declaration, the PodDisruptionBudget and the required shared `AUTH_JWT_SECRET` do not keep their single-replica rendering (the PodDisruptionBudget then blocks node drains of the single Pod until the scale-up happens; disable it with `gateway.podDisruptionBudget.enabled: false` for that interim if needed). On multi-node clusters also set `persistence.home.accessMode: ReadWriteMany` (uploads, outputs, memory and `extensions_config.json` live on that volume) and `agent_storage.backend: db` so custom agents are visible on every Pod.
The chart exports `DEER_FLOW_MULTI_INSTANCE=true` when `gateway.replicas > 1`, and the Gateway refuses to start if any of these is missing. If you scale with `kubectl scale` or an HPA instead of `gateway.replicas`, set `gateway.multiInstance: true` first so the declaration, the PodDisruptionBudget and the required shared `AUTH_JWT_SECRET` do not keep their single-replica rendering (the PodDisruptionBudget then blocks node drains of the single Pod until the scale-up happens; disable it with `gateway.podDisruptionBudget.enabled: false` for that interim if needed). On multi-node clusters also set `persistence.home.accessMode: ReadWriteMany` (uploads, outputs, memory and `extensions_config.json` live on that volume; MCP and skill changes made through one Pod are then visible to the others on their next request) and `agent_storage.backend: db` so custom agents are visible on every Pod.
Still single-instance: IM channels (every Pod would connect to every platform), the WeChat QR login, and browser tools. Keep one replica while those are enabled.

View File

@ -151,7 +151,7 @@ Helm chart 默认 `gateway.replicas: 1`。当所有副本共享同一个 Postgre
- `run_ownership.heartbeat_enabled: true`,新启动的 Pod 只回收属主确实已死的 run
- `stream_bridge.type: redis`,SSE 流与断线重连在任意 Pod 上都可用(沙箱所有权租约由此推断)
当 `gateway.replicas > 1` 时,chart 会导出 `DEER_FLOW_MULTI_INSTANCE=true`,缺少上述任一项时 Gateway 会拒绝启动。如果你通过 `kubectl scale` 或 HPA 而不是 `gateway.replicas` 扩容,请先设置 `gateway.multiInstance: true`:它们在不执行 Helm upgrade 的情况下改变副本数,否则该声明、PodDisruptionBudget 和必需的共享 `AUTH_JWT_SECRET` 都会停留在单副本的渲染结果上(在真正扩容之前,PodDisruptionBudget 会阻止驱逐唯一的 Pod 和排空节点;如有需要,可在此期间设置 `gateway.podDisruptionBudget.enabled: false`)。多节点集群还需要把 `persistence.home.accessMode` 设为 `ReadWriteMany`(上传、产物、记忆与 `extensions_config.json` 都在该卷上),并设置 `agent_storage.backend: db` 让自定义 agent 在每个 Pod 上可见。
当 `gateway.replicas > 1` 时,chart 会导出 `DEER_FLOW_MULTI_INSTANCE=true`,缺少上述任一项时 Gateway 会拒绝启动。如果你通过 `kubectl scale` 或 HPA 而不是 `gateway.replicas` 扩容,请先设置 `gateway.multiInstance: true`:它们在不执行 Helm upgrade 的情况下改变副本数,否则该声明、PodDisruptionBudget 和必需的共享 `AUTH_JWT_SECRET` 都会停留在单副本的渲染结果上(在真正扩容之前,PodDisruptionBudget 会阻止驱逐唯一的 Pod 和排空节点;如有需要,可在此期间设置 `gateway.podDisruptionBudget.enabled: false`)。多节点集群还需要把 `persistence.home.accessMode` 设为 `ReadWriteMany`(上传、产物、记忆与 `extensions_config.json` 都在该卷上;在一个 Pod 上修改的 MCP 与技能配置会在其他 Pod 的下一次请求中生效),并设置 `agent_storage.backend: db` 让自定义 agent 在每个 Pod 上可见。
仍然只能单实例运行的部分:IM 通道(每个 Pod 都会连接所有平台)、WeChat 扫码登录、浏览器工具。启用这些功能时请保持 1 个副本。