deer-flow/backend/tests/test_agent_storage_backend.py
Huang-404-Q 5839822aa1
fix(agents): validate the agent name in FileAgentStore.get_soul (#7265)
get_soul was the only read/write method that skipped validate_agent_name,
so a name like "../../other-user/agents/planner" reached
resolve_agent_dir and read another user's SOUL.md. Validate it like the
other methods do.

The regression test asserts ValueError for a traversal name; it reads the
other user's soul on main.

Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
2026-10-23 14:54:21 +08:00

436 lines
19 KiB
Python

"""Agent-storage backend selection, startup validation, and the db importer."""
from __future__ import annotations
import sys
from types import SimpleNamespace
import pytest
import yaml
from fastapi import FastAPI
from fastapi.testclient import TestClient
from sqlalchemy import create_engine
from app.gateway.auth_middleware import AuthMiddleware
from app.gateway.deps import _validate_agent_storage
from app.gateway.routers import agents as agents_router
from deerflow.config.agent_storage_config import AgentStorageConfig
from deerflow.config.agents_api_config import load_agents_api_config_from_dict
from deerflow.config.app_config import AppConfig, reset_app_config, set_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
from deerflow.persistence.agents.sql import SqlAgentStore
from deerflow.persistence.base import Base
from deerflow.persistence.managed_subagents.base import ManagedSubagentDefinition
from deerflow.persistence.managed_subagents.file import FileManagedSubagentStore
from deerflow.persistence.managed_subagents.model import ManagedSubagentRow
from deerflow.persistence.managed_subagents.sql import SqlManagedSubagentStore
from deerflow.tools.builtins.update_agent_tool import update_agent
def _cfg(agent_backend: str, db_backend: str, sqlite_dir: str = "/tmp/agent-store-test") -> SimpleNamespace:
return SimpleNamespace(
agent_storage=AgentStorageConfig(backend=agent_backend),
database=DatabaseConfig(backend=db_backend, sqlite_dir=sqlite_dir),
)
# -- make_agent_store selection --------------------------------------------
def test_file_is_the_default_backend():
assert isinstance(make_agent_store(_cfg("file", "memory")), FileAgentStore)
def test_db_backend_builds_sql_store(tmp_path):
assert isinstance(make_agent_store(_cfg("db", "sqlite", str(tmp_path))), SqlAgentStore)
def test_db_backend_on_memory_database_is_rejected():
with pytest.raises(ValueError, match="requires database.backend"):
make_agent_store(_cfg("db", "memory"))
# -- startup validation (deps) ---------------------------------------------
def test_validation_rejects_db_on_memory_database():
with pytest.raises(SystemExit):
_validate_agent_storage(_cfg("db", "memory"))
def test_validation_allows_file_and_db_on_sql(tmp_path):
_validate_agent_storage(_cfg("file", "memory")) # no raise
_validate_agent_storage(_cfg("db", "sqlite", str(tmp_path))) # no raise
def test_validation_warns_on_file_under_multiworker_postgres(monkeypatch, caplog):
monkeypatch.setenv("GATEWAY_WORKERS", "4")
cfg = SimpleNamespace(
agent_storage=AgentStorageConfig(backend="file"),
database=DatabaseConfig(backend="postgres", postgres_url="postgresql://u:p@h/db"),
)
with caplog.at_level("WARNING"):
_validate_agent_storage(cfg)
assert any("not visible across workers" in r.message for r in caplog.records)
# -- importer: file layout → db --------------------------------------------
@pytest.fixture()
def file_home(tmp_path, monkeypatch):
"""Root file stores at a temp DEER_FLOW_HOME with seeded definitions."""
monkeypatch.setenv("DEER_FLOW_HOME", str(tmp_path))
from deerflow.config import paths as paths_module
monkeypatch.setattr(paths_module, "_paths", None)
fs = FileAgentStore()
fs.create("reviewer", {"name": "reviewer", "description": "reviews"}, "review soul", user_id="u1")
fs.create("planner", {"name": "planner", "description": "plans", "model": "m1"}, "plan soul", user_id="u2")
FileManagedSubagentStore().create(
ManagedSubagentDefinition(
name="researcher",
description="Researches topics",
system_prompt="Research carefully.",
enabled=False,
)
)
return tmp_path
def _patch_importer(monkeypatch, cfg):
import pathlib
import scripts.migrate_agents_to_db as importer
async def _noop_init(_):
return None
# Pre-create the schema (stand in for the alembic bootstrap the real run
# does, which also creates the sqlite directory).
pathlib.Path(cfg.database.sqlite_dir).mkdir(parents=True, exist_ok=True)
engine = create_engine(cfg.database.app_sync_sqlalchemy_url)
Base.metadata.create_all(engine, tables=[AgentRow.__table__, ManagedSubagentRow.__table__])
engine.dispose()
monkeypatch.setattr(importer, "get_app_config", lambda: cfg)
monkeypatch.setattr("deerflow.persistence.engine.init_engine_from_config", _noop_init)
return importer
def test_importer_copies_all_definitions_into_db(file_home, monkeypatch):
cfg = _cfg("db", "sqlite", str(file_home / "db"))
importer = _patch_importer(monkeypatch, cfg)
monkeypatch.setattr(sys, "argv", ["migrate_agents_to_db"])
assert importer.main() == 0
dest = SqlAgentStore(cfg.database.app_sync_sqlalchemy_url)
assert dest.get("reviewer", user_id="u1").description == "reviews"
assert dest.get_soul("reviewer", user_id="u1") == "review soul"
assert dest.get("planner", user_id="u2").model == "m1"
managed = SqlManagedSubagentStore(cfg.database.app_sync_sqlalchemy_url).get("researcher")
assert managed.description == "Researches topics"
assert managed.enabled is False
def test_importer_is_idempotent(file_home, monkeypatch):
cfg = _cfg("db", "sqlite", str(file_home / "db"))
importer = _patch_importer(monkeypatch, cfg)
monkeypatch.setattr(sys, "argv", ["migrate_agents_to_db"])
assert importer.main() == 0
# Second run must not raise on the already-present rows.
assert importer.main() == 0
dest = SqlAgentStore(cfg.database.app_sync_sqlalchemy_url)
assert len(dest.list_all()) == 2
assert [item.name for item in SqlManagedSubagentStore(cfg.database.app_sync_sqlalchemy_url).list()] == ["researcher"]
def test_importer_dry_run_writes_nothing(file_home, monkeypatch):
cfg = _cfg("db", "sqlite", str(file_home / "db"))
importer = _patch_importer(monkeypatch, cfg)
monkeypatch.setattr(sys, "argv", ["migrate_agents_to_db", "--dry-run"])
assert importer.main() == 0
dest = SqlAgentStore(cfg.database.app_sync_sqlalchemy_url)
assert dest.list_all() == []
assert SqlManagedSubagentStore(cfg.database.app_sync_sqlalchemy_url).list() == []
@pytest.mark.parametrize("backend", ["file", "db"])
@pytest.mark.parametrize("memory_only", [False, True], ids=["no-user-dir", "memory-only-user-dir"])
@pytest.mark.parametrize("surface", ["http", "tool"])
def test_legacy_source_only_blocks_file_backed_updates(tmp_path, monkeypatch, backend, memory_only, surface):
"""Retained migration sources must not override the active SQL store (#6743)."""
monkeypatch.setenv("DEER_FLOW_HOME", str(tmp_path))
monkeypatch.setattr("deerflow.config.paths._paths", None)
monkeypatch.setenv("DEER_FLOW_AUTH_DISABLED", "1")
monkeypatch.setenv("DEER_FLOW_ENV", "test")
monkeypatch.setenv("ENVIRONMENT", "test")
legacy = tmp_path / "agents" / "reviewer"
legacy.mkdir(parents=True)
original_config = "name: reviewer\ndescription: original\n"
(legacy / "config.yaml").write_text(original_config, encoding="utf-8")
(legacy / "SOUL.md").write_text("Review carefully.", encoding="utf-8")
user_dir = tmp_path / "users" / "default" / "agents" / "reviewer"
if memory_only:
user_dir.mkdir(parents=True)
(user_dir / "memory.json").write_text("{}", encoding="utf-8")
cfg = AppConfig(
models=[],
sandbox={"use": "deerflow.sandbox.local:LocalSandboxProvider"},
database={"backend": "sqlite", "sqlite_dir": str(tmp_path / "db")},
agent_storage={"backend": backend},
)
set_app_config(cfg)
load_agents_api_config_from_dict({"enabled": True})
try:
if backend == "db":
# Real file discovery, import and SQL writes; only the unrelated
# schema bootstrap is replaced by the fixture's real SQLite DDL.
importer = _patch_importer(monkeypatch, cfg)
monkeypatch.setattr(sys, "argv", ["migrate_agents_to_db"])
assert importer.main() == 0
store = get_agent_store()
assert store.get("reviewer", user_id="default").description == "original"
if backend == "db":
store.create("reviewer", {"name": "reviewer", "description": "other owner"}, "Other soul", user_id="other-user")
if surface == "http":
app = FastAPI()
app.add_middleware(AuthMiddleware)
app.include_router(agents_router.router)
with TestClient(app) as client:
assert client.get("/api/agents/reviewer").status_code == 200
response = client.put("/api/agents/reviewer", json={"description": "updated"})
assert response.status_code == (200 if backend == "db" else 409), response.text
if backend == "file":
assert "legacy shared layout" in response.json()["detail"]
else:
runtime = SimpleNamespace(context={"agent_name": "reviewer", "user_id": "default"}, tool_call_id="update-1")
message = update_agent.func(runtime=runtime, description="updated").update["messages"][0]
assert message.status == ("success" if backend == "db" else "error"), message.content
if backend == "file":
assert "legacy shared layout" in message.content
assert store.get("reviewer", user_id="default").description == ("updated" if backend == "db" else "original")
assert store.get_soul("reviewer", user_id="default") == "Review carefully."
if backend == "db":
assert store.get("reviewer", user_id="other-user").description == "other owner"
assert store.get_soul("reviewer", user_id="other-user") == "Other soul"
assert (legacy / "config.yaml").read_text(encoding="utf-8") == original_config
assert (legacy / "SOUL.md").read_text(encoding="utf-8") == "Review carefully."
assert not (user_dir / "config.yaml").exists()
if memory_only:
assert (user_dir / "memory.json").read_text(encoding="utf-8") == "{}"
finally:
load_agents_api_config_from_dict({})
reset_app_config()
def test_importer_runs_when_only_managed_subagents_exist(tmp_path, monkeypatch):
monkeypatch.setenv("DEER_FLOW_HOME", str(tmp_path))
from deerflow.config import paths as paths_module
monkeypatch.setattr(paths_module, "_paths", None)
FileManagedSubagentStore().create(
ManagedSubagentDefinition(
name="writer",
description="Writes reports",
system_prompt="Write clearly.",
)
)
cfg = _cfg("db", "sqlite", str(tmp_path / "db"))
importer = _patch_importer(monkeypatch, cfg)
monkeypatch.setattr(sys, "argv", ["migrate_agents_to_db"])
assert importer.main() == 0
assert SqlAgentStore(cfg.database.app_sync_sqlalchemy_url).list_all() == []
assert SqlManagedSubagentStore(cfg.database.app_sync_sqlalchemy_url).get("writer").description == "Writes reports"
def test_read_free_functions_dispatch_to_db_backend(file_home, monkeypatch):
"""The headline invariant: under the db backend the standard read path (the
same free functions the per-run agent build calls) resolves from the shared
DB, not from node-local files — so on-disk agents are invisible and db agents
are visible everywhere."""
cfg = _cfg("db", "sqlite", str(file_home / "db"))
_patch_importer(monkeypatch, cfg) # creates the schema
monkeypatch.setattr("deerflow.config.app_config.get_app_config", lambda: cfg)
from deerflow.config.agents_config import list_custom_agents, load_agent_config, load_agent_soul
# The file store seeded 'reviewer'/'planner' on disk; the db is empty, so
# the free functions (now db-backed) do not see them.
assert list_custom_agents(user_id="u1") == []
with pytest.raises(FileNotFoundError):
load_agent_config("reviewer", user_id="u1")
# An agent written to the shared db is visible through the same free functions.
SqlAgentStore(cfg.database.app_sync_sqlalchemy_url).create("dbonly", {"name": "dbonly", "description": "shared"}, "db soul", user_id="u1")
assert [c.name for c in list_custom_agents(user_id="u1")] == ["dbonly"]
assert load_agent_config("dbonly", user_id="u1").description == "shared"
assert load_agent_soul("dbonly", user_id="u1") == "db soul"
def test_file_create_race_maps_file_exists_to_agent_exists(tmp_path, monkeypatch):
# TOCTOU: the existence guard passes (no agent yet), but a concurrent create
# wins the race so mkdir(exist_ok=False) raises FileExistsError. The store
# must translate that to AgentExistsError so the router returns 409, not a
# generic 500 — matching SqlAgentStore's IntegrityError path.
import pathlib
from deerflow.persistence.agents.base import AgentExistsError
monkeypatch.setenv("DEER_FLOW_HOME", str(tmp_path))
from deerflow.config import paths as paths_module
monkeypatch.setattr(paths_module, "_paths", None)
def _racing_mkdir(self, *args, **kwargs):
raise FileExistsError(str(self))
monkeypatch.setattr(pathlib.Path, "mkdir", _racing_mkdir)
fs = FileAgentStore()
with pytest.raises(AgentExistsError):
fs.create("racy", {"name": "racy"}, "soul", user_id="u1")
# -- graph-subprocess config resolution (db backend's core cross-process invariant) --
def _write_min_config(path, extra: dict) -> None:
"""Minimal but valid config.yaml (sandbox + models are the only hard requirements)."""
doc = {
"sandbox": {"use": "deerflow.sandbox.local:LocalSandboxProvider"},
"models": [{"name": "m", "use": "langchain_openai:ChatOpenAI", "model": "gpt-test"}],
**extra,
}
path.write_text(yaml.safe_dump(doc), encoding="utf-8")
def test_get_agent_store_resolves_db_backend_from_on_disk_config(tmp_path, monkeypatch):
"""Pins the db backend's headline cross-process guarantee.
The per-run agent build runs in the graph subprocess, a different process
from the gateway; its db visibility holds only because ``get_agent_store()``
resolves ``agent_storage.backend: db`` from the real on-disk ``config.yaml``
there (not a monkeypatched stub) rather than silently falling back to
node-local ``file``. Existing coverage monkeypatches ``get_app_config``;
this drives the genuine file-resolution path a fresh process would take.
"""
cfg_path = tmp_path / "config.yaml"
_write_min_config(cfg_path, {"agent_storage": {"backend": "db"}, "database": {"backend": "sqlite", "sqlite_dir": str(tmp_path / "db")}})
monkeypatch.setenv("DEER_FLOW_CONFIG_PATH", str(cfg_path))
try:
reset_app_config() # force a fresh read from the on-disk file
assert isinstance(get_agent_store(), SqlAgentStore)
finally:
reset_app_config() # don't leak the custom config into other tests
def test_get_agent_store_falls_back_to_file_without_config(tmp_path, monkeypatch):
"""The ``except -> file`` fallback is for genuinely unresolvable config only
(CLI/tests); it must not fire when a config exists — that asymmetry is what
keeps a misconfigured graph process from silently downgrading db to file."""
monkeypatch.delenv("DEER_FLOW_CONFIG_PATH", raising=False)
monkeypatch.setenv("DEER_FLOW_PROJECT_ROOT", str(tmp_path))
from deerflow.config import app_config
monkeypatch.setattr(app_config, "_legacy_config_candidates", lambda: ())
try:
reset_app_config()
assert isinstance(get_agent_store(), FileAgentStore)
finally:
reset_app_config()
def test_get_agent_store_does_not_fallback_when_explicit_config_is_missing(tmp_path, monkeypatch):
"""An explicit config path is an operator assertion and must fail closed."""
monkeypatch.setenv("DEER_FLOW_CONFIG_PATH", str(tmp_path / "does-not-exist.yaml"))
try:
reset_app_config()
with pytest.raises(FileNotFoundError, match="DEER_FLOW_CONFIG_PATH"):
get_agent_store()
finally:
reset_app_config()
def test_get_agent_store_does_not_hide_invalid_config(monkeypatch):
"""Only missing config falls back; config errors must reach the caller."""
from deerflow.config import app_config
def raise_invalid_config():
raise ValueError("invalid config")
monkeypatch.setattr(app_config, "get_app_config", raise_invalid_config)
with pytest.raises(ValueError, match="invalid config"):
get_agent_store()
def test_get_agent_store_propagates_invalid_on_disk_config(tmp_path, monkeypatch):
"""A present config with an invalid backend must fail instead of falling back."""
cfg_path = tmp_path / "config.yaml"
_write_min_config(cfg_path, {"agent_storage": {"backend": "invalid"}})
monkeypatch.setenv("DEER_FLOW_CONFIG_PATH", str(cfg_path))
try:
reset_app_config()
with pytest.raises(ValueError, match="agent_storage.backend"):
get_agent_store()
finally:
reset_app_config()
def test_get_agent_store_propagates_malformed_yaml(tmp_path, monkeypatch):
"""An unparseable config.yaml must surface the parse error, not fall back to file."""
cfg_path = tmp_path / "config.yaml"
cfg_path.write_text("agent_storage: [oops\n", encoding="utf-8")
monkeypatch.setenv("DEER_FLOW_CONFIG_PATH", str(cfg_path))
try:
reset_app_config()
with pytest.raises(yaml.YAMLError): # ParserError/ScannerError, previously swallowed
get_agent_store()
finally:
reset_app_config()
def test_get_agent_store_does_not_fallback_when_extensions_config_is_missing(tmp_path, monkeypatch):
"""A missing nested config must not look like a missing main config."""
cfg_path = tmp_path / "config.yaml"
_write_min_config(
cfg_path,
{
"agent_storage": {"backend": "db"},
"database": {"backend": "sqlite", "sqlite_dir": str(tmp_path / "db")},
},
)
monkeypatch.setenv("DEER_FLOW_CONFIG_PATH", str(cfg_path))
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()
def test_file_get_soul_rejects_path_traversal(file_home):
store = FileAgentStore()
with pytest.raises(ValueError, match="Invalid agent name"):
store.get_soul("../../u2/agents/planner", user_id="u1")