deer-flow/backend/tests/test_skill_sdk_enrollment.py
Gusanity a759a84201
test: skip POSIX-only skill mutation tests on Windows (#7154)
* test: skip POSIX-only skill mutation tests on Windows

* test: release SQLite fixture connection on Windows

---------

Co-authored-by: unknown <F028@sophon.gd>
2026-10-20 10:03:05 +08:00

157 lines
6.6 KiB
Python

"""Standalone SDK processes must not bypass another host's durable enrollment."""
import json
import os
import subprocess
import sys
import pytest
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
from deerflow.config.paths import Paths
from deerflow.persistence.base import Base
from deerflow.persistence.user.model import UserRow
from deerflow.skills.mutations.assets import capture_package
from deerflow.skills.mutations.repository import SkillMutationRepository
CONTENT = "---\nname: example\ndescription: SDK enrollment regression\n---\nOriginal body.\n"
@pytest.fixture
def sdk_home(tmp_path):
home = tmp_path / "home"
root = Paths(base_dir=home).user_custom_skills_dir("owner") / "example"
root.mkdir(parents=True)
(root / "SKILL.md").write_text(CONTENT, encoding="utf-8")
public = tmp_path / "skills" / "public" / "example"
public.mkdir(parents=True)
(public / "SKILL.md").write_text(CONTENT, encoding="utf-8")
config = tmp_path / "config.yaml"
config.write_text(
json.dumps(
{
"models": [{"name": "test", "use": "langchain_openai:ChatOpenAI", "model": "test", "api_key": "test"}],
"sandbox": {"use": "deerflow.sandbox.local:LocalSandboxProvider"},
"skills": {"path": str(tmp_path / "skills")},
"database": {"backend": "sqlite", "sqlite_dir": str(tmp_path)},
"plugins": [],
}
),
encoding="utf-8",
)
extensions = tmp_path / "extensions.json"
extensions.write_text('{"skills": {}}', encoding="utf-8")
engine = create_engine(f"sqlite:///{tmp_path / 'deerflow.db'}")
Base.metadata.create_all(engine)
sessions = sessionmaker(engine, expire_on_commit=False)
with sessions.begin() as session:
session.add(UserRow(id="owner", email="owner@example.test"))
# Release the fixture's pooled SQLite connection before SDK subprocesses
# exercise database removal, which Windows forbids while the file is open.
engine.dispose()
repository = SkillMutationRepository(sessions)
yield tmp_path, config, root, repository
engine.dispose()
def run_sdk(fixture, action, *, owner="owner"):
tmp_path, config, _, _ = fixture
source = """
import json, sys
from types import SimpleNamespace
from deerflow.client import DeerFlowClient
from deerflow.runtime.user_context import set_current_user
from deerflow.skills.mutations import guard
from deerflow.skills.storage import get_or_new_user_skill_storage
from deerflow_extension_api import HostCapabilityError
assert guard._runtime is None
set_current_user(SimpleNamespace(id=sys.argv[2]))
client = DeerFlowClient(config_path=sys.argv[1])
storage = get_or_new_user_skill_storage(sys.argv[2], app_config=client._app_config)
try:
exec(sys.argv[3])
except HostCapabilityError as exc:
print(json.dumps({"error": exc.code}))
else:
print(json.dumps({"error": None}))
"""
result = subprocess.run(
[sys.executable, "-c", source, str(config), owner, action],
env={**os.environ, "DEER_FLOW_CONFIG_PATH": str(config), "DEER_FLOW_HOME": str(tmp_path / "home"), "DEER_FLOW_EXTENSIONS_CONFIG_PATH": str(tmp_path / "extensions.json")},
text=True,
capture_output=True,
timeout=30,
)
assert result.returncode == 0, result.stderr
return json.loads(result.stdout.splitlines()[-1])
@pytest.mark.skipif(os.name != "posix", reason="package capture requires POSIX filesystem primitives")
@pytest.mark.parametrize(
"action",
[
"client.update_skill('example', enabled=False); client.update_skill('example', enabled=True)",
"storage.set_skill_enabled_state('example', False)",
"storage.write_custom_skill('example', 'SKILL.md', 'changed')",
"storage.delete_custom_skill('example')",
"client.get_skill('example')",
"storage.load_custom_skill_diagnostics()",
],
)
def test_fresh_sdk_refuses_enrolled_owner_without_gateway_runtime(sdk_home, action):
_, _, root, repository = sdk_home
before = repository.observe("owner", "example", capture_package(root).digest, True, exists=True)
assert run_sdk(sdk_home, action) == {"error": "MUTATION_RUNTIME_REQUIRED"}
assert (root / "SKILL.md").read_text(encoding="utf-8") == CONTENT
assert not (root.parent.parent / "_skill_states.json").exists()
after = repository.observe("owner", "example", capture_package(root).digest, True, exists=True)
assert after.mutation_seq == before.mutation_seq
@pytest.mark.skipif(os.name != "posix", reason="package capture requires POSIX filesystem primitives")
def test_fresh_sdk_global_toggle_cannot_bypass_another_enrolled_owner(sdk_home):
_, _, root, repository = sdk_home
repository.observe("owner", "example", capture_package(root).digest, True, exists=True)
assert run_sdk(sdk_home, "client.update_skill('example', enabled=False)", owner="other") == {"error": "MUTATION_RUNTIME_REQUIRED"}
assert json.loads((sdk_home[0] / "extensions.json").read_text(encoding="utf-8")) == {"skills": {}}
def test_unenrolled_sdk_retains_skill_management(sdk_home):
assert run_sdk(sdk_home, "client.update_skill('example', enabled=False); client.update_skill('example', enabled=True)") == {"error": None}
def test_existing_sdk_rechecks_enrollment_after_an_earlier_unmanaged_read(sdk_home):
action = """
assert client.get_skill("example") is not None
# Another host can persist enrollment after this client has started. Only the
# durable row is shared; the SDK still has no injected mutation runtime.
import sqlite3
with sqlite3.connect(client._app_config.database.sqlite_path) as connection:
connection.execute("INSERT INTO skill_mutation_owners (owner_id, generation, deleting) VALUES ('owner', 0, 0)")
storage.set_skill_enabled_state("example", False)
"""
assert run_sdk(sdk_home, action) == {"error": "MUTATION_RUNTIME_REQUIRED"}
assert not (sdk_home[2].parent.parent / "_skill_states.json").exists()
def test_sdk_cannot_treat_unavailable_enrollment_as_unmanaged(sdk_home):
action = """
from pathlib import Path
Path(client._app_config.database.sqlite_path).write_bytes(b"unreadable database")
storage.set_skill_enabled_state("example", False)
"""
assert run_sdk(sdk_home, action) == {"error": "UNAVAILABLE"}
assert not (sdk_home[2].parent.parent / "_skill_states.json").exists()
def test_sdk_does_not_create_a_missing_application_database(sdk_home):
action = """
from pathlib import Path
path = Path(client._app_config.database.sqlite_path)
path.unlink()
client.update_skill("example", enabled=False)
assert not path.exists()
"""
assert run_sdk(sdk_home, action) == {"error": None}