Compare commits
2 Commits
ec4ac6ef8e
...
aa1293323e
| Author | SHA1 | Date | |
|---|---|---|---|
| aa1293323e | |||
| caffdaa38c |
23
app.py
23
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)
|
||||
@@ -920,8 +924,23 @@ async def serve_login(request: Request):
|
||||
|
||||
@app.get("/api/version")
|
||||
async def get_version():
|
||||
from core.constants import APP_VERSION
|
||||
return {"version": APP_VERSION}
|
||||
from core.constants import (
|
||||
APP_VERSION,
|
||||
BUILD_FLAVOR,
|
||||
BUILD_REVISION,
|
||||
BUILD_VERSION,
|
||||
FULL_VERSION,
|
||||
UPSTREAM_VERSION,
|
||||
)
|
||||
return {
|
||||
"version": BUILD_VERSION,
|
||||
"upstream_version": UPSTREAM_VERSION,
|
||||
"app_version": APP_VERSION,
|
||||
"build_flavor": BUILD_FLAVOR,
|
||||
"build_revision": BUILD_REVISION,
|
||||
"build_version": BUILD_VERSION,
|
||||
"full_version": FULL_VERSION,
|
||||
}
|
||||
|
||||
@app.get("/api/health")
|
||||
async def health_check() -> Dict[str, str]:
|
||||
|
||||
36
docs/wilkensxl-build.md
Normal file
36
docs/wilkensxl-build.md
Normal file
@@ -0,0 +1,36 @@
|
||||
# WilkensXL build versioning
|
||||
|
||||
Odysseus keeps the upstream application version separate from the local
|
||||
WilkensXL build version.
|
||||
|
||||
- `APP_VERSION` and `UPSTREAM_VERSION` track the upstream release.
|
||||
- `BUILD_VERSION` and `FULL_VERSION` identify this custom image.
|
||||
- `/api/version` returns both values so Dockge, logs, and the UI can show which
|
||||
build is running.
|
||||
|
||||
The default custom build is:
|
||||
|
||||
```text
|
||||
1.0.1-wilkensxl.2
|
||||
```
|
||||
|
||||
The value can be overridden at runtime with:
|
||||
|
||||
- `ODYSSEUS_BUILD_FLAVOR`
|
||||
- `ODYSSEUS_BUILD_REVISION`
|
||||
- `ODYSSEUS_BUILD_VERSION`
|
||||
|
||||
## Update workflow
|
||||
|
||||
When upstream publishes a new Odysseus version:
|
||||
|
||||
1. Merge or rebase the upstream changes into the WilkensXL branch.
|
||||
2. Keep the addon loader small and stable.
|
||||
3. Put local behavior changes into addons wherever possible.
|
||||
4. Run the addon, integration, readiness, and version metadata tests.
|
||||
5. Bump `BUILD_REVISION` or set `ODYSSEUS_BUILD_VERSION` for the new image.
|
||||
|
||||
The loader does not remove all porting work, but it reduces it. Most local
|
||||
behavior should live behind addon manifests, so future upstream updates only
|
||||
need the loader compatibility checked plus any addons that touch changed
|
||||
internals.
|
||||
104
routes/addon_routes.py
Normal file
104
routes/addon_routes.py
Normal file
@@ -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
|
||||
280
src/addons.py
Normal file
280
src/addons.py
Normal file
@@ -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)
|
||||
@@ -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:
|
||||
|
||||
@@ -5,6 +5,14 @@ import os
|
||||
from src.runtime_paths import get_app_root, get_default_data_dir
|
||||
|
||||
APP_VERSION = "1.0.1"
|
||||
UPSTREAM_VERSION = APP_VERSION
|
||||
BUILD_FLAVOR = os.getenv("ODYSSEUS_BUILD_FLAVOR", "wilkensxl")
|
||||
BUILD_REVISION = os.getenv("ODYSSEUS_BUILD_REVISION", "2")
|
||||
BUILD_VERSION = os.getenv(
|
||||
"ODYSSEUS_BUILD_VERSION",
|
||||
f"{APP_VERSION}-{BUILD_FLAVOR}.{BUILD_REVISION}" if BUILD_FLAVOR else APP_VERSION,
|
||||
)
|
||||
FULL_VERSION = BUILD_VERSION
|
||||
|
||||
# Base paths
|
||||
BASE_DIR = os.path.join(get_app_root(), "")
|
||||
@@ -27,6 +35,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 +60,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")
|
||||
|
||||
@@ -19,7 +19,13 @@ def check_readiness() -> Dict[str, object]:
|
||||
``local_first`` is informational — a remote database is a valid deployment, so
|
||||
it never fails readiness, it only reports whether storage stays on this host.
|
||||
"""
|
||||
from core.constants import APP_VERSION, DATA_DIR
|
||||
from core.constants import (
|
||||
APP_VERSION,
|
||||
BUILD_FLAVOR,
|
||||
BUILD_VERSION,
|
||||
DATA_DIR,
|
||||
UPSTREAM_VERSION,
|
||||
)
|
||||
from core.database import DATABASE_URL, engine
|
||||
from sqlalchemy import text as sql_text
|
||||
|
||||
@@ -55,7 +61,11 @@ def check_readiness() -> Dict[str, object]:
|
||||
ready = all(bool(c.get("ok")) for c in checks.values())
|
||||
return {
|
||||
"ready": ready,
|
||||
"version": APP_VERSION,
|
||||
"version": BUILD_VERSION,
|
||||
"upstream_version": UPSTREAM_VERSION,
|
||||
"app_version": APP_VERSION,
|
||||
"build_flavor": BUILD_FLAVOR,
|
||||
"build_version": BUILD_VERSION,
|
||||
"checks": checks,
|
||||
"timestamp": datetime.utcnow().isoformat(),
|
||||
}
|
||||
|
||||
@@ -994,7 +994,10 @@
|
||||
el.textContent = 'Type /setup, then choose Local models or API.';
|
||||
}
|
||||
fetch('/api/version').then(function(r){return r.json()}).then(function(d){
|
||||
if (d.version) window._appVersion = d.version;
|
||||
var fullVersion = d.full_version || d.build_version || d.version;
|
||||
if (fullVersion) window._appVersion = fullVersion;
|
||||
if (d.upstream_version) window._upstreamVersion = d.upstream_version;
|
||||
if (d.build_flavor) window._buildFlavor = d.build_flavor;
|
||||
}).catch(function(){});
|
||||
})();
|
||||
</script>
|
||||
@@ -1405,6 +1408,10 @@
|
||||
</button>
|
||||
<div class="settings-sidebar-divider admin-only"></div>
|
||||
<div class="settings-sidebar-label admin-only">Admin</div>
|
||||
<button class="settings-nav-item admin-only" data-settings-tab="addons">
|
||||
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 2l3 6 6 .9-4.5 4.4 1.1 6.2L12 16.6 6.4 19.5l1.1-6.2L3 8.9 9 8z"/></svg>
|
||||
<span>Addons</span>
|
||||
</button>
|
||||
<button class="settings-nav-item admin-only" data-settings-tab="tools">
|
||||
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.77-3.77a6 6 0 0 1-7.94 7.94l-6.91 6.91a2.12 2.12 0 0 1-3-3l6.91-6.91a6 6 0 0 1 7.94-7.94l-3.76 3.76z"/></svg>
|
||||
<span>Agent Tools</span>
|
||||
@@ -2262,6 +2269,35 @@
|
||||
</div>
|
||||
|
||||
<!-- ═══ TOOLS TAB ═══ -->
|
||||
<!-- ADDONS TAB -->
|
||||
<div data-settings-panel="addons" class="hidden">
|
||||
<div class="admin-card">
|
||||
<h2 style="display:flex;align-items:center;gap:8px;">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="opacity:0.6"><path d="M12 2l3 6 6 .9-4.5 4.4 1.1 6.2L12 16.6 6.4 19.5l1.1-6.2L3 8.9 9 8z"/></svg>
|
||||
Addons
|
||||
<span style="flex:1"></span>
|
||||
<button class="admin-btn-sm" id="addons-refresh-btn" title="Rescan addons">Refresh</button>
|
||||
</h2>
|
||||
<div class="admin-toggle-sub" style="margin-bottom:12px">Manifest-based extensions loaded from <code>data/addons/<id>/addon.json</code>. Addons are declarative by default; Odysseus does not execute arbitrary addon code automatically.</div>
|
||||
<div id="addons-list" class="addons-list"><div class="admin-empty">Loading addons...</div></div>
|
||||
</div>
|
||||
<div class="admin-card">
|
||||
<h2 style="display:flex;align-items:center;gap:8px;">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="opacity:0.6"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><path d="M14 2v6h6"/><path d="M12 18v-6"/><path d="M9 15h6"/></svg>
|
||||
Addon Builder
|
||||
</h2>
|
||||
<div class="admin-toggle-sub" style="margin-bottom:10px">Create or edit an addon manifest. This is the stable contract future addon code can build on.</div>
|
||||
<div class="addon-builder-row">
|
||||
<input id="addon-template-id" class="settings-select" type="text" value="my-addon" aria-label="Addon id" />
|
||||
<button class="admin-btn-add" id="addon-template-btn">Load Template</button>
|
||||
<button class="admin-btn-add" id="addon-save-btn">Save Addon</button>
|
||||
<button class="admin-btn-sm" id="addon-clear-btn">Clear</button>
|
||||
</div>
|
||||
<textarea id="addon-manifest-editor" class="addon-manifest-editor" spellcheck="false" placeholder="{ "id": "my-addon", "name": "My Addon" }"></textarea>
|
||||
<div id="addons-msg" class="addon-msg"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div data-settings-panel="tools" class="hidden">
|
||||
<div class="admin-card" style="margin-bottom:12px;">
|
||||
<h2><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:-2px;margin-right:5px;opacity:0.6"><path d="M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.77-3.77a6 6 0 0 1-7.94 7.94l-6.91 6.91a2.12 2.12 0 0 1-3-3l6.91-6.91a6 6 0 0 1 7.94-7.94l-3.76 3.76z"/></svg>Agent</h2>
|
||||
|
||||
198
static/js/addons.js
Normal file
198
static/js/addons.js
Normal file
@@ -0,0 +1,198 @@
|
||||
// static/js/addons.js - Addon manager for manifest-based Odysseus addons
|
||||
|
||||
import uiModule from './ui.js';
|
||||
|
||||
let initialized = false;
|
||||
let currentManifest = null;
|
||||
|
||||
function el(id) { return document.getElementById(id); }
|
||||
function esc(value) {
|
||||
return uiModule && typeof uiModule.esc === 'function'
|
||||
? uiModule.esc(String(value ?? ''))
|
||||
: String(value ?? '').replace(/[&<>"']/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]));
|
||||
}
|
||||
|
||||
async function api(path, options = {}) {
|
||||
const res = await fetch(path, {
|
||||
credentials: 'same-origin',
|
||||
headers: { 'Content-Type': 'application/json', ...(options.headers || {}) },
|
||||
...options,
|
||||
});
|
||||
const data = await res.json().catch(() => ({}));
|
||||
if (!res.ok) throw new Error(data.detail || data.error || `HTTP ${res.status}`);
|
||||
return data;
|
||||
}
|
||||
|
||||
function contributionSummary(addon) {
|
||||
const items = Array.isArray(addon.contributions) ? addon.contributions : [];
|
||||
if (!items.length) return '<span class="addon-muted">No contributions</span>';
|
||||
const counts = new Map();
|
||||
items.forEach(item => counts.set(item.type || 'unknown', (counts.get(item.type || 'unknown') || 0) + 1));
|
||||
return Array.from(counts.entries())
|
||||
.map(([type, count]) => `<span class="addon-chip">${esc(type)}${count > 1 ? ` x${count}` : ''}</span>`)
|
||||
.join('');
|
||||
}
|
||||
|
||||
function renderAddons(addons) {
|
||||
const list = el('addons-list');
|
||||
if (!list) return;
|
||||
if (!addons.length) {
|
||||
list.innerHTML = '<div class="admin-empty">No addons installed yet.</div>';
|
||||
return;
|
||||
}
|
||||
list.innerHTML = addons.map(addon => `
|
||||
<div class="addon-card" data-addon-id="${esc(addon.id)}">
|
||||
<div class="addon-card-main">
|
||||
<div class="addon-card-title">
|
||||
<span class="addon-status ${addon.enabled ? 'enabled' : ''}"></span>
|
||||
<strong>${esc(addon.name || addon.id)}</strong>
|
||||
<span class="addon-version">${esc(addon.version || '0.1.0')}</span>
|
||||
</div>
|
||||
<div class="addon-desc">${esc(addon.description || 'No description')}</div>
|
||||
<div class="addon-meta">
|
||||
<span>${esc(addon.id)}</span>
|
||||
${addon.author ? `<span>${esc(addon.author)}</span>` : ''}
|
||||
${addon.error ? `<span class="addon-error">${esc(addon.error)}</span>` : ''}
|
||||
</div>
|
||||
<div class="addon-contribs">${contributionSummary(addon)}</div>
|
||||
</div>
|
||||
<div class="addon-card-actions">
|
||||
<button class="admin-btn-sm addon-edit-btn" data-addon-id="${esc(addon.id)}">Edit</button>
|
||||
<button class="admin-btn-sm addon-toggle-btn" data-addon-id="${esc(addon.id)}" data-enabled="${addon.enabled ? '0' : '1'}">${addon.enabled ? 'Disable' : 'Enable'}</button>
|
||||
<button class="admin-btn-sm danger addon-delete-btn" data-addon-id="${esc(addon.id)}">Delete</button>
|
||||
</div>
|
||||
</div>
|
||||
`).join('');
|
||||
}
|
||||
|
||||
function setMessage(message, kind = '') {
|
||||
const node = el('addons-msg');
|
||||
if (!node) return;
|
||||
node.textContent = message || '';
|
||||
node.className = kind ? `addon-msg ${kind}` : 'addon-msg';
|
||||
}
|
||||
|
||||
function setEditorManifest(manifest) {
|
||||
currentManifest = manifest || null;
|
||||
const editor = el('addon-manifest-editor');
|
||||
if (!editor) return;
|
||||
editor.value = JSON.stringify(manifest || {}, null, 2);
|
||||
}
|
||||
|
||||
async function loadAddons() {
|
||||
const list = el('addons-list');
|
||||
if (list) list.innerHTML = '<div class="admin-empty">Loading addons...</div>';
|
||||
try {
|
||||
const data = await api('/api/addons');
|
||||
renderAddons(Array.isArray(data.addons) ? data.addons : []);
|
||||
setMessage('');
|
||||
} catch (err) {
|
||||
if (list) list.innerHTML = `<div class="admin-empty addon-error">${esc(err.message)}</div>`;
|
||||
}
|
||||
}
|
||||
|
||||
async function loadTemplate() {
|
||||
const idInput = el('addon-template-id');
|
||||
const addonId = (idInput && idInput.value.trim()) || 'my-addon';
|
||||
const data = await api(`/api/addons/template?addon_id=${encodeURIComponent(addonId)}`);
|
||||
setEditorManifest(data.manifest);
|
||||
setMessage('Template loaded. Edit the manifest and save it.', 'ok');
|
||||
}
|
||||
|
||||
async function saveManifest() {
|
||||
const editor = el('addon-manifest-editor');
|
||||
if (!editor) return;
|
||||
let manifest;
|
||||
try {
|
||||
manifest = JSON.parse(editor.value || '{}');
|
||||
} catch (err) {
|
||||
setMessage(`Invalid JSON: ${err.message}`, 'error');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const data = await api('/api/addons', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ manifest }),
|
||||
});
|
||||
setEditorManifest(data.addon);
|
||||
setMessage('Addon saved.', 'ok');
|
||||
await loadAddons();
|
||||
} catch (err) {
|
||||
setMessage(err.message, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
async function editAddon(addonId) {
|
||||
try {
|
||||
const data = await api(`/api/addons/${encodeURIComponent(addonId)}`);
|
||||
const addon = { ...(data.addon || {}) };
|
||||
delete addon.path;
|
||||
setEditorManifest(addon);
|
||||
setMessage(`Editing ${addon.id}.`, 'ok');
|
||||
} catch (err) {
|
||||
setMessage(err.message, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
async function toggleAddon(addonId, enabled) {
|
||||
try {
|
||||
await api(`/api/addons/${encodeURIComponent(addonId)}/enabled`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ enabled }),
|
||||
});
|
||||
setMessage(enabled ? 'Addon enabled.' : 'Addon disabled.', 'ok');
|
||||
await loadAddons();
|
||||
} catch (err) {
|
||||
setMessage(err.message, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteAddon(addonId) {
|
||||
if (!confirm(`Delete addon "${addonId}"?`)) return;
|
||||
try {
|
||||
await api(`/api/addons/${encodeURIComponent(addonId)}`, { method: 'DELETE' });
|
||||
if (currentManifest && currentManifest.id === addonId) setEditorManifest(null);
|
||||
setMessage('Addon deleted.', 'ok');
|
||||
await loadAddons();
|
||||
} catch (err) {
|
||||
setMessage(err.message, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
function bindEvents() {
|
||||
const refresh = el('addons-refresh-btn');
|
||||
if (refresh) refresh.addEventListener('click', loadAddons);
|
||||
const template = el('addon-template-btn');
|
||||
if (template) template.addEventListener('click', () => loadTemplate().catch(err => setMessage(err.message, 'error')));
|
||||
const save = el('addon-save-btn');
|
||||
if (save) save.addEventListener('click', saveManifest);
|
||||
const clear = el('addon-clear-btn');
|
||||
if (clear) clear.addEventListener('click', () => {
|
||||
setEditorManifest(null);
|
||||
setMessage('');
|
||||
});
|
||||
const list = el('addons-list');
|
||||
if (list) {
|
||||
list.addEventListener('click', (event) => {
|
||||
const edit = event.target.closest('.addon-edit-btn');
|
||||
const toggle = event.target.closest('.addon-toggle-btn');
|
||||
const del = event.target.closest('.addon-delete-btn');
|
||||
if (edit) editAddon(edit.dataset.addonId);
|
||||
if (toggle) toggleAddon(toggle.dataset.addonId, toggle.dataset.enabled === '1');
|
||||
if (del) deleteAddon(del.dataset.addonId);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export function init() {
|
||||
if (initialized) return;
|
||||
initialized = true;
|
||||
bindEvents();
|
||||
}
|
||||
|
||||
export function open() {
|
||||
init();
|
||||
loadAddons();
|
||||
}
|
||||
|
||||
export default { init, open };
|
||||
@@ -9,6 +9,7 @@ import { sortModelIds } from './modelSort.js';
|
||||
import { providerLogo } from './providers.js';
|
||||
import { isAltGrEvent } from './platform.js';
|
||||
import { bindMenuDismiss } from './escMenuStack.js';
|
||||
import addonsModule from './addons.js';
|
||||
|
||||
let initialized = false;
|
||||
let modalEl = null;
|
||||
@@ -41,6 +42,7 @@ function initTabs() {
|
||||
document.body.classList.toggle('settings-appearance-open', tab === 'appearance');
|
||||
syncAppearanceOpacity(tab === 'appearance');
|
||||
if (tab === 'ai') refreshAiModelEndpoints();
|
||||
if (tab === 'addons') addonsModule.open();
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -2322,6 +2324,7 @@ function initAll() {
|
||||
initClose();
|
||||
initOpenPromptModalLink();
|
||||
initOpacityToggle();
|
||||
addonsModule.init();
|
||||
initialized = true;
|
||||
initDefaultChat();
|
||||
initTeacherModel();
|
||||
@@ -5731,6 +5734,7 @@ export function open(tab) {
|
||||
document.body.classList.toggle('settings-appearance-open', activeTab === 'appearance');
|
||||
syncAppearanceOpacity(activeTab === 'appearance');
|
||||
if (activeTab === 'ai') refreshAiModelEndpoints();
|
||||
if (activeTab === 'addons') addonsModule.open();
|
||||
if (ADMIN_TABS.has(activeTab) && window.adminModule && !window.adminModule._initialized) {
|
||||
window.adminModule._initData();
|
||||
}
|
||||
|
||||
@@ -305,7 +305,11 @@
|
||||
const vr = await fetch('/api/version');
|
||||
if (vr.ok) {
|
||||
const vd = await vr.json();
|
||||
document.getElementById('version-label').textContent = 'v' + vd.version;
|
||||
const version = vd.full_version || vd.build_version || vd.version;
|
||||
const upstream = vd.upstream_version && vd.upstream_version !== version
|
||||
? ' (upstream ' + vd.upstream_version + ')'
|
||||
: '';
|
||||
document.getElementById('version-label').textContent = version ? 'v' + version + upstream : '';
|
||||
}
|
||||
} catch(e) {}
|
||||
|
||||
|
||||
120
static/style.css
120
static/style.css
@@ -40439,6 +40439,126 @@ body.theme-frosted .modal {
|
||||
color: var(--fg, #9cdef2);
|
||||
}
|
||||
|
||||
/* Addon manager */
|
||||
.addons-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
.addon-card {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
padding: 10px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
background: color-mix(in srgb, var(--panel) 86%, var(--bg));
|
||||
}
|
||||
.addon-card-main {
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 5px;
|
||||
}
|
||||
.addon-card-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
min-width: 0;
|
||||
font-size: 13px;
|
||||
}
|
||||
.addon-card-title strong {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.addon-status {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
background: color-mix(in srgb, var(--fg) 25%, transparent);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.addon-status.enabled {
|
||||
background: var(--accent, #50fa7b);
|
||||
box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent, #50fa7b) 14%, transparent);
|
||||
}
|
||||
.addon-version,
|
||||
.addon-muted,
|
||||
.addon-meta {
|
||||
color: color-mix(in srgb, var(--fg) 55%, transparent);
|
||||
font-size: 11px;
|
||||
}
|
||||
.addon-desc {
|
||||
font-size: 12px;
|
||||
line-height: 1.35;
|
||||
color: color-mix(in srgb, var(--fg) 78%, transparent);
|
||||
}
|
||||
.addon-meta {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
font-family: var(--mono, monospace);
|
||||
}
|
||||
.addon-error,
|
||||
.addon-msg.error {
|
||||
color: var(--red, #ff5555);
|
||||
}
|
||||
.addon-contribs {
|
||||
display: flex;
|
||||
gap: 5px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.addon-chip {
|
||||
padding: 2px 6px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 999px;
|
||||
font-size: 10px;
|
||||
color: color-mix(in srgb, var(--fg) 74%, transparent);
|
||||
}
|
||||
.addon-card-actions {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
flex-wrap: wrap;
|
||||
justify-content: flex-end;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.addon-builder-row {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.addon-builder-row input {
|
||||
min-width: 180px;
|
||||
max-width: 260px;
|
||||
}
|
||||
.addon-manifest-editor {
|
||||
width: 100%;
|
||||
min-height: 300px;
|
||||
resize: vertical;
|
||||
box-sizing: border-box;
|
||||
padding: 10px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
background: var(--bg);
|
||||
color: var(--fg);
|
||||
font-family: var(--mono, Consolas, monospace);
|
||||
font-size: 12px;
|
||||
line-height: 1.45;
|
||||
}
|
||||
.addon-msg {
|
||||
min-height: 18px;
|
||||
margin-top: 7px;
|
||||
font-size: 11px;
|
||||
color: color-mix(in srgb, var(--fg) 58%, transparent);
|
||||
}
|
||||
.addon-msg.ok {
|
||||
color: var(--accent, #50fa7b);
|
||||
}
|
||||
|
||||
/* The model-comparison grid hard-codes 2-4 equal columns with no phone
|
||||
breakpoint that stacks them, so at 390px two models render ~178px columns and
|
||||
four render ~88px columns. Each column is a full scrolling chat (code blocks,
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
// - Other static assets (images/fonts/libs): cache-first with bg refresh.
|
||||
// - API / non-GET: never cached.
|
||||
// Bump CACHE_NAME whenever the precache list or SW logic changes.
|
||||
const CACHE_NAME = 'odysseus-v344';
|
||||
const CACHE_NAME = 'odysseus-v345';
|
||||
|
||||
// Core shell precached on install so repeat opens are instant without any
|
||||
// network wait. Keep this list in sync with the <script type="module"> tags
|
||||
@@ -44,6 +44,7 @@ const PRECACHE = [
|
||||
'/static/js/theme.js',
|
||||
'/static/js/censor.js',
|
||||
'/static/js/settings.js',
|
||||
'/static/js/addons.js',
|
||||
'/static/js/admin.js',
|
||||
'/static/js/init.js',
|
||||
'/static/js/slashCommands.js',
|
||||
|
||||
103
tests/test_addons.py
Normal file
103
tests/test_addons.py
Normal file
@@ -0,0 +1,103 @@
|
||||
import asyncio
|
||||
import json
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
|
||||
from src import addons
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def addon_store(tmp_path, monkeypatch):
|
||||
monkeypatch.setattr(addons, "ADDONS_DIR", str(tmp_path / "addons"))
|
||||
monkeypatch.setattr(addons, "ADDONS_FILE", str(tmp_path / "addons.json"))
|
||||
return tmp_path
|
||||
|
||||
|
||||
def test_addon_template_is_valid(addon_store):
|
||||
manifest = addons.addon_template("demo-addon")
|
||||
saved = addons.save_addon_manifest(manifest)
|
||||
|
||||
assert saved["id"] == "demo-addon"
|
||||
assert saved["enabled"] is False
|
||||
assert saved["contributions"][0]["type"] == "agent_prompt"
|
||||
assert addons.get_addon("demo-addon")["name"] == "Demo Addon"
|
||||
|
||||
|
||||
def test_enable_addon_exposes_prompt_contribution(addon_store):
|
||||
addons.save_addon_manifest({
|
||||
"id": "prompt-addon",
|
||||
"name": "Prompt Addon",
|
||||
"contributions": [
|
||||
{"type": "agent_prompt", "text": "Prefer compact summaries."},
|
||||
{"type": "unknown", "text": "ignored"},
|
||||
],
|
||||
})
|
||||
|
||||
assert addons.get_addons_prompt() == ""
|
||||
addons.set_addon_enabled("prompt-addon", True)
|
||||
|
||||
prompt = addons.get_addons_prompt()
|
||||
assert "Prompt Addon" in prompt
|
||||
assert "Prefer compact summaries." in prompt
|
||||
assert "unknown" not in prompt
|
||||
|
||||
|
||||
def test_addon_id_rejects_path_traversal(addon_store):
|
||||
with pytest.raises(ValueError):
|
||||
addons.save_addon_manifest({"id": "../bad", "name": "Bad"})
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def addon_routes(addon_store):
|
||||
fastapi = pytest.importorskip("fastapi")
|
||||
from routes.addon_routes import setup_addon_routes
|
||||
|
||||
class _AuthManager:
|
||||
def get_username_for_token(self, token):
|
||||
return "admin" if token == "session-token" else None
|
||||
|
||||
def is_admin(self, user):
|
||||
return user == "admin"
|
||||
|
||||
router = setup_addon_routes(_AuthManager())
|
||||
|
||||
def endpoint(path, method):
|
||||
for route in router.routes:
|
||||
if getattr(route, "path", "") == path and method in getattr(route, "methods", set()):
|
||||
return route.endpoint
|
||||
raise AssertionError(f"{method} {path} route not registered")
|
||||
|
||||
return endpoint, fastapi.HTTPException
|
||||
|
||||
|
||||
class _Request(SimpleNamespace):
|
||||
def __init__(self, token="session-token"):
|
||||
super().__init__(cookies={"odysseus_session": token})
|
||||
|
||||
|
||||
def test_addon_routes_require_admin(addon_routes):
|
||||
endpoint, http_exception = addon_routes
|
||||
list_all = endpoint("/api/addons", "GET")
|
||||
|
||||
with pytest.raises(http_exception) as exc:
|
||||
asyncio.run(list_all(_Request(token="bad")))
|
||||
|
||||
assert exc.value.status_code == 401
|
||||
|
||||
|
||||
def test_addon_routes_create_and_enable(addon_routes):
|
||||
endpoint, _ = addon_routes
|
||||
create = endpoint("/api/addons", "POST")
|
||||
set_enabled = endpoint("/api/addons/{addon_id}/enabled", "POST")
|
||||
list_all = endpoint("/api/addons", "GET")
|
||||
|
||||
body = SimpleNamespace(manifest={"id": "route-addon", "name": "Route Addon"})
|
||||
result = asyncio.run(create(body, _Request()))
|
||||
assert result["addon"]["id"] == "route-addon"
|
||||
|
||||
enabled_body = SimpleNamespace(enabled=True)
|
||||
asyncio.run(set_enabled("route-addon", enabled_body, _Request()))
|
||||
listed = asyncio.run(list_all(_Request()))
|
||||
assert listed["addons"][0]["enabled"] is True
|
||||
assert json.loads((addons._addon_dir("route-addon") / "addon.json").read_text())["enabled"] is True
|
||||
@@ -6,7 +6,20 @@ from src.readiness import check_readiness
|
||||
def test_readiness_reports_core_subsystems():
|
||||
result = check_readiness()
|
||||
|
||||
assert {"ready", "version", "checks", "timestamp"}.issubset(result.keys())
|
||||
assert {
|
||||
"ready",
|
||||
"version",
|
||||
"upstream_version",
|
||||
"app_version",
|
||||
"build_flavor",
|
||||
"build_version",
|
||||
"checks",
|
||||
"timestamp",
|
||||
}.issubset(result.keys())
|
||||
assert result["upstream_version"] == "1.0.1"
|
||||
assert result["app_version"] == result["upstream_version"]
|
||||
assert result["build_flavor"] == "wilkensxl"
|
||||
assert result["version"] == result["build_version"]
|
||||
checks = result["checks"]
|
||||
for name in ("database", "data_dir", "local_first"):
|
||||
assert name in checks, f"missing check: {name}"
|
||||
|
||||
11
tests/test_version_metadata.py
Normal file
11
tests/test_version_metadata.py
Normal file
@@ -0,0 +1,11 @@
|
||||
"""Tests for WilkensXL build metadata."""
|
||||
|
||||
from src.constants import APP_VERSION, BUILD_FLAVOR, BUILD_VERSION, FULL_VERSION, UPSTREAM_VERSION
|
||||
|
||||
|
||||
def test_custom_build_version_is_distinct_from_upstream_version():
|
||||
assert APP_VERSION == "1.0.1"
|
||||
assert UPSTREAM_VERSION == APP_VERSION
|
||||
assert BUILD_FLAVOR == "wilkensxl"
|
||||
assert BUILD_VERSION.startswith(f"{APP_VERSION}-wilkensxl.")
|
||||
assert FULL_VERSION == BUILD_VERSION
|
||||
Reference in New Issue
Block a user