bifroest/app/hugin/service.py
2026-10-27 08:34:17 +01:00

639 lines
30 KiB
Python

"""Hugin-Anbindung (→ Konzept 7.9): Pakete wählen, auflösen, festschreiben und für Runs laden.
Ablauf: Der Plattform-Admin trägt den Hugin-Host ein und sieht, welche Workspaces Hugin nutzen. Der
Workspace-Admin (ein Workspace gehört einem Team) trägt den Integrations-Key seines Tenants ein und wählt Pakete
(wie Zeilen in .hugin.yaml); „Abgleichen“ zeigt, was Hugin liefern würde, „Übernehmen“ schreibt Versionen
mit Prüfsumme fest und speichert jedes Paket im Quellformat. Neue Versionen kommen nur durch Übernehmen;
Pflichtpakete aus Hugin kommen immer mit. Für Runs werden nur festgeschriebene, geprüfte Kopien geladen.
Abbildung in der ersten Stufe: rules → System-Prompt, skill → Name und Beschreibung im Prompt, Inhalt über
load_skill und read_skill_file, command → Slash-Befehl im Chat. Nicht angeboten werden andere Arten, Pakete
mit Risiko scripts oder permissions und Befehle mit Shell-Einbettung. Hugin-Inhalte verleihen keine Rechte.
"""
import hashlib
import io
import json
import logging
import re
import shlex
import zipfile
from dataclasses import dataclass, field
from datetime import timedelta
from pathlib import PurePosixPath
from typing import Any
import yaml
from sqlalchemy import func, select
from sqlalchemy.orm import Session
from app import audit
from app.core.config import Settings
from app.core.crypto import Vault
from app.core.database import now
from app.core.errors import Invalid, NotFound
from app.data_sources import service as sources
from app.data_sources.connectors.git_repo import GitError, RepoCache
from app.data_sources.models import DataSource
from app.hugin.client import Client, HuginError, ready
from app.hugin.models import HuginHost, WorkspaceHugin, WorkspaceHuginLock, WorkspaceHuginPackage
from app.identity import Principal
from app.identity.admin import require as require_platform_admin
from app.identity.models import Team, User
from app.mcp import service as mcp
from app.security import untrusted
from app.workspaces import service as workspaces
from app.workspaces.models import Workspace, WorkspaceMembership
log = logging.getLogger(__name__)
SPEC = re.compile(r"^[a-z0-9]+(?:-[a-z0-9]+)*(?:@(?:stable|test|\^\d+|\d+\.\d+\.\d+))?$")
CONTENT_FILE = {"rules": "RULES.md", "skill": "SKILL.md", "command": "COMMAND.md"}
KIND_NAMES = {"rules": "Regeln", "skill": "Skill", "command": "Befehl", "subagent": "Subagent", "mcp": "MCP",
"plugin": "Plugin", "hook": "Hook"}
ALLOWED_RISKS = ("text", "tools")
COMMAND = re.compile(r"^/([a-z0-9]+(?:-[a-z0-9]+)*)(?:\s+(.*))?$", re.S)
MAX_RULES_CHARS = 20000
# Beim Laden: Größe wurde beim Übernehmen geprüft; hier nur noch Schutz vor Unfug
LOAD_LIMIT = 64 << 20
# --- Host der Plattform (Plattform-Admin) ---
def host(session: Session) -> HuginHost | None:
return session.get(HuginHost, 1)
def save_host(session: Session, principal: Principal, settings: Settings, url: str) -> HuginHost:
require_platform_admin(principal)
url = mcp.check_url(url, settings).rstrip("/")
row = session.get(HuginHost, 1)
if row is None:
row = HuginHost(id=1, updated_by=principal.user_id)
session.add(row)
if row.url != url:
row.status, row.status_message, row.checked = "new", "", None
row.url, row.updated_by, row.updated = url, principal.user_id, now()
audit.record(session, "hugin.host_saved", user_id=principal.user_id, target_kind="hugin_host", target_id=1,
url=url)
return row
def check_host(session: Session, principal: Principal, settings: Settings) -> HuginHost:
"""Erreichbarkeit über /readyz; ob ein Key gilt, prüft jeder Workspace mit seinem Key."""
require_platform_admin(principal)
row = session.get(HuginHost, 1)
if row is None:
raise NotFound
error = ready(row.url, settings.hugin_timeout_seconds)
row.status, row.status_message = ("error", error) if error else ("ok", "erreichbar")
row.checked = now()
return row
@dataclass
class WorkspaceStatus:
"""Für die Übersicht des Plattform-Admins: ob und wie ein Workspace Hugin nutzt, ohne Key."""
workspace: Workspace
team: str
access: WorkspaceHugin | None
chosen: int
locked: int
@property
def findings(self) -> list[str]:
return describe(self.access.findings or {}) if self.access else []
def overview(session: Session) -> list[WorkspaceStatus]:
accesses = {row.workspace_id: row for row in session.scalars(select(WorkspaceHugin))}
chosen = dict(session.execute(select(WorkspaceHuginPackage.workspace_id, func.count())
.group_by(WorkspaceHuginPackage.workspace_id)).all())
locked = dict(session.execute(select(WorkspaceHuginLock.workspace_id, func.count())
.group_by(WorkspaceHuginLock.workspace_id)).all())
rows = session.execute(select(Workspace, Team.name).join(Team, Team.id == Workspace.team_id)
.where(Workspace.deleted.is_(None)).order_by(Team.name, Workspace.name)).all()
return [WorkspaceStatus(w, team, accesses.get(w.id), chosen.get(w.id, 0), locked.get(w.id, 0))
for w, team in rows]
# --- Zugang je Workspace (Workspace-Admin) ---
def access(session: Session, workspace_id: int) -> WorkspaceHugin | None:
return session.get(WorkspaceHugin, workspace_id)
def save_access(session: Session, principal: Principal, settings: Settings, vault: Vault, workspace_id: int,
key: str) -> WorkspaceHugin:
"""Integrations-Key speichern und gleich prüfen; Tenant und Key-Name meldet Hugin."""
workspaces.require_admin(session, principal, workspace_id)
key = key.strip()
if not re.fullmatch(r"hi_[A-Za-z0-9_-]{16,200}", key):
raise Invalid("Das sieht nicht nach einem Integrations-Key von Hugin aus (hi_…).")
row = session.get(WorkspaceHugin, workspace_id)
if row is None:
row = WorkspaceHugin(workspace_id=workspace_id, tenant="", updated_by=principal.user_id)
session.add(row)
row.secret, row.hint, row.updated_by, row.updated = vault.encrypt(key), key[-4:], principal.user_id, now()
# Tenant bleibt bis zum Test stehen, damit ein Wechsel im Audit-Log erscheint
row.status, row.status_message, row.checked = "new", "", None
audit.record(session, "hugin.access_saved", user_id=principal.user_id, workspace_id=workspace_id,
target_kind="workspace", target_id=workspace_id)
return test_access(session, principal, settings, vault, workspace_id)
def test_access(session: Session, principal: Principal, settings: Settings, vault: Vault,
workspace_id: int) -> WorkspaceHugin:
"""Key über /status prüfen; ein Wechsel des Tenants kommt ins Audit-Log (er bestimmt die Pflichtpakete)."""
workspace = workspaces.require_admin(session, principal, workspace_id).workspace
row = session.get(WorkspaceHugin, workspace_id)
if row is None:
raise NotFound
try:
with client(session, settings, vault, workspace) as hugin:
info = hugin.status()
key = info.get("key") or {}
tenant = str(key.get("tenant", ""))[:64]
if tenant != row.tenant:
audit.record(session, "hugin.tenant_changed", user_id=principal.user_id, workspace_id=workspace_id,
target_kind="workspace", target_id=workspace_id, before=row.tenant, after=tenant)
row.status, row.tenant, row.key_name = "ok", tenant, str(key.get("name", ""))[:200]
row.status_message = f"gültig bis {key['expires_at'][:10]}" if key.get("expires_at") else ""
except HuginError as e:
row.status, row.status_message = "error", str(e)
row.checked = now()
return row
def remove_access(session: Session, principal: Principal, workspace_id: int) -> None:
"""Festgeschriebene Pakete gelten weiter; Abgleichen und Rückmeldungen gehen nicht mehr."""
workspaces.require_admin(session, principal, workspace_id)
row = session.get(WorkspaceHugin, workspace_id)
if row is not None:
session.delete(row)
audit.record(session, "hugin.access_removed", user_id=principal.user_id, workspace_id=workspace_id,
target_kind="workspace", target_id=workspace_id)
def client(session: Session, settings: Settings, vault: Vault, workspace: Workspace) -> Client:
instance = host(session)
if instance is None:
raise HuginError("Hugin ist auf dieser Plattform nicht eingerichtet (Administration → Hugin).")
row = access(session, workspace.id)
if row is None:
raise HuginError("Für diesen Workspace ist kein Hugin-Key eingetragen (Einstellungen → Hugin).")
return Client(instance.url, vault.decrypt(row.secret), settings.hugin_timeout_seconds)
# --- Auswahl je Workspace ---
def subscriptions(session: Session, workspace_id: int) -> list[WorkspaceHuginPackage]:
return list(session.scalars(select(WorkspaceHuginPackage).where(WorkspaceHuginPackage.workspace_id == workspace_id)
.order_by(WorkspaceHuginPackage.package_id)))
def locks(session: Session, workspace_id: int) -> list[WorkspaceHuginLock]:
return list(session.scalars(select(WorkspaceHuginLock).where(WorkspaceHuginLock.workspace_id == workspace_id)
.order_by(WorkspaceHuginLock.package_id)))
def add(session: Session, principal: Principal, workspace_id: int, text: str) -> WorkspaceHuginPackage:
"""Paket wählen, z. B. pub-release-notiz oder pub-release-notiz@^1."""
workspaces.require_admin(session, principal, workspace_id)
text = text.strip()
if not SPEC.match(text):
raise Invalid("Paket als <id> oder <id>@stable, @test, @^1 bzw. @1.2.3 angeben.")
package_id, _, spec = text.partition("@")
row = session.get(WorkspaceHuginPackage, (workspace_id, package_id)) or WorkspaceHuginPackage(
workspace_id=workspace_id, package_id=package_id, added_by=principal.user_id)
row.spec = spec or "stable"
session.add(row)
audit.record(session, "hugin.package_added", user_id=principal.user_id, workspace_id=workspace_id,
target_kind="hugin_package", target_id=package_id, spec=row.spec)
return row
def remove(session: Session, principal: Principal, workspace_id: int, package_id: str) -> None:
"""Aus der Auswahl nehmen; die festgeschriebene Version bleibt bis zum nächsten Übernehmen."""
workspaces.require_admin(session, principal, workspace_id)
row = session.get(WorkspaceHuginPackage, (workspace_id, package_id))
if row is not None:
session.delete(row)
audit.record(session, "hugin.package_removed", user_id=principal.user_id, workspace_id=workspace_id,
target_kind="hugin_package", target_id=package_id)
def catalog(session: Session, settings: Settings, vault: Vault, principal: Principal,
workspace_id: int) -> list[dict]:
"""Freigegebene Pakete, die das Team sieht, mit Hinweis, ob Bifröst sie abbilden kann."""
workspace = workspaces.require_admin(session, principal, workspace_id).workspace
with client(session, settings, vault, workspace) as hugin:
packages = hugin.packages()
# Pflichtpakete nennt der Katalog nicht; ein Auflösen ohne Auswahl liefert genau sie
mandatory = {p["id"] for p in hugin.resolve([], {}).get("packages", []) if p.get("mandatory")}
for p in packages:
p["kind_name"] = KIND_NAMES.get(p.get("kind", ""), p.get("kind", ""))
p["unsupported"] = unsupported(p.get("kind", ""), p.get("risk", ""))
p["mandatory"] = p["id"] in mandatory
return sorted(packages, key=lambda p: p["id"])
def unsupported(kind: str, risk: str, content: str = "") -> str:
"""Leer, wenn Bifröst das Paket anbietet; sonst der Grund."""
if kind not in CONTENT_FILE:
return f"{KIND_NAMES.get(kind, kind)} wird in Bifröst noch nicht unterstützt."
if risk not in ALLOWED_RISKS:
return "Enthält Skripte oder verlangt Rechte; Bifröst führt keinen Code aus."
if kind == "command" and "!`" in content:
return "Befehl mit Shell-Einbettung; Bifröst führt keine Befehle aus."
return ""
# --- Vorschlag aus .hugin.yaml im Repository ---
REPO_FILE = ".hugin.yaml"
@dataclass
class RepoProposal:
source: str
packages: list[str] = field(default_factory=list) # wie in der Datei, z. B. pub-x@^1
unsubscribed: list[str] = field(default_factory=list)
server: str = ""
error: str = ""
def repo_proposals(db, settings: Settings, vault: Vault, principal: Principal, workspace_id: int) -> list[RepoProposal]:
""".hugin.yaml aus dem Ziel-Branch jeder Git-Quelle lesen; nur ein Vorschlag für die Auswahl.
Ohne offene Transaktion aufrufen: das Abholen des Repositorys kann dauern."""
with db.session() as session:
workspaces.require_admin(session, principal, workspace_id)
found = list(session.scalars(select(DataSource).where(
DataSource.workspace_id == workspace_id, DataSource.kind == "git", DataSource.status == "ok")
.order_by(DataSource.name)))
for source in found:
session.expunge(source)
cache = RepoCache(settings)
proposals = []
for source in found:
proposal = RepoProposal(source.name)
try:
repo = cache.repo(source.id, sources.connector(source, settings, vault))
commit = repo.sync()
if repo.kind(commit, REPO_FILE) != "blob":
continue
data = yaml.safe_load(repo.read(commit, REPO_FILE)[:65536]) or {}
if not isinstance(data, dict):
raise ValueError("kein YAML-Objekt")
except (GitError, OSError, yaml.YAMLError, ValueError) as e:
proposal.error = f"{REPO_FILE} nicht lesbar: {str(e)[:200]}"
proposals.append(proposal)
continue
entries = [str(p).strip() for p in data.get("packages") or [] if isinstance(p, (str, int))]
proposal.packages = [p for p in entries if SPEC.match(p)]
proposal.unsubscribed = [str(p) for p in data.get("unsubscribed") or [] if isinstance(p, str)]
proposal.server = str(data.get("server") or "")
skipped = [p for p in entries if not SPEC.match(p)]
if skipped:
proposal.error = "Übersprungen (kein gültiger Eintrag): " + ", ".join(skipped[:5])
proposals.append(proposal)
return proposals
def add_many(session: Session, principal: Principal, workspace_id: int, entries: list[str]) -> int:
for entry in entries:
add(session, principal, workspace_id, entry)
return len(entries)
# --- Abgleichen und Übernehmen ---
@dataclass
class Change:
package_id: str
kind: str
current: str | None
new: str | None
mandatory: bool = False
deprecated: bool = False
unsupported: str = ""
@property
def action(self) -> str:
if self.current is None:
return "neu"
if self.new is None:
return "entfällt"
return "unverändert" if self.current == self.new else "aktualisieren"
@dataclass
class Plan:
changes: list[Change]
major_available: dict[str, str]
resolved: dict = field(default_factory=dict)
@property
def pending(self) -> bool:
return any(c.action != "unverändert" for c in self.changes)
def plan(session: Session, settings: Settings, vault: Vault, principal: Principal, workspace_id: int) -> Plan:
"""Was Hugin jetzt liefern würde, verglichen mit dem Festgeschriebenen. Ändert nichts außer dem Befund."""
workspace = workspaces.require_admin(session, principal, workspace_id).workspace
result = resolve_plan(session, settings, vault, workspace)
record_findings(session, workspace_id, result)
return result
def resolve_plan(session: Session, settings: Settings, vault: Vault, workspace: Workspace) -> Plan:
workspace_id = workspace.id
current = {lock.package_id: lock for lock in locks(session, workspace_id)}
wanted = [f"{s.package_id}@{s.spec}" for s in subscriptions(session, workspace_id)]
with client(session, settings, vault, workspace) as hugin:
resolved = hugin.resolve(wanted, {k: v.version for k, v in current.items()})
changes = []
seen = set()
for p in resolved.get("packages", []):
seen.add(p["id"])
lock = current.get(p["id"])
changes.append(Change(p["id"], p["kind"], lock.version if lock else None, p["version"], p["mandatory"],
p["deprecated"], unsupported(p["kind"], p["risk"])))
for package_id, lock in current.items():
if package_id not in seen:
changes.append(Change(package_id, lock.kind, lock.version, None, lock.mandatory))
changes.sort(key=lambda c: c.package_id)
return Plan(changes, resolved.get("major_available", {}), resolved)
def apply(session: Session, settings: Settings, vault: Vault, principal: Principal, workspace_id: int) -> Plan:
"""Auflösen und festschreiben: neue oder geänderte Versionen laden und prüfen, entfallene entfernen."""
workspace = workspaces.require_admin(session, principal, workspace_id).workspace
result = resolve_plan(session, settings, vault, workspace)
current = {lock.package_id: lock for lock in locks(session, workspace_id)}
max_bytes = settings.hugin_max_package_kb * 1024
with client(session, settings, vault, workspace) as hugin:
for p in result.resolved.get("packages", []):
lock = current.get(p["id"])
if lock is not None and lock.version == p["version"] and lock.checksum == p["checksum"]:
lock.mandatory, lock.deprecated = p["mandatory"], p["deprecated"]
continue
source = hugin.source(p["id"], p["version"], max_bytes)
if source.checksum != p["checksum"]:
raise HuginError(f"Prüfsumme von {p['id']} {p['version']} stimmt nicht mit der Auflösung überein.")
files = read_archive(source.archive, max_bytes)
content = files.get(CONTENT_FILE.get(p["kind"], ""), b"").decode("utf-8", errors="replace")
if p["kind"] in CONTENT_FILE and not content.strip():
raise HuginError(f"{p['id']} {p['version']}: {CONTENT_FILE[p['kind']]} fehlt oder ist leer.")
lock = lock or WorkspaceHuginLock(workspace_id=workspace_id, package_id=p["id"])
lock.kind, lock.version, lock.checksum, lock.risk = p["kind"], p["version"], p["checksum"], p["risk"]
lock.mandatory, lock.deprecated = p["mandatory"], p["deprecated"]
lock.description, lock.arguments = p.get("description", "")[:2000], list(p.get("arguments") or [])
lock.archive, lock.archive_sha256 = source.archive, hashlib.sha256(source.archive).hexdigest()
lock.unsupported = unsupported(p["kind"], p["risk"], content)
lock.locked_by, lock.locked = principal.user_id, now()
session.add(lock)
for change in result.changes:
if change.action == "entfällt":
session.delete(current[change.package_id])
session.flush()
installed = {lock.package_id: lock.version for lock in locks(session, workspace_id)}
try:
hugin.usage(f"ai-workspace/{workspace_id}", installed)
except HuginError as e:
log.warning("Nutzung nicht an Hugin gemeldet", extra={"event": "hugin_usage_failed", "error": str(e)})
audit.record(session, "hugin.applied", user_id=principal.user_id, workspace_id=workspace_id,
target_kind="workspace", target_id=workspace_id, packages=installed,
changes={c.package_id: c.action for c in result.changes if c.action != "unverändert"})
# Nach dem Übernehmen bleibt nur, was sich nicht durch Übernehmen erledigt (veraltet, Hauptversion)
record_findings(session, workspace_id, Plan([Change(c.package_id, c.kind, c.new, c.new, c.mandatory, c.deprecated)
for c in result.changes if c.new], result.major_available))
return result
# --- Abgleich im Hintergrund ---
def findings_of(result: Plan) -> dict[str, Any]:
"""Was ein Workspace-Admin wissen sollte; eigene, noch nicht übernommene Auswahl zählt nicht."""
found: dict[str, Any] = {}
updates = {c.package_id: [c.current, c.new] for c in result.changes if c.action == "aktualisieren"}
deprecated = sorted(c.package_id for c in result.changes if c.deprecated and c.current)
new_mandatory = sorted(c.package_id for c in result.changes if c.action == "neu" and c.mandatory)
removed_mandatory = sorted(c.package_id for c in result.changes if c.action == "entfällt" and c.mandatory)
for key, value in (("updates", updates), ("deprecated", deprecated), ("new_mandatory", new_mandatory),
("removed_mandatory", removed_mandatory), ("major", dict(result.major_available))):
if value:
found[key] = value
return found
def record_findings(session: Session, workspace_id: int, result: Plan) -> dict[str, Any]:
"""Befund am Zugang speichern; gibt ihn zurück, wenn er neu und nicht leer ist (dann benachrichtigen)."""
row = session.get(WorkspaceHugin, workspace_id)
if row is None:
return {}
found = findings_of(result)
changed = found != (row.findings or {})
row.findings, row.synced = found, now()
return found if changed and found else {}
def describe(found: dict[str, Any]) -> list[str]:
lines = [f"Update verfügbar: {k} {old} → {new}" for k, (old, new) in found.get("updates", {}).items()]
lines += [f"Veraltet: {k}" for k in found.get("deprecated", [])]
lines += [f"Neues Pflichtpaket: {k}" for k in found.get("new_mandatory", [])]
lines += [f"Pflichtpaket entfällt: {k}" for k in found.get("removed_mandatory", [])]
lines += [f"Neue Hauptversion: {k} {v}" for k, v in found.get("major", {}).items()]
return lines
def check_due(db, settings: Settings) -> int:
"""Vom Worker aufgerufen: Workspaces abgleichen, deren letzter Abgleich älter als AIW_HUGIN_CHECK_HOURS ist.
Übernimmt nie etwas; ein neuer Befund geht per E-Mail an die Workspace-Admins und an den Webhook."""
if settings.hugin_check_hours <= 0:
return 0
limit = now() - timedelta(hours=settings.hugin_check_hours)
with db.session() as session:
if host(session) is None:
return 0
due = list(session.scalars(select(WorkspaceHugin.workspace_id).join(
Workspace, Workspace.id == WorkspaceHugin.workspace_id).where(
Workspace.deleted.is_(None), (WorkspaceHugin.synced.is_(None)) | (WorkspaceHugin.synced < limit))))
vault = Vault(settings.secret_key)
checked = 0
for workspace_id in due:
try:
with db.session() as session:
row = session.get(WorkspaceHugin, workspace_id, with_for_update={"skip_locked": True})
if row is None or (row.synced is not None and row.synced >= limit):
continue
workspace = session.get(Workspace, workspace_id)
try:
found = record_findings(session, workspace_id, resolve_plan(session, settings, vault, workspace))
except HuginError as e:
row.synced = now() # nicht in jeder Runde erneut versuchen
log.warning("Abgleich mit Hugin fehlgeschlagen", extra={
"event": "hugin_check_failed", "workspace_id": workspace_id, "error": str(e)})
continue
if found:
notify_findings(session, workspace, found)
checked += 1
except Exception:
log.exception("Abgleich mit Hugin für Workspace %s fehlgeschlagen", workspace_id)
return checked
def notify_findings(session: Session, workspace: Workspace, found: dict[str, Any]) -> None:
from app.notify import service as notify
url = notify.link(f"/workspaces/{workspace.id}/settings?tab=hugin")
lines = describe(found)
subject = f"Hugin: Neuigkeiten für {notify.clean(workspace.name, 80)}"
body = (f"Der Abgleich mit Hugin hat für den Workspace „{workspace.name}“ Neues gefunden:\n\n"
+ "\n".join(f"- {line}" for line in lines)
+ f"\n\nÜbernommen wird nichts automatisch. Zum Abgleichen: {url}")
admins = session.scalars(select(User).join(WorkspaceMembership, WorkspaceMembership.user_id == User.id).where(
WorkspaceMembership.workspace_id == workspace.id, WorkspaceMembership.role == "admin", User.active))
for user in admins:
notify.email(session, user, "hugin.findings", subject, body, workspace.id)
notify.webhook(session, workspace, "hugin.findings",
f"Hugin: {len(lines)} Neuigkeit(en) im Workspace {notify.clean(workspace.name, 80)}. {url}")
def read_archive(raw: bytes, max_bytes: int) -> dict[str, bytes]:
"""ZIP im Quellformat lesen; nur einfache relative Pfade, begrenzte Größe."""
try:
with zipfile.ZipFile(io.BytesIO(raw)) as z:
infos = [i for i in z.infolist() if not i.is_dir()]
if sum(i.file_size for i in infos) > 8 * max_bytes:
raise HuginError("Das Paket ist entpackt zu groß.")
files = {}
for info in infos:
path = PurePosixPath(info.filename)
if path.is_absolute() or ".." in path.parts or "\\" in info.filename:
raise HuginError(f"Ungültiger Pfad im Paket: {info.filename[:100]}")
files[str(path)] = z.read(info)
return files
except zipfile.BadZipFile as e:
raise HuginError("Hugin hat kein gültiges ZIP geliefert.") from e
# --- Laden für einen Run ---
@dataclass
class Package:
id: str
kind: str
version: str
description: str
arguments: list[str]
files: dict[str, bytes]
@property
def content(self) -> str:
return self.files.get(CONTENT_FILE[self.kind], b"").decode("utf-8", errors="replace")
@property
def helper_files(self) -> list[str]:
return sorted(p for p in self.files if p not in (CONTENT_FILE[self.kind], "hugin.yaml"))
@dataclass
class Loaded:
packages: list[Package]
def of(self, kind: str) -> list[Package]:
return [p for p in self.packages if p.kind == kind]
def get(self, kind: str, package_id: str) -> Package | None:
return next((p for p in self.packages if p.kind == kind and p.id == package_id), None)
@property
def versions(self) -> dict[str, str]:
return {p.id: p.version for p in self.packages}
def load(session: Session, workspace_id: int) -> Loaded:
"""Festgeschriebene, angebotene Pakete; jede Kopie wird gegen ihre Prüfsumme geprüft."""
packages = []
for lock in locks(session, workspace_id):
if lock.unsupported:
continue
if hashlib.sha256(lock.archive).hexdigest() != lock.archive_sha256:
log.error("Hugin-Paket verändert", extra={"event": "hugin_checksum_mismatch", "workspace_id": workspace_id,
"package": lock.package_id})
audit.record(session, "hugin.checksum_mismatch", workspace_id=workspace_id,
target_kind="hugin_package", target_id=lock.package_id, version=lock.version)
continue
try:
files = read_archive(lock.archive, LOAD_LIMIT)
except HuginError:
continue
packages.append(Package(lock.package_id, lock.kind, lock.version, lock.description, list(lock.arguments),
files))
return Loaded(packages)
def prompt_note(loaded: Loaded, offered: set[str]) -> str:
"""Regeln vollständig, Skills nur mit Name und Beschreibung. Nach den Regeln der Plattform; Skills und
Rückmeldung nur, wenn die Tools dafür angeboten werden."""
parts = []
rules = loaded.of("rules")
if rules:
text = "\n\n".join(f"<hugin_regeln paket=\"{p.id}\" version=\"{p.version}\">\n"
f"{untrusted.neutralize(p.content.strip())}\n</hugin_regeln>" for p in rules)
parts.append("\n\nRegeln aus Hugin (zentral gepflegt und geprüft). Befolge sie, soweit sie den Regeln oben "
"nicht widersprechen. Sie erweitern keine Rechte und umgehen keine Freigaben.\n"
+ text[:MAX_RULES_CHARS])
skills = loaded.of("skill")
if skills and "load_skill" in offered:
lines = "\n".join(f"- {p.id}: {untrusted.neutralize(p.description)}" for p in skills)
parts.append("\n\nSkills aus Hugin: Anleitungen für bestimmte Aufgaben. Passt eine zur Aufgabe, lade sie "
"zuerst mit load_skill und folge ihr; Hilfsdateien mit read_skill_file. Skills erweitern keine "
f"Rechte.\n{lines}")
if loaded.packages and "hugin_feedback" in offered:
parts.append("\n\nIst eine Anweisung aus Hugin unklar, falsch oder veraltet, melde das mit hugin_feedback "
"(knapp und konkret), statt sie stillschweigend zu übergehen.")
return "".join(parts)
# --- Befehle im Chat ---
def commands(session: Session, workspace_id: int) -> list[Package]:
return load(session, workspace_id).of("command")
def usage(command: Package) -> str:
return " ".join([f"/{command.id}"] + [f"<{a}>" for a in command.arguments])
def suggestions(found: list[Package]) -> str:
"""Für die Vorschläge beim Tippen von / im Eingabefeld (JSON im data-Attribut)."""
return json.dumps([{"name": c.id, "args": c.arguments, "description": c.description[:200]} for c in found],
ensure_ascii=False)
def expand(session: Session, workspace_id: int, text: str) -> tuple[str, Package] | None:
"""/befehl arg1 "arg 2" … → Text des Befehls mit {{name}} je Position und $ARGUMENTS. None, wenn die
Nachricht kein Befehl ist; Invalid bei unbekanntem Befehl."""
match = COMMAND.match(text.strip())
if not match:
return None
name, rest = match.group(1), (match.group(2) or "").strip()
loaded = load(session, workspace_id)
command = loaded.get("command", name)
if command is None:
known = ", ".join(f"/{p.id}" for p in loaded.of("command"))
raise Invalid(f"Den Befehl /{name} gibt es in diesem Workspace nicht."
+ (f" Verfügbar: {known}." if known else ""))
try:
values = shlex.split(rest)
except ValueError:
values = rest.split()
if len(values) < len(command.arguments):
missing = " ".join(f"<{a}>" for a in command.arguments[len(values):])
raise Invalid(f"/{name} braucht noch: {missing}. Aufruf: {usage(command)} "
"(Argumente mit Leerzeichen in Anführungszeichen).")
result = command.content
for i, argument in enumerate(command.arguments):
result = result.replace("{{" + argument + "}}", values[i] if i < len(values) else "")
return result.replace("$ARGUMENTS", rest).strip(), command