diff --git a/app.py b/app.py index a89f801..c3794dd 100644 --- a/app.py +++ b/app.py @@ -661,6 +661,10 @@ app.include_router(setup_session_routes(session_manager, session_config, webhook from routes.admin_wipe_routes import setup_admin_wipe_routes app.include_router(setup_admin_wipe_routes(session_manager)) +# Addons / manifest modloader +from routes.addon_routes import setup_addon_routes +app.include_router(setup_addon_routes(auth_manager)) + # Memory from routes.memory.memory_routes import setup_memory_routes memory_router = setup_memory_routes(memory_manager, session_manager, memory_vector=memory_vector) diff --git a/routes/addon_routes.py b/routes/addon_routes.py new file mode 100644 index 0000000..e8b6216 --- /dev/null +++ b/routes/addon_routes.py @@ -0,0 +1,104 @@ +"""Admin API for Odysseus manifest addons.""" + +from fastapi import APIRouter, HTTPException, Request +from pydantic import BaseModel, Field + +from src.addons import ( + addon_template, + delete_addon, + get_addon, + get_enabled_addon_contributions, + list_addons, + save_addon_manifest, + set_addon_enabled, +) + + +class AddonManifestRequest(BaseModel): + manifest: dict = Field(default_factory=dict) + + +class AddonEnabledRequest(BaseModel): + enabled: bool + + +def setup_addon_routes(auth_manager) -> APIRouter: + router = APIRouter(prefix="/api/addons", tags=["addons"]) + + def _require_admin(request: Request) -> str: + token = request.cookies.get("odysseus_session") + user = auth_manager.get_username_for_token(token) + if not user: + raise HTTPException(401, "Not authenticated") + if not auth_manager.is_admin(user): + raise HTTPException(403, "Admin only") + return user + + @router.get("") + async def list_all(request: Request): + _require_admin(request) + return { + "addons": list_addons(), + "enabled_contributions": get_enabled_addon_contributions(), + } + + @router.get("/template") + async def get_template(request: Request, addon_id: str = "my-addon"): + _require_admin(request) + try: + return {"manifest": addon_template(addon_id)} + except ValueError as exc: + raise HTTPException(400, str(exc)) from exc + + @router.post("") + async def create_or_update(body: AddonManifestRequest, request: Request): + _require_admin(request) + try: + addon = save_addon_manifest(body.manifest) + except ValueError as exc: + raise HTTPException(400, str(exc)) from exc + return {"ok": True, "addon": addon} + + @router.get("/{addon_id}") + async def get_one(addon_id: str, request: Request): + _require_admin(request) + try: + addon = get_addon(addon_id) + except ValueError as exc: + raise HTTPException(400, str(exc)) from exc + if not addon: + raise HTTPException(404, "Addon not found") + return {"addon": addon} + + @router.patch("/{addon_id}") + async def update_one(addon_id: str, body: AddonManifestRequest, request: Request): + _require_admin(request) + manifest = dict(body.manifest or {}) + manifest["id"] = addon_id + try: + addon = save_addon_manifest(manifest) + except ValueError as exc: + raise HTTPException(400, str(exc)) from exc + return {"ok": True, "addon": addon} + + @router.post("/{addon_id}/enabled") + async def set_enabled(addon_id: str, body: AddonEnabledRequest, request: Request): + _require_admin(request) + try: + addon = set_addon_enabled(addon_id, body.enabled) + except KeyError as exc: + raise HTTPException(404, "Addon not found") from exc + except ValueError as exc: + raise HTTPException(400, str(exc)) from exc + return {"ok": True, "addon": addon} + + @router.delete("/{addon_id}") + async def delete_one(addon_id: str, request: Request): + _require_admin(request) + try: + deleted = delete_addon(addon_id) + except ValueError as exc: + raise HTTPException(400, str(exc)) from exc + return {"ok": deleted} + + return router diff --git a/src/addons.py b/src/addons.py new file mode 100644 index 0000000..a30c796 --- /dev/null +++ b/src/addons.py @@ -0,0 +1,280 @@ +"""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) diff --git a/src/agent_loop.py b/src/agent_loop.py index ebc2a99..810a25b 100644 --- a/src/agent_loop.py +++ b/src/agent_loop.py @@ -2005,6 +2005,18 @@ def _build_system_prompt( except Exception as _integ_err: logger.debug(f"Integration prompt injection skipped: {_integ_err}") + try: + from src.addons import get_addons_prompt + _addons_prompt = get_addons_prompt() + if _addons_prompt: + _integ_message = untrusted_context_message( + "integrations and addons", + ((_integ_message or {}).get("content", "") + "\n\n" + _addons_prompt).strip() + if _integ_message else _addons_prompt, + ) + except Exception as _addon_err: + logger.debug(f"Addon prompt injection skipped: {_addon_err}") + # MCP tool descriptions — sourced from external servers, must not be in system role. if mcp_mgr: try: diff --git a/src/constants.py b/src/constants.py index eceeb6e..c38f190 100644 --- a/src/constants.py +++ b/src/constants.py @@ -27,6 +27,7 @@ AUTH_FILE = os.path.join(DATA_DIR, "auth.json") USER_PREFS_FILE = os.path.join(DATA_DIR, "user_prefs.json") PRESETS_FILE = os.path.join(DATA_DIR, "presets.json") INTEGRATIONS_FILE = os.path.join(DATA_DIR, "integrations.json") +ADDONS_FILE = os.path.join(DATA_DIR, "addons.json") CONTACTS_FILE = os.path.join(DATA_DIR, "contacts.json") APP_KEY_FILE = os.path.join(DATA_DIR, ".app_key") EMBEDDING_ENDPOINT_FILE = os.path.join(DATA_DIR, "embedding_endpoint.json") @@ -51,6 +52,7 @@ GENERATED_IMAGES_DIR = os.path.join(DATA_DIR, "generated_images") TTS_CACHE_DIR = os.path.join(DATA_DIR, "tts_cache") EMAIL_URGENCY_CACHE_DIR = os.path.join(DATA_DIR, "email_urgency_cache") SKILLS_DIR = os.path.join(DATA_DIR, "skills") +ADDONS_DIR = os.path.join(DATA_DIR, "addons") GALLERY_DIR = os.path.join(DATA_DIR, "gallery") GALLERY_UPLOADS_DIR = os.path.join(DATA_DIR, "gallery_uploads") MEMORY_VECTORS_DIR = os.path.join(DATA_DIR, "memory_vectors") diff --git a/static/index.html b/static/index.html index cb30e84..898c747 100644 --- a/static/index.html +++ b/static/index.html @@ -1405,6 +1405,10 @@
Admin
+ + +
Manifest-based extensions loaded from data/addons/<id>/addon.json. Addons are declarative by default; Odysseus does not execute arbitrary addon code automatically.
+
Loading addons...
+ +
+

+ + Addon Builder +

+
Create or edit an addon manifest. This is the stable contract future addon code can build on.
+
+ + + + +
+ +
+
+ +