deer-flow/backend/tests/test_skills_disabled_fail_closed.py
lukyturnflow bef8c9c2e3
fix(skills): keep disabled skills disabled when the extensions config cannot be read (#7705)
* 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>
2026-11-05 15:07:07 +08:00

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) == []