feat(plugins): discover packages and persist state

This commit is contained in:
2026-06-16 11:40:31 +08:00
parent 7020f2d67f
commit 41b45e0423
15 changed files with 1127 additions and 0 deletions

View File

@ -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",

View File

@ -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(

View File

@ -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)

View 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]

View 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",
]

View 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}"

View 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)

View 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

View 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)

View 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)