"""Manifest-based Addon loader for Odysseus. Addons are intentionally declarative in this first layer. They can describe capabilities and contribute prompt/UI metadata, but Odysseus does not execute arbitrary addon code automatically. """ from __future__ import annotations import json import os import re import shutil import uuid from pathlib import Path from typing import Any, Dict, List, Optional from src.constants import ADDONS_DIR, ADDONS_FILE ADDON_SCHEMA_VERSION = 1 _ID_RE = re.compile(r"^[a-z0-9][a-z0-9_.-]{1,63}$") _ALLOWED_CONTRIBUTIONS = { "agent_prompt", "tool_hint", "settings_panel", "ui_panel", "api_proxy", "skill_template", } def _atomic_write_json(path: str, data: Any, *, indent: Optional[int] = None) -> None: os.makedirs(os.path.dirname(path) or ".", exist_ok=True) tmp = f"{path}.tmp.{os.getpid()}" with open(tmp, "w", encoding="utf-8") as f: json.dump(data, f, indent=indent, ensure_ascii=False) f.flush() os.fsync(f.fileno()) os.replace(tmp, path) def _safe_addon_id(value: Any) -> str: raw = str(value or "").strip().lower() if "/" in raw or "\\" in raw or ".." in raw: raise ValueError("Addon id must not contain path separators") addon_id = raw addon_id = re.sub(r"[^a-z0-9_.-]+", "-", addon_id).strip(".-_") if not addon_id: addon_id = f"addon-{uuid.uuid4().hex[:8]}" if not _ID_RE.match(addon_id): raise ValueError("Addon id must be 2-64 chars and use a-z, 0-9, dot, dash, or underscore") return addon_id def _addon_dir(addon_id: str) -> Path: safe_id = _safe_addon_id(addon_id) base = Path(ADDONS_DIR).resolve() path = (base / safe_id).resolve() if base not in path.parents and path != base: raise ValueError("Invalid addon path") return path def _default_manifest(addon_id: str) -> Dict[str, Any]: return { "schema_version": ADDON_SCHEMA_VERSION, "id": addon_id, "name": addon_id.replace("-", " ").replace("_", " ").title(), "version": "0.1.0", "description": "", "author": "", "enabled": False, "entrypoints": {}, "contributions": [], "permissions": [], } def _normalize_contribution(item: Any) -> Optional[Dict[str, Any]]: if not isinstance(item, dict): return None kind = str(item.get("type") or item.get("kind") or "").strip() if kind not in _ALLOWED_CONTRIBUTIONS: return None normalized = dict(item) normalized["type"] = kind if "id" in normalized: normalized["id"] = str(normalized["id"])[:80] return normalized def normalize_manifest(manifest: Dict[str, Any], *, existing_enabled: Optional[bool] = None) -> Dict[str, Any]: if not isinstance(manifest, dict): raise ValueError("Addon manifest must be a JSON object") if not manifest.get("id") and not manifest.get("name"): raise ValueError("Addon manifest requires id or name") addon_id = _safe_addon_id(manifest.get("id") or manifest.get("name")) normalized = _default_manifest(addon_id) normalized.update({ "schema_version": int(manifest.get("schema_version") or ADDON_SCHEMA_VERSION), "id": addon_id, "name": str(manifest.get("name") or normalized["name"])[:120], "version": str(manifest.get("version") or "0.1.0")[:40], "description": str(manifest.get("description") or "")[:1000], "author": str(manifest.get("author") or "")[:120], "enabled": bool(existing_enabled if existing_enabled is not None else manifest.get("enabled", False)), "entrypoints": manifest.get("entrypoints") if isinstance(manifest.get("entrypoints"), dict) else {}, "permissions": manifest.get("permissions") if isinstance(manifest.get("permissions"), list) else [], }) contributions = manifest.get("contributions") if isinstance(manifest.get("contributions"), list) else [] normalized["contributions"] = [ c for c in (_normalize_contribution(item) for item in contributions) if c is not None ] return normalized def _load_registry() -> Dict[str, Dict[str, Any]]: if not os.path.exists(ADDONS_FILE): return {} try: with open(ADDONS_FILE, "r", encoding="utf-8") as f: raw = json.load(f) except (OSError, json.JSONDecodeError): return {} items = raw.get("addons", raw) if isinstance(raw, dict) else raw if not isinstance(items, list): return {} registry: Dict[str, Dict[str, Any]] = {} for item in items: try: addon = normalize_manifest(item) registry[addon["id"]] = addon except Exception: continue return registry def _save_registry(registry: Dict[str, Dict[str, Any]]) -> None: _atomic_write_json(ADDONS_FILE, {"addons": list(registry.values())}, indent=2) def scan_addons() -> List[Dict[str, Any]]: """Load addon manifests from disk and merge stored enabled state.""" os.makedirs(ADDONS_DIR, exist_ok=True) registry = _load_registry() changed = False for child in sorted(Path(ADDONS_DIR).iterdir()): if not child.is_dir(): continue manifest_path = child / "addon.json" if not manifest_path.exists(): continue try: with open(manifest_path, "r", encoding="utf-8") as f: manifest = json.load(f) addon_id = _safe_addon_id(manifest.get("id") or child.name) enabled = registry.get(addon_id, {}).get("enabled") addon = normalize_manifest({**manifest, "id": addon_id}, existing_enabled=enabled) addon["path"] = str(manifest_path) registry[addon_id] = addon changed = True except Exception as exc: registry[child.name] = { **_default_manifest(child.name), "name": child.name, "enabled": False, "error": str(exc), "path": str(manifest_path), } changed = True if changed: _save_registry(registry) return list(registry.values()) def list_addons() -> List[Dict[str, Any]]: return sorted(scan_addons(), key=lambda item: item.get("name", "").lower()) def get_addon(addon_id: str) -> Optional[Dict[str, Any]]: safe_id = _safe_addon_id(addon_id) for item in list_addons(): if item.get("id") == safe_id: return item return None def save_addon_manifest(manifest: Dict[str, Any]) -> Dict[str, Any]: registry = _load_registry() if not manifest.get("id") and not manifest.get("name"): raise ValueError("Addon manifest requires id or name") existing = registry.get(_safe_addon_id(manifest.get("id") or manifest.get("name"))) addon = normalize_manifest(manifest, existing_enabled=existing.get("enabled") if existing else None) addon_dir = _addon_dir(addon["id"]) addon_dir.mkdir(parents=True, exist_ok=True) manifest_path = addon_dir / "addon.json" _atomic_write_json(str(manifest_path), addon, indent=2) addon["path"] = str(manifest_path) registry[addon["id"]] = addon _save_registry(registry) return addon def set_addon_enabled(addon_id: str, enabled: bool) -> Dict[str, Any]: registry = _load_registry() addon = get_addon(addon_id) if not addon: raise KeyError(addon_id) addon["enabled"] = bool(enabled) registry[addon["id"]] = addon _save_registry(registry) manifest_path = _addon_dir(addon["id"]) / "addon.json" if manifest_path.exists(): persisted = dict(addon) persisted.pop("path", None) _atomic_write_json(str(manifest_path), persisted, indent=2) return addon def delete_addon(addon_id: str) -> bool: safe_id = _safe_addon_id(addon_id) registry = _load_registry() existed = safe_id in registry registry.pop(safe_id, None) _save_registry(registry) addon_dir = _addon_dir(safe_id) if addon_dir.exists(): shutil.rmtree(addon_dir) existed = True return existed def addon_template(addon_id: str = "my-addon") -> Dict[str, Any]: safe_id = _safe_addon_id(addon_id) manifest = _default_manifest(safe_id) manifest.update({ "name": safe_id.replace("-", " ").title(), "description": "Describe what this addon changes or adds.", "contributions": [ { "type": "agent_prompt", "id": f"{safe_id}.guidance", "text": "Short, specific guidance the agent may use when this addon is enabled.", }, { "type": "settings_panel", "id": f"{safe_id}.settings", "title": "Addon Settings", "description": "Describe configurable options for this addon.", }, ], }) return manifest def get_enabled_addon_contributions() -> List[Dict[str, Any]]: contributions: List[Dict[str, Any]] = [] for addon in list_addons(): if not addon.get("enabled") or addon.get("error"): continue for contribution in addon.get("contributions", []): item = dict(contribution) item["addon_id"] = addon["id"] item["addon_name"] = addon.get("name") or addon["id"] contributions.append(item) return contributions def get_addons_prompt() -> str: lines: List[str] = [] for item in get_enabled_addon_contributions(): if item.get("type") not in {"agent_prompt", "tool_hint", "skill_template"}: continue text = str(item.get("text") or item.get("description") or "").strip() if not text: continue lines.append(f"- {item.get('addon_name')} ({item.get('type')}): {text[:1200]}") if not lines: return "" return "Enabled Odysseus addons may contribute these untrusted instructions or hints:\n" + "\n".join(lines)