deer-flow/backend/tests/test_extension_host_access.py
Ryker_Feng d1d0958bea
feat(skills): add guarded evolution host APIs (#5645)
* feat(skills): add guarded evolution host APIs

* docs(agents): avoid growing saturated guidance chains

* fix(skills): address mutation host review findings

* fix(skills): order name fence before projection lock

* fix(skills): preserve composite toggle lock order

* fix(skills): address catalog and pagination review feedback

* docs: keep subagent guidance within merged instruction budget

---------

Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
2026-10-18 21:17:02 +08:00

167 lines
6.1 KiB
Python

from types import SimpleNamespace
import pytest
from pydantic import ValidationError
from deerflow.extensions.loader import ExtensionSpec, load_extensions
def granted(**overrides):
return ExtensionSpec(
use="example:install",
name="evolution",
host_access={
"evidence": {"owners": ["owner"]},
"skill_mutations": {"owners": ["owner"], "operations": ["stage", "check"], "trigger_agents": ["agent"], "target_skills": ["example"], **overrides},
},
)
def test_host_access_is_default_deny_and_not_private_config():
plain = ExtensionSpec(use="example:install")
assert plain.host_access.evidence.owners == ()
assert plain.host_access.skill_mutations.operations == ()
spec = granted()
assert spec.config == {}
assert spec.host_access.skill_mutations.operations == ("stage", "check")
@pytest.mark.parametrize("key,value", [("owners", ["*"]), ("operations", ["create"]), ("trigger_agents", [""]), ("target_skills", ["../bad"]), ("topology", "multi_node")])
def test_invalid_grants_rejected(key, value):
with pytest.raises(ValidationError):
granted(**{key: value})
def test_host_access_requires_stable_name():
with pytest.raises(ValidationError):
ExtensionSpec(use="example:install", host_access={"evidence": {"owners": ["owner"]}})
def test_granted_source_cannot_be_ambiguous_even_with_other_ungranted_entry(monkeypatch):
calls = []
monkeypatch.setattr("deerflow.extensions.loader.resolve_variable", lambda _: calls.append(1))
with pytest.raises(ValueError, match="unique"):
load_extensions([granted(), ExtensionSpec(use="example:install", name="other")])
assert calls == []
def test_granted_name_cannot_be_ambiguous(monkeypatch):
monkeypatch.setattr("deerflow.extensions.loader.resolve_variable", lambda _: None)
with pytest.raises(ValueError, match="unique"):
load_extensions([granted(), ExtensionSpec(use="other:install", name="evolution")])
def test_binding_changes_with_entry_point_and_empty_scope_means_none():
from deerflow.extensions.host_access import bind_host_access
first = bind_host_access([granted()])["example:install"]
replacement = granted().model_copy(update={"use": "replacement:install"})
second = bind_host_access([replacement])["replacement:install"]
assert first.plugin_id != second.plugin_id
assert not first.allows("commit", "owner", "example")
assert first.allows("stage", "owner", "example")
assert not first.allows("stage", "another", "example")
empty = granted(owners=[])
assert not bind_host_access([empty])["example:install"].allows("stage", "owner", "example")
@pytest.mark.asyncio
async def test_services_receive_independently_bound_capabilities():
from deerflow.extensions.gateway import start_services
from deerflow.extensions.registry import ExtensionRegistry
received = []
class Service:
async def start(self, deps):
received.append(deps)
registry = ExtensionRegistry()
for source in ("first", "second"):
with registry.attributed_to(source):
registry.service(Service())
first, second = object(), object()
await start_services(registry.build(), SimpleNamespace(), None, host_capabilities={"first": (first, None), "second": (None, second)})
assert received[0].completed_run_evidence is first
assert received[0].skill_mutations is None
assert received[1].completed_run_evidence is None
assert received[1].skill_mutations is second
def test_new_host_contracts_are_available_without_harness_imports():
import deerflow_extension_api as api
for name in (
"HostCapabilityError",
"CompletedRunSnapshot",
"CompletedRunEvent",
"CompletedRunEventPage",
"CompletedRunEvidenceReader",
"EvidenceLimits",
"AssetRevision",
"SkillRevisionView",
"MutationCapabilities",
"Proposal",
"ProposalBundle",
"BundleFile",
"HostCheckResult",
"AssessmentRef",
"Operation",
"SkillMutationService",
):
assert name in api.__all__
assert getattr(api, name).__module__.startswith("deerflow_extension_api.")
def test_mutation_bundles_and_capabilities_detach_mutable_constructor_values():
from deerflow_extension_api import AssetRevision, BundleFile, MutationCapabilities, Proposal, ProposalBundle
content = bytearray(b"original")
files = [BundleFile("SKILL.md", content, "digest", False)]
revision = AssetRevision("incarnation", 1, "digest")
bundle = ProposalBundle("p", files, files, revision, "hash")
ops = ["stage"]
caps = MutationCapabilities(ops)
sources = ["s"]
proposal = Proposal("p", "o", "t", "skill", revision, "hash", "STAGED", "expiry", sources)
content[:] = b"tampered"
files.clear()
ops.append("commit")
sources.clear()
assert bundle.baseline[0].content == b"original"
assert caps.operations == ("stage",)
assert proposal.source_refs == ("s",)
@pytest.mark.asyncio
async def test_model_and_evolution_grants_compose_in_same_service(monkeypatch):
from deerflow.extensions.gateway import start_services, stop_services
received = []
class Service:
async def start(self, deps):
received.append(deps)
async def stop(self):
pass
def install(registry, config):
registry.service(Service())
monkeypatch.setattr("deerflow.extensions.loader.resolve_variable", lambda _: install)
access = granted().host_access.model_dump()
access["model_invocation"] = {"roles": {"default": "host-model"}}
spec = ExtensionSpec(use="example:install", name="evolution", host_access=access)
loaded, diagnostics = load_extensions([spec])
assert not diagnostics
evidence, mutations = object(), object()
try:
diagnostics = await start_services(loaded, SimpleNamespace(), None, host_capabilities={spec.use: (evidence, mutations)})
assert not diagnostics
assert received[0].model_invoker is not None
assert received[0].completed_run_evidence is evidence
assert received[0].skill_mutations is mutations
finally:
await stop_services(loaded)