feat(plugins): discover packages and persist state
This commit is contained in:
@ -8,6 +8,7 @@ from .schema import (
|
|||||||
BeaverConfig,
|
BeaverConfig,
|
||||||
EmbeddingConfig,
|
EmbeddingConfig,
|
||||||
MCPServerConfig,
|
MCPServerConfig,
|
||||||
|
PluginsConfig,
|
||||||
ProviderConfig,
|
ProviderConfig,
|
||||||
ToolsConfig,
|
ToolsConfig,
|
||||||
)
|
)
|
||||||
@ -19,6 +20,7 @@ __all__ = [
|
|||||||
"BeaverConfig",
|
"BeaverConfig",
|
||||||
"EmbeddingConfig",
|
"EmbeddingConfig",
|
||||||
"MCPServerConfig",
|
"MCPServerConfig",
|
||||||
|
"PluginsConfig",
|
||||||
"ProviderConfig",
|
"ProviderConfig",
|
||||||
"ToolsConfig",
|
"ToolsConfig",
|
||||||
"default_config_path",
|
"default_config_path",
|
||||||
|
|||||||
@ -16,6 +16,7 @@ from .schema import (
|
|||||||
ChannelConfig,
|
ChannelConfig,
|
||||||
EmbeddingConfig,
|
EmbeddingConfig,
|
||||||
MCPServerConfig,
|
MCPServerConfig,
|
||||||
|
PluginsConfig,
|
||||||
ProviderConfig,
|
ProviderConfig,
|
||||||
ToolsConfig,
|
ToolsConfig,
|
||||||
)
|
)
|
||||||
@ -73,6 +74,7 @@ def load_config(
|
|||||||
providers=_parse_providers(data.get("providers")),
|
providers=_parse_providers(data.get("providers")),
|
||||||
embedding=_parse_embedding(data),
|
embedding=_parse_embedding(data),
|
||||||
tools=_parse_tools(data.get("tools")),
|
tools=_parse_tools(data.get("tools")),
|
||||||
|
plugins=_parse_plugins(data.get("plugins")),
|
||||||
authz=_parse_authz(data.get("authz")),
|
authz=_parse_authz(data.get("authz")),
|
||||||
channels=_parse_channels(data.get("channels")),
|
channels=_parse_channels(data.get("channels")),
|
||||||
backend_identity=_parse_backend_identity(data.get("backend_identity") or data.get("backendIdentity")),
|
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:
|
def _parse_authz(raw: Any) -> AuthzConfig:
|
||||||
data = _as_dict(raw)
|
data = _as_dict(raw)
|
||||||
return AuthzConfig(
|
return AuthzConfig(
|
||||||
|
|||||||
@ -81,6 +81,14 @@ class ToolsConfig:
|
|||||||
mcp_servers: dict[str, MCPServerConfig] = field(default_factory=dict)
|
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)
|
@dataclass(slots=True)
|
||||||
class AuthzConfig:
|
class AuthzConfig:
|
||||||
"""External AuthZ service configuration."""
|
"""External AuthZ service configuration."""
|
||||||
@ -123,6 +131,7 @@ class BeaverConfig:
|
|||||||
providers: dict[str, ProviderConfig] = field(default_factory=dict)
|
providers: dict[str, ProviderConfig] = field(default_factory=dict)
|
||||||
embedding: EmbeddingConfig = field(default_factory=EmbeddingConfig)
|
embedding: EmbeddingConfig = field(default_factory=EmbeddingConfig)
|
||||||
tools: ToolsConfig = field(default_factory=ToolsConfig)
|
tools: ToolsConfig = field(default_factory=ToolsConfig)
|
||||||
|
plugins: PluginsConfig = field(default_factory=PluginsConfig)
|
||||||
authz: AuthzConfig = field(default_factory=AuthzConfig)
|
authz: AuthzConfig = field(default_factory=AuthzConfig)
|
||||||
channels: dict[str, ChannelConfig] = field(default_factory=dict)
|
channels: dict[str, ChannelConfig] = field(default_factory=dict)
|
||||||
backend_identity: BackendIdentityConfig = field(default_factory=BackendIdentityConfig)
|
backend_identity: BackendIdentityConfig = field(default_factory=BackendIdentityConfig)
|
||||||
|
|||||||
111
app-instance/backend/beaver/foundation/utils/file_lock.py
Normal file
111
app-instance/backend/beaver/foundation/utils/file_lock.py
Normal file
@ -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]
|
||||||
29
app-instance/backend/beaver/plugins/__init__.py
Normal file
29
app-instance/backend/beaver/plugins/__init__.py
Normal file
@ -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",
|
||||||
|
]
|
||||||
74
app-instance/backend/beaver/plugins/discovery.py
Normal file
74
app-instance/backend/beaver/plugins/discovery.py
Normal file
@ -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"<external>/{resolved.parent.name}/{resolved.name}"
|
||||||
78
app-instance/backend/beaver/plugins/hashing.py
Normal file
78
app-instance/backend/beaver/plugins/hashing.py
Normal file
@ -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)
|
||||||
106
app-instance/backend/beaver/plugins/manifest.py
Normal file
106
app-instance/backend/beaver/plugins/manifest.py
Normal file
@ -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"<external>/{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
|
||||||
137
app-instance/backend/beaver/plugins/models.py
Normal file
137
app-instance/backend/beaver/plugins/models.py
Normal file
@ -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)
|
||||||
78
app-instance/backend/beaver/plugins/state.py
Normal file
78
app-instance/backend/beaver/plugins/state.py
Normal file
@ -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)
|
||||||
@ -47,6 +47,46 @@ def test_load_config_reads_current_instance_shape(tmp_path) -> None:
|
|||||||
assert target["extra_headers"] == {"X-Test": "1"}
|
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:
|
def test_config_loader_reads_channels(tmp_path) -> None:
|
||||||
config_path = tmp_path / "config.json"
|
config_path = tmp_path / "config.json"
|
||||||
config_path.write_text(
|
config_path.write_text(
|
||||||
|
|||||||
83
app-instance/backend/tests/unit/test_plugin_hashing.py
Normal file
83
app-instance/backend/tests/unit/test_plugin_hashing.py
Normal file
@ -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)
|
||||||
160
app-instance/backend/tests/unit/test_plugin_manifest.py
Normal file
160
app-instance/backend/tests/unit/test_plugin_manifest.py
Normal file
@ -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)
|
||||||
143
app-instance/backend/tests/unit/test_plugin_state.py
Normal file
143
app-instance/backend/tests/unit/test_plugin_state.py
Normal file
@ -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>/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"
|
||||||
64
app-instance/backend/tests/unit/test_workspace_write_lock.py
Normal file
64
app-instance/backend/tests/unit/test_workspace_write_lock.py
Normal file
@ -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"
|
||||||
Reference in New Issue
Block a user