mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-11-08 12:00:36 +00:00
* fix(skills): fail closed when the extensions config cannot be read * test(skills): cover a disabled skill when the extensions config cannot be read * style(tests): format the fail-closed regression for ruff * style(tests): fit the docstring and signature inside the 240-column limit * fix(skills): fail closed in the user-scoped listing when the config cannot be read * test(skills): cover the user-scoped listing with an unreadable extensions config * fix(gateway): keep skill read surfaces loud about an unreadable extensions config Folding the user-scoped degraded mode in (#7704) also stopped the skill read endpoints from failing on a config that cannot be read: the storage now answers with an all-disabled listing, which regressed the #7155 contract (a generic 500 with the host path only in the server log) into a 200 that presents a broken deployment as a legitimately empty one. Keep both properties: the storage still fails closed for the runtime gate, and the read surfaces re-validate the config explicitly so the raised error reaches each handler's generic 500 branch unchanged. First-run behavior is untouched: no config file resolves to an empty config without raising, so default-enabled still applies. * fix(skills): redact config-load failures and keep the read preflight off the event loop Review follow-ups: - Log the failure shape instead of the exception text in both degraded modes: from_file resolves $VAR placeholders before validation, so its message can embed resolved credentials. Reuse _describe_load_failure, the loader's own redaction helper. - Run the gateway read preflight through asyncio.to_thread in the capability adapter, matching the scan it precedes. - Cover the user-scoped degraded branch with a CUSTOM skill that has per-user state on disk, so the second read's guard is directly regression-tested (verified red with the guard removed). --------- Co-authored-by: Jay0130-a <309071492+Jay0130-a@users.noreply.github.com>
69 lines
2.9 KiB
Python
69 lines
2.9 KiB
Python
"""A broken extensions config must not re-enable a disabled skill.
|
|
|
|
``SkillStorage.load_skills()`` merges the enabled state from ``extensions_config.json``. When that file
|
|
cannot be read, the parsed default (``enabled=True`` from ``parse_skill_file``) must not stand in for the
|
|
answer: an unrelated defect in the file — a malformed ``mcpServers`` entry, a trailing comma — would
|
|
otherwise silently re-enable every skill an operator disabled.
|
|
|
|
The sandbox projection fails closed on the same error (``projection._rebuild_public_locked`` clears the
|
|
view), so the runtime half of the gate has to agree with it.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
|
|
from deerflow.config.extensions_config import ExtensionsConfig, reset_extensions_config
|
|
from deerflow.skills.storage.local_skill_storage import LocalSkillStorage
|
|
|
|
_SKILL_MD = """---
|
|
name: {name}
|
|
description: {name} description
|
|
---
|
|
body
|
|
"""
|
|
|
|
|
|
def _write_public_skill(root: Path, name: str) -> None:
|
|
skill_dir = root / "public" / name
|
|
skill_dir.mkdir(parents=True)
|
|
(skill_dir / "SKILL.md").write_text(_SKILL_MD.format(name=name), encoding="utf-8")
|
|
|
|
|
|
@pytest.fixture()
|
|
def disabled_skill_root(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path:
|
|
"""A skills root whose only skill is disabled in the extensions config."""
|
|
root = tmp_path / "skills"
|
|
_write_public_skill(root, "demo")
|
|
config_path = tmp_path / "extensions_config.json"
|
|
config_path.write_text(json.dumps({"mcpServers": {}, "skills": {"demo": {"enabled": False}}}), encoding="utf-8")
|
|
monkeypatch.setenv("DEER_FLOW_EXTENSIONS_CONFIG_PATH", str(config_path))
|
|
reset_extensions_config()
|
|
return root
|
|
|
|
|
|
def test_disabled_skill_is_disabled_while_the_config_loads(disabled_skill_root: Path) -> None:
|
|
storage = LocalSkillStorage(str(disabled_skill_root))
|
|
|
|
assert [(skill.name, skill.enabled) for skill in storage.load_skills()] == [("demo", False)]
|
|
assert storage.load_skills(enabled_only=True) == []
|
|
|
|
|
|
def test_disabled_skill_stays_disabled_when_the_config_cannot_be_read(disabled_skill_root: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
|
storage = LocalSkillStorage(str(disabled_skill_root))
|
|
|
|
def _raise(*_args: object, **_kwargs: object) -> None:
|
|
# What one schema-invalid ``mcpServers`` entry raises from ``McpServerConfig._validate_header_names``;
|
|
# a malformed JSON file raises ``ValueError`` from the same call site.
|
|
raise ValueError("headers maps the same HTTP header under two spellings ('X-Api-Key' and 'x-api-key')")
|
|
|
|
monkeypatch.setattr(ExtensionsConfig, "from_file", classmethod(_raise))
|
|
|
|
# Still listed, so the skill does not silently disappear from the UI...
|
|
assert [(skill.name, skill.enabled) for skill in storage.load_skills()] == [("demo", False)]
|
|
# ...and never handed to the model as usable.
|
|
assert storage.load_skills(enabled_only=True) == []
|