mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-10-26 21:26:39 +00:00
* fix(security): add DEER_FLOW_CREDENTIALS_KEY for at-rest credential encryption
There was no at-rest encryption key. ChannelCredentialCipher had no
production caller: the channel service, the scheduled-task notification
outbox and the channel-connections router all built
ChannelConnectionRepository without a cipher, so store_credentials refused
(RuntimeError) and get_credentials always returned None.
- New harness module deerflow.config.credentials_key: env-only
DEER_FLOW_CREDENTIALS_KEY (comma-separated Fernet keys; first encrypts,
all decrypt via MultiFernet), CredentialsCipher with encrypt_text /
decrypt_text / rotate_text, a fernet:v2: prefix with legacy fernet:v1:
values still readable, and get_credentials_cipher() as the process-wide
entry point. Malformed keys are refused without echoing key material.
Without the variable a single instance generates
{base_dir}/.credentials_key. Never derived from AUTH_JWT_SECRET.
- New deerflow.config.secret_file.read_or_create_secret_file: O_EXCL
create (0600) plus read-back with a bounded settle window, shared by
.credentials_key, .jwt_secret (fixes two replicas cold-starting on a
shared volume keeping different JWT secrets) and the managed-model key.
- ChannelCredentialCipher now builds on CredentialsCipher (same
constructors); the Gateway loads the cipher off the event loop at
startup when channel_connections is enabled and passes it to every
ChannelConnectionRepository. Undecryptable values stay "missing + warn".
- Startup gate: a malformed key stops startup in any topology; a declared
multi-instance deployment with channel_connections enabled and no key
is refused. Plain multi-worker shares the generated file and is allowed.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* feat(deploy): generate and inject DEER_FLOW_CREDENTIALS_KEY in Helm, compose and deploy.sh
- Helm: the app Secret generates a Fernet key (randBytes 32 translated to
the urlsafe alphabet) and preserves it across upgrades via lookup like
AUTH_JWT_SECRET; the gateway reads it through an optional secretKeyRef
so a user-managed existingAppSecret without it still boots (the Gateway
gate refuses only multi-instance deployments that store credentials).
README, NOTES and values document backup, rotation and existingAppSecret.
- scripts/deploy.sh resolves the key shell -> .env -> persisted file ->
generated, persisting it as $DEER_FLOW_HOME/.credentials_key (0600,
noclobber/O_EXCL with read-back): the same file the Gateway generates in
its runtime home, so either side's key is the one both keep.
- Both compose files pass DEER_FLOW_CREDENTIALS_KEY=${...:-} to the gateway.
- .env.example entry with a generation hint; README and CHANGELOG notes.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* fix(security): make abandoned secret-file replacement single-winner
A rename is last-writer-wins, so replicas that restart together and all
find the same abandoned empty secret file could each replace it and keep
a different value. The process that exclusively creates
<name>.replacing now owns the replacement, re-checks the file is still
empty, and publishes by renaming the claim over it; peers wait for that
value, bounded so a crashed replacer cannot block startup forever.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* docs(changelog): link DEER_FLOW_CREDENTIALS_KEY entries to #6611
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* fix(security): name the setting that turns off a credentials-key consumer
The multi-instance refusal ended with "or disable
channel_connections.enabled=true"; it now says "or set
channel_connections.enabled=false".
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* fix(security): never expose a secret file before its value is written
O_EXCL published the name before its content, so a creator that stalled
mid-write left an empty file a peer replaced after the settle window; the
resumed creator then returned a value nobody persisted, or deleted the
peer's valid file when its own write failed. Creators now write and sync
a private temporary file and hard-link it to the name, which fails if
the name exists; a failed creator only removes its own temporary file.
Filesystems without hard links publish through the same single-winner
<name>.replacing claim that replaces abandoned empty files, so creation
and replacement are serialized there.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* fix(models): recover an empty managed-model key when no catalog exists
An interrupted first save could leave an empty key file; every later save
then passed empty bytes to Fernet and the endpoint returned 503 until the
file was removed by hand. An empty key with no catalog encrypted nothing,
so it now goes through the secret-file recovery path; next to an existing
catalog it is still refused.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* fix(docker): pass the dotenv DEER_FLOW_CREDENTIALS_KEY through make docker-start
scripts/docker.sh runs Compose from docker/ without --env-file, so the dev
gateway's DEER_FLOW_CREDENTIALS_KEY=${DEER_FLOW_CREDENTIALS_KEY:-} entry
interpolated to empty and overrode the key the checkout .env supplied via
env_file. Export it from the dotenv like AUTH_TRUSTED_PROXIES.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
342 lines
14 KiB
Python
342 lines
14 KiB
Python
"""Managed models: persistence, snapshot resolution and administrator boundaries."""
|
|
|
|
import asyncio
|
|
import logging
|
|
import threading
|
|
from types import SimpleNamespace
|
|
|
|
import pytest
|
|
from fastapi import HTTPException
|
|
from pydantic import ValidationError
|
|
|
|
from deerflow.config.app_config import AppConfig
|
|
from deerflow.config.managed_models import ManagedModel, ManagedModelStore, merge_managed_models
|
|
|
|
|
|
def profile(**kwargs):
|
|
return ManagedModel(name="managed-test", model="test-model", base_url="https://example.com/v1", api_key="test-secret", **kwargs)
|
|
|
|
|
|
@pytest.fixture
|
|
def store(tmp_path, monkeypatch):
|
|
monkeypatch.setenv("DEER_FLOW_HOME", str(tmp_path))
|
|
return ManagedModelStore()
|
|
|
|
|
|
def test_persist_encrypt_preserve_secret_and_revision(store):
|
|
with pytest.raises(FileNotFoundError):
|
|
store.save(profile(), expected_revision="missing")
|
|
first = store.save(profile(), expected_revision=None)
|
|
assert b"test-secret" not in store.path.read_bytes()
|
|
assert ManagedModelStore().list()[0].api_key.get_secret_value() == "test-secret"
|
|
updated = store.save(profile(enabled=False).model_copy(update={"api_key": None}), expected_revision=first.revision)
|
|
assert updated.api_key.get_secret_value() == "test-secret"
|
|
assert updated.revision != first.revision
|
|
with pytest.raises(FileExistsError):
|
|
store.save(profile(), expected_revision=first.revision)
|
|
with pytest.raises(FileExistsError):
|
|
store.save(profile(), expected_revision=None)
|
|
|
|
|
|
def test_snapshot_merge_yaml_precedence_and_disable(store):
|
|
base = AppConfig.model_validate({"sandbox": {"use": "test"}, "models": [{"name": "yaml", "model": "yaml", "use": "test"}]})
|
|
first = store.save(profile(), expected_revision=None)
|
|
merged = merge_managed_models(base)
|
|
assert [m.name for m in merged.models] == ["yaml", "managed-test"]
|
|
assert merged.get_model_config("managed-test").api_key == "test-secret"
|
|
assert base.get_model_config("managed-test") is None
|
|
store.save(profile(enabled=False), expected_revision=first.revision)
|
|
assert merge_managed_models(base).get_model_config("managed-test") is None
|
|
assert merged.get_model_config("managed-test") is not None
|
|
store.save(profile().model_copy(update={"name": "yaml"}), expected_revision=None)
|
|
assert merge_managed_models(base).get_model_config("yaml").use == "test"
|
|
|
|
|
|
def test_missing_encryption_key_never_replaced(store):
|
|
store.save(profile(), expected_revision=None)
|
|
store.key_path.unlink()
|
|
with pytest.raises(ValueError, match="key"):
|
|
store.list()
|
|
assert not store.key_path.exists()
|
|
|
|
|
|
def test_first_save_keeps_a_key_a_peer_created_concurrently(store, monkeypatch):
|
|
"""A peer that publishes the key file first must win; replacing it would orphan the peer's catalog."""
|
|
from cryptography.fernet import Fernet
|
|
|
|
peer_key = Fernet.generate_key()
|
|
original_generate = Fernet.generate_key
|
|
|
|
def peer_wins_the_race():
|
|
store.key_path.parent.mkdir(parents=True, exist_ok=True)
|
|
store.key_path.write_bytes(peer_key)
|
|
return original_generate()
|
|
|
|
monkeypatch.setattr(Fernet, "generate_key", staticmethod(peer_wins_the_race))
|
|
store.save(profile(), expected_revision=None)
|
|
|
|
assert store.key_path.read_bytes() == peer_key
|
|
assert Fernet(peer_key).decrypt(store.path.read_bytes())
|
|
|
|
|
|
def test_first_save_recovers_an_empty_key_left_by_an_interrupted_creation(store):
|
|
"""An empty key file with no catalog encrypted no data, so the next save must replace it, not 503 forever."""
|
|
from cryptography.fernet import Fernet
|
|
|
|
store.key_path.parent.mkdir(parents=True, exist_ok=True)
|
|
store.key_path.write_bytes(b"")
|
|
|
|
store.save(profile(), expected_revision=None)
|
|
|
|
key = store.key_path.read_bytes()
|
|
assert key.strip()
|
|
assert Fernet(key).decrypt(store.path.read_bytes())
|
|
assert ManagedModelStore().list()[0].api_key.get_secret_value() == "test-secret"
|
|
|
|
|
|
def test_an_empty_key_next_to_an_existing_catalog_is_never_replaced(store):
|
|
"""The catalog was encrypted with the lost key; a new key would silently orphan it."""
|
|
store.save(profile(), expected_revision=None)
|
|
catalog = store.path.read_bytes()
|
|
store.key_path.write_bytes(b"")
|
|
|
|
with pytest.raises(ValueError):
|
|
store.save(profile(), expected_revision=None)
|
|
with pytest.raises(ValueError, match="restore it from backup"):
|
|
store._cipher(create=True)
|
|
|
|
assert store.key_path.read_bytes() == b""
|
|
assert store.path.read_bytes() == catalog
|
|
|
|
|
|
@pytest.mark.parametrize("url", ["file:///tmp/test", "https://user:pass@example.com/v1", "https://example.com/v1?key=abc", "https://example.com/#fragment"])
|
|
def test_endpoint_validation(url):
|
|
with pytest.raises(ValidationError):
|
|
ManagedModel(name="test", model="test", base_url=url)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_save_model_drains_started_persistence_across_cancellation(monkeypatch):
|
|
from app.gateway.routers import managed_models as router
|
|
|
|
started = threading.Event()
|
|
release = threading.Event()
|
|
|
|
def save(_body):
|
|
started.set()
|
|
assert release.wait(timeout=2)
|
|
return {"name": "managed-test", "has_api_key": True}
|
|
|
|
monkeypatch.setattr(router, "_save", save)
|
|
request = SimpleNamespace(state=SimpleNamespace(user=SimpleNamespace(system_role="admin")))
|
|
body = router.SaveModelRequest(config=profile())
|
|
|
|
task = asyncio.create_task(router.save_model(request, body))
|
|
try:
|
|
assert await asyncio.to_thread(started.wait, 2)
|
|
task.cancel()
|
|
await asyncio.sleep(0.05)
|
|
task.cancel()
|
|
await asyncio.sleep(0.05)
|
|
assert not task.done()
|
|
|
|
release.set()
|
|
with pytest.raises(asyncio.CancelledError):
|
|
await task
|
|
finally:
|
|
release.set()
|
|
if not task.done():
|
|
task.cancel()
|
|
await asyncio.gather(task, return_exceptions=True)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.parametrize("failure", [OSError, RuntimeError])
|
|
async def test_save_model_logs_failed_drained_persistence_after_cancellation(store, monkeypatch, caplog, failure):
|
|
from app.gateway.routers import managed_models as router
|
|
|
|
started = threading.Event()
|
|
release = threading.Event()
|
|
|
|
def fail_save(self, *_args, **_kwargs):
|
|
started.set()
|
|
assert release.wait(timeout=5)
|
|
raise failure("test-secret must not appear in logs")
|
|
|
|
monkeypatch.setattr(router, "get_app_config", lambda: AppConfig.model_validate({"sandbox": {"use": "test"}}))
|
|
monkeypatch.setattr(ManagedModelStore, "save", fail_save)
|
|
request = SimpleNamespace(state=SimpleNamespace(user=SimpleNamespace(system_role="admin")))
|
|
body = router.SaveModelRequest(config=profile())
|
|
|
|
with caplog.at_level(logging.ERROR, logger=router.__name__):
|
|
task = asyncio.create_task(router.save_model(request, body))
|
|
try:
|
|
assert await asyncio.to_thread(started.wait, 5)
|
|
task.cancel()
|
|
await asyncio.sleep(0)
|
|
assert not task.done()
|
|
|
|
release.set()
|
|
with pytest.raises(asyncio.CancelledError):
|
|
await task
|
|
finally:
|
|
release.set()
|
|
if not task.done():
|
|
task.cancel()
|
|
await asyncio.gather(task, return_exceptions=True)
|
|
|
|
save_errors = [record for record in caplog.records if record.name == router.__name__ and "Managed model save failed" in record.message]
|
|
assert len(save_errors) == 1
|
|
expected_detail = "503" if failure is OSError else "RuntimeError"
|
|
assert expected_detail in save_errors[0].message
|
|
assert "test-secret" not in caplog.text
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_admin_gate_and_response_redaction(store, monkeypatch):
|
|
from app.gateway.routers import managed_models as router
|
|
|
|
config = AppConfig.model_validate({"sandbox": {"use": "test"}})
|
|
monkeypatch.setattr(router, "get_app_config", lambda: config)
|
|
admin = SimpleNamespace(state=SimpleNamespace(user=SimpleNamespace(system_role="admin")))
|
|
member = SimpleNamespace(state=SimpleNamespace(user=SimpleNamespace(system_role="user")))
|
|
body = router.SaveModelRequest(config=profile())
|
|
with pytest.raises(HTTPException) as exc:
|
|
await router.save_model(member, body)
|
|
assert exc.value.status_code == 403
|
|
result = await router.save_model(admin, body)
|
|
assert result["has_api_key"] is True
|
|
assert "api_key" not in result
|
|
assert "test-secret" not in str(await router.list_managed_models(admin))
|
|
with pytest.raises(HTTPException) as exc:
|
|
await router.list_managed_models(member)
|
|
assert exc.value.status_code == 403
|
|
|
|
|
|
def test_config_loader_sees_changes_without_yaml_write(store, tmp_path, monkeypatch):
|
|
from deerflow.config.app_config import get_app_config, reset_app_config
|
|
|
|
path = tmp_path / "config.yaml"
|
|
original = "sandbox:\n use: test\nmodels: []\n"
|
|
path.write_text(original, encoding="utf-8")
|
|
monkeypatch.setenv("DEER_FLOW_CONFIG_PATH", str(path))
|
|
monkeypatch.delenv("DEER_FLOW_EXTENSIONS_CONFIG_PATH", raising=False)
|
|
reset_app_config()
|
|
try:
|
|
assert not get_app_config().models
|
|
saved = store.save(profile(), expected_revision=None)
|
|
snapshot = get_app_config()
|
|
assert snapshot.get_model_config("managed-test").model == "test-model"
|
|
assert get_app_config() is snapshot
|
|
store.save(profile(enabled=False), expected_revision=saved.revision)
|
|
assert not get_app_config().models
|
|
assert snapshot.get_model_config("managed-test") is not None
|
|
assert path.read_text(encoding="utf-8") == original
|
|
finally:
|
|
reset_app_config()
|
|
|
|
|
|
def test_parallel_writes_preserve_all_models(store):
|
|
from concurrent.futures import ThreadPoolExecutor
|
|
|
|
def save(index):
|
|
store.save(profile().model_copy(update={"name": f"model-{index}"}), expected_revision=None)
|
|
|
|
with ThreadPoolExecutor(max_workers=8) as executor:
|
|
list(executor.map(save, range(16)))
|
|
assert len(store.list()) == 16
|
|
|
|
|
|
def test_clear_key_and_corrupt_catalog_fail_closed(store):
|
|
from pydantic import SecretStr
|
|
|
|
previous = store.save(profile(), expected_revision=None)
|
|
saved = store.save(profile().model_copy(update={"api_key": SecretStr("")}), expected_revision=previous.revision)
|
|
assert saved.public()["has_api_key"] is False
|
|
assert store.list()[0].runtime_config().api_key == "not-required"
|
|
store.path.write_bytes(b"broken")
|
|
with pytest.raises(ValueError):
|
|
store.save(profile(), expected_revision=None)
|
|
assert store.path.read_bytes() == b"broken"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_yaml_name_reserved_and_pat_denied(store, monkeypatch):
|
|
from app.gateway.auth_disabled import AUTH_SOURCE_PAT
|
|
from app.gateway.routers import managed_models as router
|
|
|
|
config = AppConfig.model_validate({"sandbox": {"use": "test"}, "models": [{"name": "managed-test", "model": "yaml", "use": "test"}]})
|
|
monkeypatch.setattr(router, "get_app_config", lambda: config)
|
|
request = SimpleNamespace(state=SimpleNamespace(user=SimpleNamespace(system_role="admin")))
|
|
body = router.SaveModelRequest(config=profile())
|
|
with pytest.raises(HTTPException) as exc:
|
|
await router.save_model(request, body)
|
|
assert exc.value.status_code == 409
|
|
request.state.auth_source = AUTH_SOURCE_PAT
|
|
for operation in (router.save_model, router.test_model):
|
|
with pytest.raises(HTTPException) as exc:
|
|
await operation(request, body)
|
|
assert exc.value.status_code == 403
|
|
assert not store.path.exists()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.parametrize("mode,expected", [("tool", "success"), ("text", "tool_call_missing"), ("error", "connection_failed")])
|
|
async def test_connection_probe_is_bounded_redacted_and_does_not_save(store, monkeypatch, mode, expected):
|
|
import langchain_openai
|
|
from langchain_core.messages import AIMessageChunk
|
|
|
|
from app.gateway.routers import managed_models as router
|
|
|
|
captured = {}
|
|
|
|
class FakeModel:
|
|
def __init__(self, **kwargs):
|
|
captured.update(kwargs)
|
|
|
|
def bind_tools(self, tools, **kwargs):
|
|
assert kwargs["tool_choice"] == "connection_check"
|
|
return self
|
|
|
|
async def astream(self, *args, **kwargs):
|
|
if mode == "error":
|
|
raise RuntimeError("test-secret should never be returned")
|
|
yield AIMessageChunk(content="", tool_call_chunks=[{"name": "connection_check", "args": "{}", "id": "call-1", "index": 0}] if mode == "tool" else [])
|
|
|
|
monkeypatch.setattr(langchain_openai, "ChatOpenAI", FakeModel)
|
|
request = SimpleNamespace(state=SimpleNamespace(user=SimpleNamespace(system_role="admin")))
|
|
result = await router.test_model(request, router.SaveModelRequest(config=profile()))
|
|
assert result["message"] == expected
|
|
assert "test-secret" not in str(result)
|
|
assert captured["max_retries"] == 0
|
|
assert captured["timeout"] == 15
|
|
assert not store.path.exists()
|
|
|
|
|
|
def test_separate_process_writers_share_catalog_lock(store):
|
|
import os
|
|
import subprocess
|
|
import sys
|
|
|
|
script = """
|
|
import sys
|
|
from deerflow.config.managed_models import ManagedModelStore, ManagedModel
|
|
store = ManagedModelStore()
|
|
for index in range(5):
|
|
store.save(ManagedModel(name=f'{sys.argv[1]}-{index}', model='test', base_url='https://example.com/v1', api_key='secret'), expected_revision=None)
|
|
"""
|
|
processes = [subprocess.Popen([sys.executable, "-c", script, str(index)], env=os.environ.copy(), stdout=subprocess.PIPE, stderr=subprocess.PIPE) for index in range(3)]
|
|
try:
|
|
for process in processes:
|
|
output, errors = process.communicate(timeout=30)
|
|
assert process.returncode == 0, (output, errors)
|
|
finally:
|
|
for process in processes:
|
|
if process.poll() is None:
|
|
process.kill()
|
|
process.wait()
|
|
assert len(store.list()) == 15
|
|
if os.name == "posix":
|
|
assert store.path.stat().st_mode & 0o777 == 0o600
|
|
assert store.key_path.stat().st_mode & 0o777 == 0o600
|