From 41b45e04230236456b4f3e8ab84c2544e135ba2b Mon Sep 17 00:00:00 2001 From: steven_li Date: Tue, 16 Jun 2026 11:40:31 +0800 Subject: [PATCH] feat(plugins): discover packages and persist state --- .../beaver/foundation/config/__init__.py | 2 + .../beaver/foundation/config/loader.py | 13 ++ .../beaver/foundation/config/schema.py | 9 + .../beaver/foundation/utils/file_lock.py | 111 ++++++++++++ .../backend/beaver/plugins/__init__.py | 29 ++++ .../backend/beaver/plugins/discovery.py | 74 ++++++++ .../backend/beaver/plugins/hashing.py | 78 +++++++++ .../backend/beaver/plugins/manifest.py | 106 ++++++++++++ app-instance/backend/beaver/plugins/models.py | 137 +++++++++++++++ app-instance/backend/beaver/plugins/state.py | 78 +++++++++ .../backend/tests/unit/test_config_loader.py | 40 +++++ .../backend/tests/unit/test_plugin_hashing.py | 83 +++++++++ .../tests/unit/test_plugin_manifest.py | 160 ++++++++++++++++++ .../backend/tests/unit/test_plugin_state.py | 143 ++++++++++++++++ .../tests/unit/test_workspace_write_lock.py | 64 +++++++ 15 files changed, 1127 insertions(+) create mode 100644 app-instance/backend/beaver/foundation/utils/file_lock.py create mode 100644 app-instance/backend/beaver/plugins/__init__.py create mode 100644 app-instance/backend/beaver/plugins/discovery.py create mode 100644 app-instance/backend/beaver/plugins/hashing.py create mode 100644 app-instance/backend/beaver/plugins/manifest.py create mode 100644 app-instance/backend/beaver/plugins/models.py create mode 100644 app-instance/backend/beaver/plugins/state.py create mode 100644 app-instance/backend/tests/unit/test_plugin_hashing.py create mode 100644 app-instance/backend/tests/unit/test_plugin_manifest.py create mode 100644 app-instance/backend/tests/unit/test_plugin_state.py create mode 100644 app-instance/backend/tests/unit/test_workspace_write_lock.py diff --git a/app-instance/backend/beaver/foundation/config/__init__.py b/app-instance/backend/beaver/foundation/config/__init__.py index c3c1aa1..63955dc 100644 --- a/app-instance/backend/beaver/foundation/config/__init__.py +++ b/app-instance/backend/beaver/foundation/config/__init__.py @@ -8,6 +8,7 @@ from .schema import ( BeaverConfig, EmbeddingConfig, MCPServerConfig, + PluginsConfig, ProviderConfig, ToolsConfig, ) @@ -19,6 +20,7 @@ __all__ = [ "BeaverConfig", "EmbeddingConfig", "MCPServerConfig", + "PluginsConfig", "ProviderConfig", "ToolsConfig", "default_config_path", diff --git a/app-instance/backend/beaver/foundation/config/loader.py b/app-instance/backend/beaver/foundation/config/loader.py index 3e71302..9f63025 100644 --- a/app-instance/backend/beaver/foundation/config/loader.py +++ b/app-instance/backend/beaver/foundation/config/loader.py @@ -16,6 +16,7 @@ from .schema import ( ChannelConfig, EmbeddingConfig, MCPServerConfig, + PluginsConfig, ProviderConfig, ToolsConfig, ) @@ -73,6 +74,7 @@ def load_config( providers=_parse_providers(data.get("providers")), embedding=_parse_embedding(data), tools=_parse_tools(data.get("tools")), + plugins=_parse_plugins(data.get("plugins")), authz=_parse_authz(data.get("authz")), channels=_parse_channels(data.get("channels")), backend_identity=_parse_backend_identity(data.get("backend_identity") or data.get("backendIdentity")), @@ -188,6 +190,17 @@ def _parse_tools(raw: Any) -> ToolsConfig: ) +def _parse_plugins(raw: Any) -> PluginsConfig: + data = _as_dict(raw) + return PluginsConfig( + search_paths=_string_list(data.get("searchPaths") or data.get("search_paths")), + auto_sync=_bool( + data.get("autoSync") if "autoSync" in data else data.get("auto_sync"), + default=True, + ), + ) + + def _parse_authz(raw: Any) -> AuthzConfig: data = _as_dict(raw) return AuthzConfig( diff --git a/app-instance/backend/beaver/foundation/config/schema.py b/app-instance/backend/beaver/foundation/config/schema.py index 2c89f57..9179cae 100644 --- a/app-instance/backend/beaver/foundation/config/schema.py +++ b/app-instance/backend/beaver/foundation/config/schema.py @@ -81,6 +81,14 @@ class ToolsConfig: mcp_servers: dict[str, MCPServerConfig] = field(default_factory=dict) +@dataclass(slots=True) +class PluginsConfig: + """Declarative plugin discovery settings.""" + + search_paths: list[str] = field(default_factory=list) + auto_sync: bool = True + + @dataclass(slots=True) class AuthzConfig: """External AuthZ service configuration.""" @@ -123,6 +131,7 @@ class BeaverConfig: providers: dict[str, ProviderConfig] = field(default_factory=dict) embedding: EmbeddingConfig = field(default_factory=EmbeddingConfig) tools: ToolsConfig = field(default_factory=ToolsConfig) + plugins: PluginsConfig = field(default_factory=PluginsConfig) authz: AuthzConfig = field(default_factory=AuthzConfig) channels: dict[str, ChannelConfig] = field(default_factory=dict) backend_identity: BackendIdentityConfig = field(default_factory=BackendIdentityConfig) diff --git a/app-instance/backend/beaver/foundation/utils/file_lock.py b/app-instance/backend/beaver/foundation/utils/file_lock.py new file mode 100644 index 0000000..5f8a5bd --- /dev/null +++ b/app-instance/backend/beaver/foundation/utils/file_lock.py @@ -0,0 +1,111 @@ +"""Cross-process workspace write lock with in-process reentrancy.""" + +from __future__ import annotations + +from contextlib import contextmanager +from dataclasses import dataclass +from pathlib import Path +import os +import threading +import time +from typing import Iterator + +if os.name == "nt": # pragma: no cover - exercised on Windows only + import msvcrt +else: # pragma: no cover - import branch is platform-specific + import fcntl + + +class WorkspaceWriteLockBusy(RuntimeError): + """Raised when the shared workspace write lock cannot be acquired.""" + + +@dataclass(slots=True) +class _HeldLock: + rlock: threading.RLock + handle: object | None = None + owner_thread: int | None = None + depth: int = 0 + + +_REGISTRY_GUARD = threading.Lock() +_HELD_BY_PATH: dict[Path, _HeldLock] = {} + + +class WorkspaceWriteLock: + def __init__(self, workspace: str | Path) -> None: + self.workspace = Path(workspace) + self.path = self.workspace / ".beaver" / "locks" / "plugin-skill-write.lock" + + @contextmanager + def acquire( + self, + *, + timeout_seconds: float | None = None, + blocking: bool = True, + ) -> Iterator[None]: + held = self._held_lock() + thread_id = threading.get_ident() + with held.rlock: + if held.owner_thread == thread_id and held.depth > 0: + held.depth += 1 + try: + yield + finally: + held.depth -= 1 + return + + self.path.parent.mkdir(parents=True, exist_ok=True) + handle = self.path.open("a+b") + try: + self._acquire_os_lock(handle, timeout_seconds=timeout_seconds, blocking=blocking) + held.handle = handle + held.owner_thread = thread_id + held.depth = 1 + try: + yield + finally: + held.depth = 0 + held.owner_thread = None + held.handle = None + self._release_os_lock(handle) + finally: + handle.close() + + def _held_lock(self) -> _HeldLock: + resolved = self.path.resolve() + with _REGISTRY_GUARD: + held = _HELD_BY_PATH.get(resolved) + if held is None: + held = _HeldLock(rlock=threading.RLock()) + _HELD_BY_PATH[resolved] = held + return held + + @staticmethod + def _acquire_os_lock(handle: object, *, timeout_seconds: float | None, blocking: bool) -> None: + deadline = None if timeout_seconds is None else time.monotonic() + timeout_seconds + while True: + try: + if os.name == "nt": # pragma: no cover + mode = msvcrt.LK_LOCK if blocking else msvcrt.LK_NBLCK + msvcrt.locking(handle.fileno(), mode, 1) # type: ignore[attr-defined] + else: + flags = fcntl.LOCK_EX + if not blocking: + flags |= fcntl.LOCK_NB + fcntl.flock(handle.fileno(), flags) # type: ignore[attr-defined] + return + except (BlockingIOError, OSError): + if not blocking: + raise WorkspaceWriteLockBusy("plugin_write_busy") + if deadline is not None and time.monotonic() >= deadline: + raise WorkspaceWriteLockBusy("plugin_write_busy") + time.sleep(0.05) + + @staticmethod + def _release_os_lock(handle: object) -> None: + if os.name == "nt": # pragma: no cover + handle.seek(0) # type: ignore[attr-defined] + msvcrt.locking(handle.fileno(), msvcrt.LK_UNLCK, 1) # type: ignore[attr-defined] + else: + fcntl.flock(handle.fileno(), fcntl.LOCK_UN) # type: ignore[attr-defined] diff --git a/app-instance/backend/beaver/plugins/__init__.py b/app-instance/backend/beaver/plugins/__init__.py new file mode 100644 index 0000000..5dfe36a --- /dev/null +++ b/app-instance/backend/beaver/plugins/__init__.py @@ -0,0 +1,29 @@ +"""Declarative Beaver plugin support.""" + +from .hashing import hash_plugin_skill_tree +from .manifest import load_plugin_manifest +from .models import ( + PluginDiscoveryError, + PluginDiscoveryResult, + PluginManifest, + PluginSkillBinding, + PluginSkillDeclaration, + PluginSkillFileDigest, + PluginSkillTreeDigest, + PluginState, +) +from .state import PluginStateStore + +__all__ = [ + "PluginDiscoveryError", + "PluginDiscoveryResult", + "PluginManifest", + "PluginSkillBinding", + "PluginSkillDeclaration", + "PluginSkillFileDigest", + "PluginSkillTreeDigest", + "PluginState", + "PluginStateStore", + "hash_plugin_skill_tree", + "load_plugin_manifest", +] diff --git a/app-instance/backend/beaver/plugins/discovery.py b/app-instance/backend/beaver/plugins/discovery.py new file mode 100644 index 0000000..cb9c75b --- /dev/null +++ b/app-instance/backend/beaver/plugins/discovery.py @@ -0,0 +1,74 @@ +"""Plugin package discovery.""" + +from __future__ import annotations + +from pathlib import Path +from typing import Iterable + +from .manifest import load_plugin_manifest +from .models import PluginDiscoveryError, PluginDiscoveryResult, PluginManifest + + +def discover_plugins( + workspace: str | Path, + *, + search_paths: Iterable[str | Path] = (), +) -> PluginDiscoveryResult: + workspace_root = Path(workspace).resolve() + candidates: list[Path] = [] + candidates.extend(_candidate_manifest_paths(workspace_root / "plugins")) + for root in search_paths: + candidates.extend(_candidate_manifest_paths(Path(root).expanduser())) + + manifests_by_id: dict[str, list[PluginManifest]] = {} + errors: list[PluginDiscoveryError] = [] + for manifest_path in candidates: + try: + manifest = load_plugin_manifest(manifest_path, workspace=workspace_root) + except Exception as exc: # noqa: BLE001 - discovery reports per-path errors. + errors.append( + PluginDiscoveryError( + path=manifest_path, + display_path=_display_path(manifest_path, workspace_root), + message=str(exc), + plugin_id=None, + ) + ) + continue + manifests_by_id.setdefault(manifest.plugin_id, []).append(manifest) + + manifests: dict[str, PluginManifest] = {} + for plugin_id, matches in manifests_by_id.items(): + if len(matches) == 1: + manifests[plugin_id] = matches[0] + continue + for manifest in matches: + errors.append( + PluginDiscoveryError( + path=manifest.manifest_path, + display_path=manifest.display_path, + message=f"Duplicate plugin id: {plugin_id}", + plugin_id=plugin_id, + ) + ) + return PluginDiscoveryResult(manifests=manifests, errors=errors) + + +def _candidate_manifest_paths(root: Path) -> list[Path]: + if not root.exists() or not root.is_dir(): + return [] + results: list[Path] = [] + for child in sorted(root.iterdir()): + if not child.is_dir(): + continue + manifest = child / "beaver.plugin.json" + if manifest.is_file(): + results.append(manifest) + return results + + +def _display_path(path: Path, workspace: Path) -> str: + resolved = path.resolve() + if resolved.is_relative_to(workspace): + return resolved.relative_to(workspace).as_posix() + return f"/{resolved.parent.name}/{resolved.name}" diff --git a/app-instance/backend/beaver/plugins/hashing.py b/app-instance/backend/beaver/plugins/hashing.py new file mode 100644 index 0000000..5c6d654 --- /dev/null +++ b/app-instance/backend/beaver/plugins/hashing.py @@ -0,0 +1,78 @@ +"""Canonical hashing for plugin skill trees.""" + +from __future__ import annotations + +import hashlib +import os +from pathlib import Path + +from .models import PluginSkillFileDigest, PluginSkillTreeDigest + +IGNORED_METADATA_FILENAMES = {"version.json", "upstream.json"} + + +def hash_plugin_skill_tree(root: str | Path) -> PluginSkillTreeDigest: + skill_root = Path(root) + if not skill_root.is_dir(): + raise ValueError(f"Plugin skill root is not a directory: {skill_root}") + skill_file = skill_root / "SKILL.md" + if not skill_file.is_file() or skill_file.is_symlink(): + raise ValueError("Plugin skill tree must contain a regular SKILL.md") + + file_digests: list[PluginSkillFileDigest] = [] + tree_hasher = hashlib.sha256() + for path in _iter_regular_files(skill_root): + relative = path.relative_to(skill_root).as_posix() + data = path.read_bytes() + executable = _is_executable(path) + content_hash = _sha256(data) + file_digests.append( + PluginSkillFileDigest( + path=relative, + size=len(data), + executable=executable, + content_hash=content_hash, + ) + ) + _update_field(tree_hasher, relative.encode("utf-8")) + _update_field(tree_hasher, str(len(data)).encode("ascii")) + _update_field(tree_hasher, b"1" if executable else b"0") + _update_field(tree_hasher, data) + + skill_content = skill_file.read_text(encoding="utf-8").replace("\r\n", "\n").replace("\r", "\n") + return PluginSkillTreeDigest( + skill_content_hash=_sha256(skill_content.encode("utf-8")), + skill_tree_hash=f"sha256:{tree_hasher.hexdigest()}", + files=tuple(file_digests), + ) + + +def _iter_regular_files(root: Path) -> list[Path]: + results: list[Path] = [] + for path in sorted(root.rglob("*"), key=lambda item: item.relative_to(root).as_posix()): + relative = path.relative_to(root) + if any(part in {"", ".", ".."} for part in relative.parts): + raise ValueError(f"Invalid path in plugin skill tree: {relative.as_posix()}") + if path.is_symlink(): + raise ValueError(f"Plugin skill tree contains a symlink: {relative.as_posix()}") + if path.is_dir(): + continue + if not path.is_file(): + raise ValueError(f"Plugin skill tree contains a non-regular file: {relative.as_posix()}") + if len(relative.parts) == 1 and relative.name in IGNORED_METADATA_FILENAMES: + continue + results.append(path) + return results + + +def _is_executable(path: Path) -> bool: + return bool(path.stat().st_mode & (os.X_OK | 0o111)) + + +def _sha256(data: bytes) -> str: + return f"sha256:{hashlib.sha256(data).hexdigest()}" + + +def _update_field(hasher: "hashlib._Hash", data: bytes) -> None: + hasher.update(len(data).to_bytes(8, "big")) + hasher.update(data) diff --git a/app-instance/backend/beaver/plugins/manifest.py b/app-instance/backend/beaver/plugins/manifest.py new file mode 100644 index 0000000..0012703 --- /dev/null +++ b/app-instance/backend/beaver/plugins/manifest.py @@ -0,0 +1,106 @@ +"""Strict manifest parsing for declarative skill plugins.""" + +from __future__ import annotations + +import json +import re +from pathlib import Path +from typing import Any + +from .models import PluginManifest, PluginSkillDeclaration + +IDENTIFIER_PATTERN = re.compile(r"^[a-z0-9][a-z0-9_-]*$") + + +def load_plugin_manifest(path: str | Path, *, workspace: str | Path | None = None) -> PluginManifest: + manifest_path = Path(path) + payload = json.loads(manifest_path.read_text(encoding="utf-8")) + if not isinstance(payload, dict): + raise ValueError("Plugin manifest must be a JSON object") + + schema_version = int(payload.get("schema_version", 0) or 0) + if schema_version != 1: + raise ValueError(f"Unsupported plugin manifest schema version: {schema_version}") + + plugin_id = _require_identifier(payload.get("id"), field="id") + name = _require_string(payload.get("name"), field="name") + version = _require_string(payload.get("version"), field="version") + root = manifest_path.parent.resolve() + raw_skills = payload.get("skills") + if not isinstance(raw_skills, list) or not raw_skills: + raise ValueError("Plugin manifest must declare at least one skill") + + skills: list[PluginSkillDeclaration] = [] + seen_names: set[str] = set() + for item in raw_skills: + if not isinstance(item, dict): + raise ValueError("Plugin skill declarations must be JSON objects") + skill_name = _require_identifier(item.get("name"), field="skill name") + if skill_name in seen_names: + raise ValueError(f"Plugin manifest contains duplicate skill name: {skill_name}") + seen_names.add(skill_name) + relative_path = _require_string(item.get("path"), field=f"{skill_name}.path") + _reject_symlink_path(root, Path(relative_path)) + skill_root = _resolve_contained_path(root, relative_path) + skill_file = skill_root / "SKILL.md" + if not skill_file.is_file() or skill_file.is_symlink(): + raise ValueError(f"Plugin skill {skill_name} must contain a regular SKILL.md") + skills.append(PluginSkillDeclaration(name=skill_name, relative_path=relative_path, root=skill_root)) + + return PluginManifest( + schema_version=schema_version, + plugin_id=plugin_id, + name=name, + version=version, + root=root, + manifest_path=manifest_path.resolve(), + display_path=_display_path(manifest_path, workspace=workspace), + skills=tuple(skills), + ) + + +def _resolve_contained_path(root: Path, raw_path: str) -> Path: + relative = Path(raw_path) + if relative.is_absolute(): + raise ValueError("Plugin skill path must be contained within the plugin root") + resolved = (root / relative).resolve() + if not resolved.is_relative_to(root): + raise ValueError("Plugin skill path must be contained within the plugin root") + return resolved + + +def _reject_symlink_path(root: Path, relative: Path) -> None: + current = root + for part in relative.parts: + current = current / part + if current.is_symlink(): + raise ValueError(f"Plugin skill path contains a symlink: {current}") + + +def _display_path(path: Path, *, workspace: str | Path | None) -> str: + resolved = path.resolve() + if workspace is not None: + workspace_root = Path(workspace).resolve() + if resolved.is_relative_to(workspace_root): + return resolved.relative_to(workspace_root).as_posix() + return f"/{resolved.parent.name}/{resolved.name}" + parent = resolved.parent.parent + if resolved.is_relative_to(parent): + return resolved.relative_to(parent).as_posix() + return resolved.name + + +def _require_identifier(value: Any, *, field: str) -> str: + text = str(value or "").strip() + if not IDENTIFIER_PATTERN.fullmatch(text): + raise ValueError(f"Invalid plugin identifier for {field}: {text!r}") + return text + + +def _require_string(value: Any, *, field: str) -> str: + if value is None: + raise ValueError(f"Plugin manifest field is required: {field}") + text = str(value).strip() + if not text: + raise ValueError(f"Plugin manifest field cannot be empty: {field}") + return text diff --git a/app-instance/backend/beaver/plugins/models.py b/app-instance/backend/beaver/plugins/models.py new file mode 100644 index 0000000..726ebcb --- /dev/null +++ b/app-instance/backend/beaver/plugins/models.py @@ -0,0 +1,137 @@ +"""Models for declarative Beaver plugin packages.""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from pathlib import Path +from typing import Any + + +@dataclass(frozen=True, slots=True) +class PluginSkillDeclaration: + name: str + relative_path: str + root: Path + + +@dataclass(frozen=True, slots=True) +class PluginManifest: + schema_version: int + plugin_id: str + name: str + version: str + root: Path + manifest_path: Path + display_path: str + skills: tuple[PluginSkillDeclaration, ...] + + +@dataclass(frozen=True, slots=True) +class PluginSkillFileDigest: + path: str + size: int + executable: bool + content_hash: str + + +@dataclass(frozen=True, slots=True) +class PluginSkillTreeDigest: + skill_content_hash: str + skill_tree_hash: str + files: tuple[PluginSkillFileDigest, ...] + + +@dataclass(frozen=True, slots=True) +class PluginDiscoveryError: + path: Path + display_path: str + message: str + plugin_id: str | None = None + + +@dataclass(frozen=True, slots=True) +class PluginDiscoveryResult: + manifests: dict[str, PluginManifest] + errors: list[PluginDiscoveryError] + + +@dataclass(slots=True) +class PluginSkillBinding: + accepted_upstream_tree_hash: str | None = None + observed_upstream_tree_hash: str | None = None + accepted_beaver_version: str | None = None + current_beaver_version: str | None = None + pending_candidate_id: str | None = None + status: str = "discovered" + last_error: str | None = None + + def to_dict(self) -> dict[str, Any]: + return { + "accepted_upstream_tree_hash": self.accepted_upstream_tree_hash, + "observed_upstream_tree_hash": self.observed_upstream_tree_hash, + "accepted_beaver_version": self.accepted_beaver_version, + "current_beaver_version": self.current_beaver_version, + "pending_candidate_id": self.pending_candidate_id, + "status": self.status, + "last_error": self.last_error, + } + + @classmethod + def from_dict(cls, payload: dict[str, Any] | None) -> "PluginSkillBinding": + data = payload if isinstance(payload, dict) else {} + return cls( + accepted_upstream_tree_hash=_optional_str(data.get("accepted_upstream_tree_hash")), + observed_upstream_tree_hash=_optional_str(data.get("observed_upstream_tree_hash")), + accepted_beaver_version=_optional_str(data.get("accepted_beaver_version")), + current_beaver_version=_optional_str(data.get("current_beaver_version")), + pending_candidate_id=_optional_str(data.get("pending_candidate_id")), + status=str(data.get("status") or "discovered"), + last_error=_optional_str(data.get("last_error")), + ) + + +@dataclass(slots=True) +class PluginState: + plugin_id: str + enabled: bool = False + updates_paused: bool = False + installed_version: str | None = None + manifest_path: str | None = None + status: str = "discovered" + last_error: str | None = None + skills: dict[str, PluginSkillBinding] = field(default_factory=dict) + + def to_dict(self) -> dict[str, Any]: + return { + "enabled": self.enabled, + "updates_paused": self.updates_paused, + "installed_version": self.installed_version, + "manifest_path": self.manifest_path, + "status": self.status, + "last_error": self.last_error, + "skills": {name: binding.to_dict() for name, binding in sorted(self.skills.items())}, + } + + @classmethod + def from_dict(cls, plugin_id: str, payload: dict[str, Any] | None) -> "PluginState": + data = payload if isinstance(payload, dict) else {} + raw_skills = data.get("skills") if isinstance(data.get("skills"), dict) else {} + return cls( + plugin_id=plugin_id, + enabled=bool(data.get("enabled", False)), + updates_paused=bool(data.get("updates_paused", False)), + installed_version=_optional_str(data.get("installed_version")), + manifest_path=_optional_str(data.get("manifest_path")), + status=str(data.get("status") or "discovered"), + last_error=_optional_str(data.get("last_error")), + skills={ + str(name): PluginSkillBinding.from_dict(binding if isinstance(binding, dict) else {}) + for name, binding in raw_skills.items() + }, + ) + + +def _optional_str(value: Any) -> str | None: + if value in (None, ""): + return None + return str(value) diff --git a/app-instance/backend/beaver/plugins/state.py b/app-instance/backend/beaver/plugins/state.py new file mode 100644 index 0000000..9be435d --- /dev/null +++ b/app-instance/backend/beaver/plugins/state.py @@ -0,0 +1,78 @@ +"""Atomic state persistence for declarative plugins.""" + +from __future__ import annotations + +import json +import os +from pathlib import Path +from typing import Any + +from .models import PluginSkillBinding, PluginState + + +class PluginStateStore: + def __init__(self, workspace: str | Path) -> None: + self.workspace = Path(workspace) + self.root = self.workspace / ".beaver" / "plugins" + self.path = self.root / "state.json" + + def list_plugins(self) -> list[PluginState]: + return [ + PluginState.from_dict(plugin_id, payload if isinstance(payload, dict) else {}) + for plugin_id, payload in sorted(self._read_state().get("plugins", {}).items()) + ] + + def get_plugin(self, plugin_id: str) -> PluginState | None: + payload = self._read_state().get("plugins", {}).get(plugin_id) + if not isinstance(payload, dict): + return None + return PluginState.from_dict(plugin_id, payload) + + def set_enabled(self, plugin_id: str, enabled: bool) -> PluginState: + state = self.get_plugin(plugin_id) or PluginState(plugin_id=plugin_id) + state.enabled = enabled + if enabled and state.status == "discovered": + state.status = "enabled" + self.upsert_plugin(state) + return state + + def upsert_plugin(self, plugin_state: PluginState) -> None: + state = self._read_state() + plugins = state.setdefault("plugins", {}) + if not isinstance(plugins, dict): + plugins = {} + state["plugins"] = plugins + plugins[plugin_state.plugin_id] = plugin_state.to_dict() + self._write_state(state) + + def update_skill_binding( + self, + plugin_id: str, + skill_name: str, + binding: PluginSkillBinding, + ) -> PluginState: + state = self.get_plugin(plugin_id) or PluginState(plugin_id=plugin_id) + state.skills[skill_name] = binding + self.upsert_plugin(state) + return state + + def _read_state(self) -> dict[str, Any]: + if not self.path.exists(): + return {"plugins": {}} + payload = json.loads(self.path.read_text(encoding="utf-8")) + if not isinstance(payload, dict): + return {"plugins": {}} + plugins = payload.get("plugins") + if not isinstance(plugins, dict): + payload["plugins"] = {} + return payload + + def _write_state(self, state: dict[str, Any]) -> None: + self.root.mkdir(parents=True, exist_ok=True) + tmp_path = self.path.with_name("state.json.tmp") + with tmp_path.open("w", encoding="utf-8") as handle: + json.dump(state, handle, ensure_ascii=False, sort_keys=True, indent=2) + handle.write("\n") + handle.flush() + os.fsync(handle.fileno()) + os.replace(tmp_path, self.path) diff --git a/app-instance/backend/tests/unit/test_config_loader.py b/app-instance/backend/tests/unit/test_config_loader.py index 1f61cef..081899c 100644 --- a/app-instance/backend/tests/unit/test_config_loader.py +++ b/app-instance/backend/tests/unit/test_config_loader.py @@ -47,6 +47,46 @@ def test_load_config_reads_current_instance_shape(tmp_path) -> None: assert target["extra_headers"] == {"X-Test": "1"} +def test_config_loader_reads_plugin_config(tmp_path) -> None: + config_path = tmp_path / "config.json" + config_path.write_text( + json.dumps( + { + "plugins": { + "searchPaths": [str(tmp_path / "plugins"), ""], + "autoSync": False, + } + } + ), + encoding="utf-8", + ) + + config = load_config(config_path=config_path) + + assert config.plugins.search_paths == [str(tmp_path / "plugins")] + assert config.plugins.auto_sync is False + + +def test_config_loader_accepts_snake_case_plugin_config(tmp_path) -> None: + config_path = tmp_path / "config.json" + config_path.write_text( + json.dumps( + { + "plugins": { + "search_paths": [str(tmp_path / "external")], + "auto_sync": True, + } + } + ), + encoding="utf-8", + ) + + config = load_config(config_path=config_path) + + assert config.plugins.search_paths == [str(tmp_path / "external")] + assert config.plugins.auto_sync is True + + def test_config_loader_reads_channels(tmp_path) -> None: config_path = tmp_path / "config.json" config_path.write_text( diff --git a/app-instance/backend/tests/unit/test_plugin_hashing.py b/app-instance/backend/tests/unit/test_plugin_hashing.py new file mode 100644 index 0000000..d835a51 --- /dev/null +++ b/app-instance/backend/tests/unit/test_plugin_hashing.py @@ -0,0 +1,83 @@ +from __future__ import annotations + +import os +from pathlib import Path + +import pytest + +from beaver.plugins.hashing import hash_plugin_skill_tree + + +def test_skill_tree_hash_changes_when_supporting_file_changes(tmp_path: Path) -> None: + root = tmp_path / "skill" + root.mkdir() + (root / "SKILL.md").write_text("# Skill\n", encoding="utf-8") + (root / "templates").mkdir() + template = root / "templates" / "report.md" + template.write_text("v1", encoding="utf-8") + + first = hash_plugin_skill_tree(root) + template.write_text("v2", encoding="utf-8") + second = hash_plugin_skill_tree(root) + + assert first.skill_content_hash == second.skill_content_hash + assert first.skill_tree_hash != second.skill_tree_hash + + +def test_skill_tree_hash_changes_when_path_changes(tmp_path: Path) -> None: + root = tmp_path / "skill" + root.mkdir() + (root / "SKILL.md").write_text("# Skill\n", encoding="utf-8") + (root / "a.txt").write_text("same", encoding="utf-8") + first = hash_plugin_skill_tree(root) + + (root / "b.txt").write_text((root / "a.txt").read_text(encoding="utf-8"), encoding="utf-8") + (root / "a.txt").unlink() + second = hash_plugin_skill_tree(root) + + assert first.skill_tree_hash != second.skill_tree_hash + + +def test_skill_tree_hash_tracks_executable_bit_but_not_other_mode_bits(tmp_path: Path) -> None: + root = tmp_path / "skill" + root.mkdir() + script = root / "script.sh" + (root / "SKILL.md").write_text("# Skill\n", encoding="utf-8") + script.write_text("#!/bin/sh\n", encoding="utf-8") + script.chmod(0o644) + first = hash_plugin_skill_tree(root) + + script.chmod(0o600) + non_exec_changed = hash_plugin_skill_tree(root) + script.chmod(0o700) + exec_changed = hash_plugin_skill_tree(root) + + assert first.skill_tree_hash == non_exec_changed.skill_tree_hash + assert first.skill_tree_hash != exec_changed.skill_tree_hash + + +def test_skill_tree_hash_ignores_mtime_and_beaver_metadata(tmp_path: Path) -> None: + root = tmp_path / "skill" + root.mkdir() + skill = root / "SKILL.md" + skill.write_text("# Skill\n", encoding="utf-8") + (root / "version.json").write_text('{"ignored": true}', encoding="utf-8") + (root / "upstream.json").write_text('{"ignored": true}', encoding="utf-8") + first = hash_plugin_skill_tree(root) + + os.utime(skill, (skill.stat().st_atime + 20, skill.stat().st_mtime + 20)) + (root / "version.json").write_text('{"ignored": false}', encoding="utf-8") + (root / "upstream.json").write_text('{"ignored": false}', encoding="utf-8") + second = hash_plugin_skill_tree(root) + + assert first.skill_tree_hash == second.skill_tree_hash + + +def test_skill_tree_hash_rejects_symlinks(tmp_path: Path) -> None: + root = tmp_path / "skill" + root.mkdir() + (root / "SKILL.md").write_text("# Skill\n", encoding="utf-8") + (root / "linked").symlink_to(root / "SKILL.md") + + with pytest.raises(ValueError, match="symlink"): + hash_plugin_skill_tree(root) diff --git a/app-instance/backend/tests/unit/test_plugin_manifest.py b/app-instance/backend/tests/unit/test_plugin_manifest.py new file mode 100644 index 0000000..d836d7c --- /dev/null +++ b/app-instance/backend/tests/unit/test_plugin_manifest.py @@ -0,0 +1,160 @@ +from __future__ import annotations + +import json +from pathlib import Path + +import pytest + +from beaver.plugins.manifest import load_plugin_manifest + + +def _write_manifest(root: Path, payload: dict) -> Path: + path = root / "beaver.plugin.json" + path.write_text(json.dumps(payload), encoding="utf-8") + return path + + +def test_load_plugin_manifest_accepts_declared_skill(tmp_path: Path) -> None: + root = tmp_path / "comic" + (root / "skills" / "comic").mkdir(parents=True) + (root / "skills" / "comic" / "SKILL.md").write_text("# Comic\n", encoding="utf-8") + _write_manifest( + root, + { + "schema_version": 1, + "id": "baoyu-comic", + "name": "Baoyu Comic", + "version": "1.2.0", + "skills": [{"name": "baoyu-comic", "path": "skills/comic"}], + }, + ) + + manifest = load_plugin_manifest(root / "beaver.plugin.json") + + assert manifest.plugin_id == "baoyu-comic" + assert manifest.name == "Baoyu Comic" + assert manifest.version == "1.2.0" + assert manifest.display_path == "comic/beaver.plugin.json" + assert manifest.skills[0].name == "baoyu-comic" + assert manifest.skills[0].relative_path == "skills/comic" + assert manifest.skills[0].root == root / "skills" / "comic" + + +@pytest.mark.parametrize("value", ["../outside", "/absolute", "skills/../../outside"]) +def test_load_plugin_manifest_rejects_escaping_skill_path(tmp_path: Path, value: str) -> None: + root = tmp_path / "unsafe" + root.mkdir() + path = _write_manifest( + root, + { + "schema_version": 1, + "id": "unsafe", + "name": "Unsafe", + "version": "1.0.0", + "skills": [{"name": "unsafe", "path": value}], + }, + ) + + with pytest.raises(ValueError, match="contained"): + load_plugin_manifest(path) + + +@pytest.mark.parametrize("identifier", ["BadName", "-bad", "bad.name", ""]) +def test_load_plugin_manifest_rejects_invalid_identifiers(tmp_path: Path, identifier: str) -> None: + root = tmp_path / "bad" + (root / "skills" / "skill").mkdir(parents=True) + (root / "skills" / "skill" / "SKILL.md").write_text("# Skill\n", encoding="utf-8") + path = _write_manifest( + root, + { + "schema_version": 1, + "id": identifier, + "name": "Bad", + "version": "1.0.0", + "skills": [{"name": "good-skill", "path": "skills/skill"}], + }, + ) + + with pytest.raises(ValueError, match="identifier"): + load_plugin_manifest(path) + + +def test_load_plugin_manifest_rejects_duplicate_skill_names(tmp_path: Path) -> None: + root = tmp_path / "dupe" + for dirname in ("one", "two"): + (root / "skills" / dirname).mkdir(parents=True) + (root / "skills" / dirname / "SKILL.md").write_text("# Skill\n", encoding="utf-8") + path = _write_manifest( + root, + { + "schema_version": 1, + "id": "dupe", + "name": "Duplicate", + "version": "1.0.0", + "skills": [ + {"name": "same", "path": "skills/one"}, + {"name": "same", "path": "skills/two"}, + ], + }, + ) + + with pytest.raises(ValueError, match="duplicate"): + load_plugin_manifest(path) + + +def test_load_plugin_manifest_rejects_unsupported_schema_version(tmp_path: Path) -> None: + root = tmp_path / "future" + root.mkdir() + path = _write_manifest( + root, + { + "schema_version": 2, + "id": "future", + "name": "Future", + "version": "2.0.0", + "skills": [], + }, + ) + + with pytest.raises(ValueError, match="schema"): + load_plugin_manifest(path) + + +def test_load_plugin_manifest_requires_skill_md(tmp_path: Path) -> None: + root = tmp_path / "missing" + (root / "skills" / "missing").mkdir(parents=True) + path = _write_manifest( + root, + { + "schema_version": 1, + "id": "missing", + "name": "Missing", + "version": "1.0.0", + "skills": [{"name": "missing", "path": "skills/missing"}], + }, + ) + + with pytest.raises(ValueError, match="SKILL.md"): + load_plugin_manifest(path) + + +def test_load_plugin_manifest_rejects_symlinked_skill_root(tmp_path: Path) -> None: + root = tmp_path / "linked" + real = root / "real" + real.mkdir(parents=True) + (real / "SKILL.md").write_text("# Linked\n", encoding="utf-8") + (root / "skills").mkdir() + (root / "skills" / "linked").symlink_to(real, target_is_directory=True) + path = _write_manifest( + root, + { + "schema_version": 1, + "id": "linked", + "name": "Linked", + "version": "1.0.0", + "skills": [{"name": "linked", "path": "skills/linked"}], + }, + ) + + with pytest.raises(ValueError, match="symlink"): + load_plugin_manifest(path) diff --git a/app-instance/backend/tests/unit/test_plugin_state.py b/app-instance/backend/tests/unit/test_plugin_state.py new file mode 100644 index 0000000..7bcf074 --- /dev/null +++ b/app-instance/backend/tests/unit/test_plugin_state.py @@ -0,0 +1,143 @@ +from __future__ import annotations + +import json +from pathlib import Path + +from beaver.plugins.discovery import discover_plugins +from beaver.plugins.models import PluginSkillBinding, PluginState +from beaver.plugins.state import PluginStateStore + + +def _create_plugin(root: Path, plugin_id: str, *, version: str = "1.0.0") -> Path: + plugin_root = root / plugin_id + skill_root = plugin_root / "skills" / plugin_id + skill_root.mkdir(parents=True) + (skill_root / "SKILL.md").write_text(f"# {plugin_id}\n", encoding="utf-8") + (plugin_root / "beaver.plugin.json").write_text( + json.dumps( + { + "schema_version": 1, + "id": plugin_id, + "name": plugin_id.title(), + "version": version, + "skills": [{"name": plugin_id, "path": f"skills/{plugin_id}"}], + } + ), + encoding="utf-8", + ) + return plugin_root + + +def test_plugin_state_round_trip_is_atomic(tmp_path: Path) -> None: + store = PluginStateStore(tmp_path) + store.set_enabled("baoyu-comic", True) + store.update_skill_binding( + "baoyu-comic", + "baoyu-comic", + PluginSkillBinding( + accepted_upstream_tree_hash="old", + observed_upstream_tree_hash="new", + accepted_beaver_version="v0001", + current_beaver_version="v0002", + pending_candidate_id="plugin-update:baoyu-comic:baoyu-comic:new", + status="update_pending", + ), + ) + + reloaded = PluginStateStore(tmp_path).get_plugin("baoyu-comic") + + assert reloaded is not None + assert reloaded.enabled is True + assert reloaded.skills["baoyu-comic"].accepted_upstream_tree_hash == "old" + assert not (tmp_path / ".beaver" / "plugins" / "state.json.tmp").exists() + + +def test_plugin_state_preserves_unknown_legacy_fields(tmp_path: Path) -> None: + state_path = tmp_path / ".beaver" / "plugins" / "state.json" + state_path.parent.mkdir(parents=True) + state_path.write_text( + json.dumps( + { + "plugins": { + "legacy": { + "enabled": True, + "installed_version": "1.0.0", + "skills": {"legacy": {"status": "synced", "extra": "ignored"}}, + "extra": "ignored", + } + } + } + ), + encoding="utf-8", + ) + + plugin = PluginStateStore(tmp_path).get_plugin("legacy") + + assert plugin is not None + assert plugin.enabled is True + assert plugin.skills["legacy"].status == "synced" + + +def test_discover_plugins_scans_workspace_plugins_and_external_roots(tmp_path: Path) -> None: + workspace = tmp_path / "workspace" + external = tmp_path / "external" + _create_plugin(workspace / "plugins", "workspace-plugin") + _create_plugin(external, "external-plugin") + + result = discover_plugins(workspace, search_paths=[external]) + + assert sorted(result.manifests) == ["external-plugin", "workspace-plugin"] + assert result.manifests["workspace-plugin"].display_path == "plugins/workspace-plugin/beaver.plugin.json" + assert result.manifests["external-plugin"].display_path == "/external-plugin/beaver.plugin.json" + assert result.errors == [] + + +def test_discover_plugins_reports_malformed_manifest_without_crashing(tmp_path: Path) -> None: + workspace = tmp_path / "workspace" + _create_plugin(workspace / "plugins", "valid") + broken = workspace / "plugins" / "broken" + broken.mkdir(parents=True) + (broken / "beaver.plugin.json").write_text("{not json", encoding="utf-8") + + result = discover_plugins(workspace, search_paths=[]) + + assert sorted(result.manifests) == ["valid"] + assert len(result.errors) == 1 + assert result.errors[0].plugin_id is None + assert "broken" in result.errors[0].display_path + + +def test_discover_plugins_reports_duplicate_ids_and_activates_neither(tmp_path: Path) -> None: + workspace = tmp_path / "workspace" + external = tmp_path / "external" + _create_plugin(workspace / "plugins", "dupe") + _create_plugin(external, "dupe", version="2.0.0") + + result = discover_plugins(workspace, search_paths=[external]) + + assert result.manifests == {} + assert len(result.errors) == 2 + assert {error.plugin_id for error in result.errors} == {"dupe"} + + +def test_plugin_state_upsert_round_trips_full_state(tmp_path: Path) -> None: + store = PluginStateStore(tmp_path) + store.upsert_plugin( + PluginState( + plugin_id="baoyu-comic", + enabled=True, + updates_paused=True, + installed_version="1.2.0", + manifest_path="plugins/baoyu-comic/beaver.plugin.json", + status="synced", + skills={"baoyu-comic": PluginSkillBinding(status="synced")}, + ) + ) + + plugin = PluginStateStore(tmp_path).get_plugin("baoyu-comic") + + assert plugin is not None + assert plugin.updates_paused is True + assert plugin.installed_version == "1.2.0" + assert plugin.manifest_path == "plugins/baoyu-comic/beaver.plugin.json" + assert plugin.skills["baoyu-comic"].status == "synced" diff --git a/app-instance/backend/tests/unit/test_workspace_write_lock.py b/app-instance/backend/tests/unit/test_workspace_write_lock.py new file mode 100644 index 0000000..22d93d7 --- /dev/null +++ b/app-instance/backend/tests/unit/test_workspace_write_lock.py @@ -0,0 +1,64 @@ +from __future__ import annotations + +import multiprocessing as mp +import time +from pathlib import Path + +from beaver.foundation.utils.file_lock import WorkspaceWriteLock, WorkspaceWriteLockBusy + + +def _lock_worker(workspace: str, queue: "mp.Queue[tuple[str, float]]", hold_seconds: float) -> None: + lock = WorkspaceWriteLock(workspace) + with lock.acquire(timeout_seconds=2): + queue.put(("enter", time.monotonic())) + time.sleep(hold_seconds) + queue.put(("exit", time.monotonic())) + + +def _nonblocking_worker(workspace: str, queue: "mp.Queue[str]") -> None: + lock = WorkspaceWriteLock(workspace) + try: + with lock.acquire(blocking=False): + queue.put("acquired") + except WorkspaceWriteLockBusy: + queue.put("busy") + + +def test_workspace_write_lock_is_reentrant(tmp_path: Path) -> None: + lock = WorkspaceWriteLock(tmp_path) + + with lock.acquire(timeout_seconds=1): + with lock.acquire(timeout_seconds=1): + assert lock.path.exists() + + +def test_workspace_write_lock_serializes_processes(tmp_path: Path) -> None: + queue: mp.Queue[tuple[str, float]] = mp.Queue() + first = mp.Process(target=_lock_worker, args=(str(tmp_path), queue, 0.25)) + second = mp.Process(target=_lock_worker, args=(str(tmp_path), queue, 0.01)) + + first.start() + time.sleep(0.05) + second.start() + events = [queue.get(timeout=3) for _ in range(4)] + first.join(timeout=3) + second.join(timeout=3) + + assert first.exitcode == 0 + assert second.exitcode == 0 + assert [event for event, _timestamp in events] == ["enter", "exit", "enter", "exit"] + assert events[1][1] <= events[2][1] + + +def test_workspace_write_lock_nonblocking_reports_busy(tmp_path: Path) -> None: + lock = WorkspaceWriteLock(tmp_path) + queue: mp.Queue[str] = mp.Queue() + + with lock.acquire(timeout_seconds=1): + process = mp.Process(target=_nonblocking_worker, args=(str(tmp_path), queue)) + process.start() + result = queue.get(timeout=3) + process.join(timeout=3) + + assert process.exitcode == 0 + assert result == "busy"