From ced8d36a0c0c02e767a978b3e388ac4af4281de1 Mon Sep 17 00:00:00 2001 From: Mikei386 <44135113+Mikei386@users.noreply.github.com> Date: Sat, 22 Aug 2026 22:12:14 +0200 Subject: [PATCH] Add native chat background and guarded HA YAML access --- compose.yaml | 3 +- dev/test_hass_mcp_yaml_guard.py | 91 +++ docs/INSTALLATION.md | 7 + platform/mcp/README.md | 34 + platform/mcp/install-hass-mcp-yaml-guard.sh | 27 + platform/mcp/patches/hass_mcp/yaml_config.py | 621 +++++++++++++++++++ platform/openwebui/install-filters.sh | 6 +- platform/openwebui/install-models.sh | 14 + platform/openwebui/theme/custom.css | 125 +--- platform/openwebui/theme/loader.js | 2 +- platform/openwebui/theme/midnight-aurora.svg | 33 + 11 files changed, 838 insertions(+), 125 deletions(-) create mode 100644 dev/test_hass_mcp_yaml_guard.py create mode 100755 platform/mcp/install-hass-mcp-yaml-guard.sh create mode 100644 platform/mcp/patches/hass_mcp/yaml_config.py create mode 100644 platform/openwebui/theme/midnight-aurora.svg diff --git a/compose.yaml b/compose.yaml index da4ec68..091cd45 100644 --- a/compose.yaml +++ b/compose.yaml @@ -545,6 +545,7 @@ services: # the repository makes the global dark theme reproducible and update-safe. - ./platform/openwebui/theme/custom.css:/app/build/static/custom.css:ro - ./platform/openwebui/theme/loader.js:/app/build/static/loader.js:ro + - ./platform/openwebui/theme/midnight-aurora.svg:/app/build/static/midnight-aurora.svg:ro environment: WEBUI_SECRET_KEY: "${WEBUI_SECRET_KEY:?WEBUI_SECRET_KEY is required}" DEFAULT_MODELS: mikeai-medium @@ -578,7 +579,7 @@ services: # Seed native MCP connections on a fresh Open WebUI database. Secrets # stay inside the tool containers, so these internal URLs need no keys. TOOL_SERVER_CONNECTIONS: >- - [{"url":"http://mike-ai-mcp-web:8000/mcp","path":"","type":"mcp","auth_type":"none","headers":null,"key":"","config":{"enable":true,"access_grants":[]},"info":{"id":"web-local","name":"Web (öffentlich, read-only)","description":"Für aktuelle öffentliche Internetdaten, Quellenprüfung, GitHub/Hugging Face und Produktsuche. Nicht für Home Assistant, Medienverwaltung oder NAS-Diagnose."}},{"url":"http://mike-ai-mcp-homeassistant:8000/mcp","path":"","type":"mcp","auth_type":"none","headers":null,"key":"","config":{"enable":true,"access_grants":[]},"info":{"id":"homeassistant-local","name":"Home Assistant (lokal)","description":"Nur für Home-Assistant-Entitäten, Zustände, Historie, Automationen, Dashboards und HA-Diagnose. Nicht für Unraid, Sonarr/Radarr oder allgemeine Websuche."}},{"url":"http://mike-ai-mcp-arr:8000/mcp","path":"","type":"mcp","auth_type":"none","headers":null,"key":"","config":{"enable":true,"access_grants":[]},"info":{"id":"arr-local","name":"Sonarr und Radarr (lokal)","description":"Nur für verwaltete Serien/Filme, fehlende Episoden, Queue und Suche über konfigurierte Indexer. Keine allgemeine Websuche; Schreibaktionen benötigen Vorschau und Freigabe."}},{"url":"http://mike-ai-mcp-unraid-official:8000/mcp","path":"","type":"mcp","auth_type":"none","headers":null,"key":"","config":{"enable":true,"access_grants":[]},"info":{"id":"unraid-readonly-local","name":"Unraid (Systemdiagnose)","description":"Nur für Unraid-Host, Array, Datenträger, Docker-Container, Shares, Netzwerk, UPS und Systemlogs. Nicht für Home Assistant oder Medieninhalte; Standardzugriff read-only."}}] + [{"url":"http://mike-ai-mcp-web:8000/mcp","path":"","type":"mcp","auth_type":"none","headers":null,"key":"","config":{"enable":true,"access_grants":[]},"info":{"id":"web-local","name":"Web (öffentlich, read-only)","description":"Für aktuelle öffentliche Internetdaten, Quellenprüfung, GitHub/Hugging Face und Produktsuche. Nicht für Home Assistant, Medienverwaltung oder NAS-Diagnose."}},{"url":"http://mike-ai-mcp-homeassistant:8000/mcp","path":"","type":"mcp","auth_type":"none","headers":null,"key":"","config":{"enable":true,"access_grants":[]},"info":{"id":"homeassistant-local","name":"Home Assistant (lokal)","description":"Für Home-Assistant-Entitäten, Zustände, Historie, Automationen, Dashboards, HA-Diagnose und freigegebene YAML-Dateien. YAML-Lesen ist begrenzt; Änderungen benötigen serverseitige Vorschau, explizite Freigabe, Sicherung und Validierung. Nicht für Unraid, Sonarr/Radarr oder allgemeine Websuche."}},{"url":"http://mike-ai-mcp-arr:8000/mcp","path":"","type":"mcp","auth_type":"none","headers":null,"key":"","config":{"enable":true,"access_grants":[]},"info":{"id":"arr-local","name":"Sonarr und Radarr (lokal)","description":"Nur für verwaltete Serien/Filme, fehlende Episoden, Queue und Suche über konfigurierte Indexer. Keine allgemeine Websuche; Schreibaktionen benötigen Vorschau und Freigabe."}},{"url":"http://mike-ai-mcp-unraid-official:8000/mcp","path":"","type":"mcp","auth_type":"none","headers":null,"key":"","config":{"enable":true,"access_grants":[]},"info":{"id":"unraid-readonly-local","name":"Unraid (Systemdiagnose)","description":"Nur für Unraid-Host, Array, Datenträger, Docker-Container, Shares, Netzwerk, UPS und Systemlogs. Nicht für Home Assistant oder Medieninhalte; Standardzugriff read-only."}}] DO_NOT_TRACK: "true" SCARF_NO_ANALYTICS: "true" dns: ["${AI_DNS:-1.1.1.1}"] diff --git a/dev/test_hass_mcp_yaml_guard.py b/dev/test_hass_mcp_yaml_guard.py new file mode 100644 index 0000000..48ff617 --- /dev/null +++ b/dev/test_hass_mcp_yaml_guard.py @@ -0,0 +1,91 @@ +#!/usr/bin/env python3 +"""Dependency-free safety checks for the hass_mcp YAML overlay.""" + +from __future__ import annotations + +import importlib.util +import pathlib +import sys +import types + + +def module(name: str, **attributes): + value = types.ModuleType(name) + for key, item in attributes.items(): + setattr(value, key, item) + sys.modules[name] = value + return value + + +class ToolError(Exception): + pass + + +def decorator(**_kwargs): + return lambda function: function + + +module("homeassistant") +module("homeassistant.core", HomeAssistant=object) +module("homeassistant.util", slugify=lambda value: str(value).lower().replace(" ", "_")) +module("guarded") +module("guarded.tools") +module("guarded.identity", user_context=lambda: None) +module("guarded.protocol", ToolError=ToolError, internal_error=lambda message, error: RuntimeError(f"{message}: {error}")) +module( + "guarded.registry", + LIMIT_FIELD={"type": "integer"}, + OFFSET_FIELD={"type": "integer"}, + paginate=lambda items, limit, offset: {"items": items[offset : offset + limit]}, + schema=lambda **kwargs: kwargs, + tool=decorator, +) + +source = pathlib.Path(__file__).parents[1] / "platform/mcp/patches/hass_mcp/yaml_config.py" +spec = importlib.util.spec_from_file_location("guarded.tools.yaml_config", source) +assert spec and spec.loader +guard = importlib.util.module_from_spec(spec) +sys.modules[spec.name] = guard +spec.loader.exec_module(guard) + +assert set(guard._KINDS) == {"automation", "script", "scene", "configuration"} +assert all("secret" not in filename for filename, _, _ in guard._KINDS.values()) +assert guard._redact_line("api_key: abc") == "api_key: " +assert guard._redact_line("token: abc") == "token: " +assert guard._redact_line("value: !secret private_name") == "value: !secret " +assert guard._redact_line("alias: Safe automation") == "alias: Safe automation" + +for sensitive in ("token: abc", "password: abc", "value: !secret private_name"): + try: + guard._reject_sensitive_replacement(sensitive) + except ToolError: + pass + else: + raise AssertionError(f"sensitive replacement was accepted: {sensitive}") + +change = guard._change_record("automation", "update", "before", {"id": "demo"}) +preview = guard._preview(change) +assert preview["changed"] is False +assert preview["confirmation_required"] is True +guard._consume_ticket(change, preview["approval_ticket"]) +try: + guard._consume_ticket(change, preview["approval_ticket"]) +except ToolError: + pass +else: + raise AssertionError("one-time approval ticket was reusable") + +other = guard._change_record("automation", "update", "different", {"id": "demo"}) +ticket = guard._preview(change)["approval_ticket"] +try: + guard._consume_ticket(other, ticket) +except ToolError: + pass +else: + raise AssertionError("ticket accepted a different current file fingerprint") + +diff = guard._source_diff("automations.yaml", "a\nb\n", "a\nc\n") +assert any("-b" in line for line in diff) +assert any("+c" in line for line in diff) + +print("hass_mcp_yaml_guard_tests=ok") diff --git a/docs/INSTALLATION.md b/docs/INSTALLATION.md index 87aea61..91ace34 100644 --- a/docs/INSTALLATION.md +++ b/docs/INSTALLATION.md @@ -109,6 +109,13 @@ dem internen Router und dessen aktuellem Schlüssel. Das ist erforderlich, weil persistente Providerwerte nach einer Schlüsselrotation Vorrang vor den Container-Umgebungsvariablen haben. +Der Modellinstaller setzt außerdem für den ermittelten OpenWebUI-Benutzer das +native Chat-Hintergrundbild `/static/midnight-aurora.svg`. Das Bild wird durch +Compose read-only eingebunden. `custom.css` verändert bewusst nicht mehr die +strukturellen Chat-Layer, damit OpenWebUIs eigene Bildfläche, Kontrast-Overlay +und Mobilansicht funktionieren. Ein bestehender Benutzer kann denselben Wert +auch unter **Einstellungen → Oberfläche → Chat Background Image** ändern. + ## Werkzeug-Container Der Installer startet Websuche automatisch in einem privaten Docker-Netz. diff --git a/platform/mcp/README.md b/platform/mcp/README.md index b8d869b..5dbd55d 100644 --- a/platform/mcp/README.md +++ b/platform/mcp/README.md @@ -117,6 +117,40 @@ Schreibende Aktionen bleiben hinter der jeweiligen serverseitigen Policy und einem Vorschau-/Bestätigungsablauf. Ein Client-Schalter allein darf niemals eine read-only Policy aufheben. +## Home Assistant: abgesicherter YAML-Zugang + +Die Referenzinstallation überlagert im nativen `czechbol/hass-mcp` das Werkzeug +`ha_yaml_config` mit +`patches/hass_mcp/yaml_config.py`. Es erlaubt strukturierte Zugriffe nur auf +`automations.yaml`, `scripts.yaml` und `scenes.yaml`. Für auskommentierte Blöcke +stehen begrenztes `read_source` und `find_source` zur Verfügung; +`configuration.yaml` darf dabei ebenfalls gelesen werden. Beliebige Pfade und +`secrets.yaml` sind konstruktiv ausgeschlossen. Verdächtige Inline-Zugangsdaten +und selbst Namen von `!secret`-Referenzen werden in Rohtextantworten maskiert. + +Jede Änderung arbeitet zweistufig: Vorschau mit einmaligem Ticket, anschließend +derselbe unveränderte Aufruf mit `confirm=true` nach ausdrücklicher Zustimmung. +Vor dem atomaren Schreiben wird eine lokale Sicherung erzeugt. Danach läuft die +vollständige Home-Assistant-Konfigurationsprüfung; bei Fehlern oder gescheitertem +Reload wird automatisch zurückgerollt. `configuration.yaml` wird nicht live neu +geladen und meldet deshalb nach einer erfolgreichen Änderung +`restart_required=true`. + +Installation auf dem Host, der das Home-Assistant-Konfigurationsverzeichnis +besitzt: + +```bash +sudo platform/mcp/install-hass-mcp-yaml-guard.sh \ + /mnt/user/appdata/HomeAssistant/config +``` + +Danach Home Assistant kontrolliert neu starten und den Werkzeugkatalog prüfen. +Der Installer aktiviert **nicht** pauschal Schreibrechte: In Home Assistant unter +**Einstellungen → Geräte & Dienste → Native MCP for Home Assistant → Konfigurieren** +muss `Allow write tools` bewusst eingeschaltet werden. Das gibt auch anderen +nicht-destruktiven Schreibwerkzeugen dieser Integration Zugriff und sollte daher +nur zusammen mit sichtbarer Tool-Freigabe im Client aktiviert werden. + ## Sonarr: sichere Episodensuche Der lokale Sonarr-Patch stellt bewusst keine freie Sonarr-Command-API bereit. diff --git a/platform/mcp/install-hass-mcp-yaml-guard.sh b/platform/mcp/install-hass-mcp-yaml-guard.sh new file mode 100755 index 0000000..4f80997 --- /dev/null +++ b/platform/mcp/install-hass-mcp-yaml-guard.sh @@ -0,0 +1,27 @@ +#!/usr/bin/env bash +set -Eeuo pipefail +umask 077 + +repo_dir=$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd) +source_file=$repo_dir/platform/mcp/patches/hass_mcp/yaml_config.py +ha_config_dir=${1:-} + +die() { printf 'FEHLER: %s\n' "$*" >&2; exit 1; } +[[ $EUID -eq 0 ]] || die "Bitte als root auf dem Home-Assistant-Host ausführen." +[[ -n $ha_config_dir ]] || die "Aufruf: $0 /pfad/zum/home-assistant-config" +[[ -s $source_file ]] || die "Patchdatei fehlt: $source_file" + +target=$ha_config_dir/custom_components/hass_mcp/tools/yaml_config.py +[[ -s $target ]] || die "Native hass_mcp-Installation nicht gefunden: $target" + +backup_dir=$ha_config_dir/.hass_mcp_patch_backups +mkdir -p "$backup_dir" +chmod 700 "$backup_dir" +stamp=$(date +%Y%m%d-%H%M%S) +backup=$backup_dir/yaml_config.py.before-guard-$stamp +cp -p "$target" "$backup" +chmod 600 "$backup" +install -m 0644 "$source_file" "$target" + +printf 'YAML Guard installiert. Sicherung: %s\n' "$backup" +printf 'Home Assistant muss jetzt kontrolliert neu gestartet werden.\n' diff --git a/platform/mcp/patches/hass_mcp/yaml_config.py b/platform/mcp/patches/hass_mcp/yaml_config.py new file mode 100644 index 0000000..c8d7080 --- /dev/null +++ b/platform/mcp/patches/hass_mcp/yaml_config.py @@ -0,0 +1,621 @@ +"""Guarded YAML access for Home Assistant configuration files. + +The language model is untrusted. Reads are bounded and redact likely inline +credentials. Every mutation is previewed first, bound to the exact current file +hash, backed up, written atomically, checked by Home Assistant and rolled back +when validation or reload fails. ``secrets.yaml`` is never addressable. +""" + +from __future__ import annotations + +import copy +import difflib +import hashlib +import json +import os +import re +import secrets +import time +from pathlib import Path +from typing import Any + +from homeassistant.core import HomeAssistant +from homeassistant.util import slugify + +from ..identity import user_context +from ..protocol import ToolError, internal_error +from ..registry import LIMIT_FIELD, OFFSET_FIELD, paginate, schema, tool + +# kind -> (filename, parsed structure, reload service domain) +_KINDS: dict[str, tuple[str, str, str | None]] = { + "automation": ("automations.yaml", "list", "automation"), + "script": ("scripts.yaml", "dict", "script"), + "scene": ("scenes.yaml", "list", "scene"), + # configuration.yaml is intentionally raw-read/replace only. Treating its + # top-level keys as CRUD records would be dangerously misleading. + "configuration": ("configuration.yaml", "dict", None), +} +_STRUCTURED_KINDS = frozenset({"automation", "script", "scene"}) +_OPS = ( + "list", + "get", + "read_source", + "find_source", + "list_backups", + "create", + "update", + "delete", + "replace_source_text", + "restore_backup", + "reload", +) +_MUTATING_OPS = frozenset({"create", "update", "delete", "replace_source_text", "restore_backup"}) +_TICKET_TTL_SECONDS = 600 +_MAX_SOURCE_BYTES = 2 * 1024 * 1024 +_MAX_REPLACEMENT_CHARS = 50_000 +_PENDING: dict[str, dict[str, Any]] = {} +_SENSITIVE_LINE = re.compile( + r"(?i)^(?P\s*[^#\n]*(?:password|passwd|token|secret|api[_-]?key|authorization)[^:]*:\s*).*$" +) +_SECRET_REFERENCE = re.compile(r"!secret\s+[^\s#]+", re.IGNORECASE) + + +@tool( + name="ha_yaml_config", + description=( + "Safely inspect and edit Home Assistant YAML. Structured CRUD is limited to " + "automations.yaml, scripts.yaml and scenes.yaml. Raw source operations also " + "allow configuration.yaml so commented-out blocks can be found and reviewed. " + "secrets.yaml and arbitrary paths are impossible. Use read_source/find_source " + "for comments or exact YAML text. Every mutation first returns a diff/preview " + "and one-time approval_ticket; only repeat the exact unchanged call with " + "confirm=true after explicit user approval. Writes create a backup, are atomic, " + "run Home Assistant config validation, roll back on failure, and reload the " + "affected domain when supported. Never claim a preview changed Home Assistant." + ), + input_schema=schema( + properties={ + "kind": {"type": "string", "enum": list(_KINDS)}, + "op": {"type": "string", "enum": list(_OPS)}, + "id": { + "type": "string", + "description": "Entry id for structured get/create/update/delete.", + }, + "config": { + "type": "object", + "additionalProperties": True, + "description": "Complete entry config for structured create/update.", + }, + "query": { + "type": "string", + "maxLength": 500, + "description": "Case-insensitive literal text for find_source.", + }, + "start_line": { + "type": "integer", + "minimum": 1, + "default": 1, + "description": "First 1-based line returned by read_source.", + }, + "max_lines": { + "type": "integer", + "minimum": 1, + "maximum": 400, + "default": 120, + "description": "Bounded source lines returned by read_source/find_source.", + }, + "old_text": { + "type": "string", + "minLength": 1, + "maxLength": _MAX_REPLACEMENT_CHARS, + "description": "Exact unique YAML source text to replace.", + }, + "new_text": { + "type": "string", + "maxLength": _MAX_REPLACEMENT_CHARS, + "description": "Replacement YAML source text; may be empty to remove a block.", + }, + "backup_id": { + "type": "string", + "pattern": r"^[a-z0-9_.-]+$", + "description": "Opaque filename returned by list_backups.", + }, + "confirm": { + "type": "boolean", + "default": False, + "description": "True only after the user approved the exact preview.", + }, + "approval_ticket": { + "type": "string", + "description": "One-time ticket from the unchanged mutation preview.", + }, + "limit": LIMIT_FIELD, + "offset": OFFSET_FIELD, + }, + required=["kind", "op"], + ), + read_only=False, + idempotent=False, + requires_admin=True, + write_ops=["create", "update", "replace_source_text", "reload"], + destructive_ops=["delete", "restore_backup"], + admin_ops=["list", "get", "read_source", "find_source", "list_backups"], +) +async def ha_yaml_config( + hass: HomeAssistant, + kind: str, + op: str, + id: str | None = None, + config: dict[str, Any] | None = None, + query: str | None = None, + start_line: int = 1, + max_lines: int = 120, + old_text: str | None = None, + new_text: str | None = None, + backup_id: str | None = None, + confirm: bool = False, + approval_ticket: str | None = None, + limit: int = 100, + offset: int = 0, +) -> dict[str, Any]: + if kind not in _KINDS: + raise ToolError(f"unknown kind '{kind}'") + if op not in _OPS: + raise ToolError(f"unknown op '{op}'") + + filename, structure, reload_domain = _KINDS[kind] + path = Path(hass.config.path(filename)) + + if op in {"list", "get", "create", "update", "delete", "reload"} and kind not in _STRUCTURED_KINDS: + raise ToolError( + "configuration.yaml supports only read_source, find_source, list_backups, " + "replace_source_text and restore_backup" + ) + + if op == "read_source": + return await _read_source(hass, path, start_line, max_lines) + if op == "find_source": + if not query: + raise ToolError("op=find_source requires query") + return await _find_source(hass, path, query, max_lines) + if op == "list_backups": + return await _list_backups(hass, filename, limit, offset) + if op == "replace_source_text": + if old_text is None or new_text is None: + raise ToolError("op=replace_source_text requires old_text and new_text") + _reject_sensitive_replacement(old_text, new_text) + current = await _read_text(hass, path) + if current.count(old_text) != 1: + raise ToolError( + f"old_text must occur exactly once in {filename}; found {current.count(old_text)} occurrences" + ) + proposed = current.replace(old_text, new_text, 1) + await _validate_yaml_text(hass, proposed, structure, filename) + change = _change_record(kind, op, current, {"old_text": old_text, "new_text": new_text}) + if not confirm: + return _preview(change, _source_diff(filename, current, proposed)) + _consume_ticket(change, approval_ticket) + return await _commit_text(hass, path, filename, structure, reload_domain, current, proposed) + if op == "restore_backup": + if not backup_id: + raise ToolError("op=restore_backup requires backup_id from list_backups") + current = await _read_text(hass, path) + restored = await _read_backup(hass, filename, backup_id) + await _validate_yaml_text(hass, restored, structure, filename) + change = _change_record(kind, op, current, {"backup_id": backup_id}) + if not confirm: + return _preview(change, _source_diff(filename, current, restored), extra={"backup_id": backup_id}) + _consume_ticket(change, approval_ticket) + return await _commit_text(hass, path, filename, structure, reload_domain, current, restored) + + data = await _load(hass, path, structure) + if op == "list": + return paginate(_to_list(data, structure), limit, offset) + if op == "get": + if not id: + raise ToolError("op=get requires id") + item = _find(data, structure, id) + if item is None: + raise ToolError(f"{kind} '{id}' not found in {filename}") + return item + if op == "reload": + await _reload(hass, reload_domain) + return {"reloaded": reload_domain, "changed_file": False} + + current_text = await _read_text(hass, path) + proposed_data = _copy_data(data) + result: dict[str, Any] + if op == "create": + if not config: + raise ToolError("op=create requires config") + # The generated id must be deterministic so the exact preview can be + # confirmed in a second call without silently proposing another entry. + generated_id = f"mcp_{hashlib.sha256(json.dumps(config, sort_keys=True).encode()).hexdigest()[:16]}" + new_id = id or config.get("id") or generated_id + if _find(proposed_data, structure, new_id) is not None: + raise ToolError(f"{kind} '{new_id}' already exists") + if structure == "list": + proposed_data.append({"id": new_id, **{k: v for k, v in config.items() if k != "id"}}) + else: + proposed_data[new_id] = config + result = { + "operation": "create", + "id": new_id, + "proposed_entry": _find(proposed_data, structure, new_id), + } + elif op == "update": + if not id or not config: + raise ToolError("op=update requires id and config") + before = _find(proposed_data, structure, id) + if before is None or not _replace(proposed_data, structure, id, config): + raise ToolError(f"{kind} '{id}' not found") + result = {"operation": "update", "id": id, "current_entry": before, "proposed_entry": _find(proposed_data, structure, id)} + elif op == "delete": + if not id: + raise ToolError("op=delete requires id") + before = _find(proposed_data, structure, id) + if before is None or not _remove(proposed_data, structure, id): + raise ToolError(f"{kind} '{id}' not found") + result = {"operation": "delete", "id": id, "current_entry": before} + else: + raise ToolError(f"unsupported op '{op}'") + + change = _change_record(kind, op, current_text, {"id": id, "config": config, "result": result}) + if not confirm: + return _preview(change, extra=result) + _consume_ticket(change, approval_ticket) + committed = await _commit_data(hass, path, filename, structure, reload_domain, current_text, proposed_data) + return {**result, **committed} + + +def _copy_data(data: Any) -> Any: + return copy.deepcopy(data) + + +def _fingerprint(text: str) -> str: + return hashlib.sha256(text.encode("utf-8")).hexdigest() + + +def _change_record(kind: str, op: str, current: str, arguments: dict[str, Any]) -> dict[str, Any]: + return { + "kind": kind, + "op": op, + "current_sha256": _fingerprint(current), + "arguments": arguments, + } + + +def _new_ticket(change: dict[str, Any]) -> str: + now = time.time() + for key, value in list(_PENDING.items()): + if value["expires_at"] <= now: + _PENDING.pop(key, None) + ticket = secrets.token_urlsafe(18) + _PENDING[ticket] = { + "fingerprint": _fingerprint(json.dumps(change, sort_keys=True, separators=(",", ":"))), + "expires_at": now + _TICKET_TTL_SECONDS, + } + return ticket + + +def _consume_ticket(change: dict[str, Any], ticket: str | None) -> None: + record = _PENDING.pop(ticket, None) if ticket else None + expected = _fingerprint(json.dumps(change, sort_keys=True, separators=(",", ":"))) + if not record or record["expires_at"] <= time.time() or record["fingerprint"] != expected: + raise ToolError( + "approval_ticket is missing, expired, already used, or does not match the exact " + "change/current file. Run the same operation without confirm, show the preview, " + "then repeat unchanged with confirm=true only after explicit user approval." + ) + + +def _preview(change: dict[str, Any], diff: list[str] | None = None, extra: dict[str, Any] | None = None) -> dict[str, Any]: + return { + "changed": False, + "confirmation_required": True, + "approval_ticket": _new_ticket(change), + "ticket_expires_in_seconds": _TICKET_TTL_SECONDS, + "current_sha256": change["current_sha256"], + **(extra or {}), + **({"diff": diff, "diff_truncated": len(diff) >= 120} if diff is not None else {}), + "model_instruction": ( + "This is a preview only. Show it to the user and stop. Do not claim anything was " + "changed. After explicit approval repeat the exact call with confirm=true and approval_ticket." + ), + } + + +def _redact_line(line: str) -> str: + match = _SENSITIVE_LINE.match(line) + if match: + return f"{match.group('prefix')}" + return _SECRET_REFERENCE.sub("!secret ", line) + + +def _reject_sensitive_replacement(*values: str) -> None: + for value in values: + if any(_SENSITIVE_LINE.match(line) for line in value.splitlines()) or _SECRET_REFERENCE.search(value): + raise ToolError( + "Raw replacement containing credential-like keys or !secret references is refused. " + "Edit that material locally outside the LLM context." + ) + + +async def _read_text(hass: HomeAssistant, path: Path) -> str: + def _read() -> str: + if not path.exists(): + return "" + if path.stat().st_size > _MAX_SOURCE_BYTES: + raise ToolError(f"{path.name} exceeds the {_MAX_SOURCE_BYTES} byte safety limit") + return path.read_text(encoding="utf-8") + + return await hass.async_add_executor_job(_read) + + +async def _read_source(hass: HomeAssistant, path: Path, start_line: int, max_lines: int) -> dict[str, Any]: + text = await _read_text(hass, path) + lines = text.splitlines() + start = max(1, start_line) + count = max(1, min(max_lines, 400)) + selected = lines[start - 1 : start - 1 + count] + return { + "file": path.name, + "sha256": _fingerprint(text), + "total_lines": len(lines), + "start_line": start, + "returned_lines": len(selected), + "has_more": start - 1 + len(selected) < len(lines), + "lines": [{"line": start + index, "text": _redact_line(line)} for index, line in enumerate(selected)], + "redaction_note": "Credential-like values and !secret reference names are redacted.", + } + + +async def _find_source(hass: HomeAssistant, path: Path, query: str, max_lines: int) -> dict[str, Any]: + text = await _read_text(hass, path) + lines = text.splitlines() + hits = [index for index, line in enumerate(lines) if query.casefold() in line.casefold()] + cap = max(1, min(max_lines, 400)) + selected = hits[:cap] + return { + "file": path.name, + "sha256": _fingerprint(text), + "authoritative_match_count": len(hits), + "returned_count": len(selected), + "has_more": len(hits) > len(selected), + "matches": [{"line": index + 1, "text": _redact_line(lines[index])} for index in selected], + "redaction_note": "Credential-like values and !secret reference names are redacted.", + } + + +def _source_diff(filename: str, before: str, after: str) -> list[str]: + return list( + difflib.unified_diff( + before.splitlines(), + after.splitlines(), + fromfile=f"{filename}:before", + tofile=f"{filename}:after", + lineterm="", + n=3, + ) + )[:120] + + +def _backup_dir(hass: HomeAssistant) -> Path: + return Path(hass.config.path(".hass_mcp_backups", "yaml")) + + +async def _create_backup(hass: HomeAssistant, filename: str, content: str) -> str: + backup_id = f"{filename}.{time.strftime('%Y%m%d-%H%M%S')}.{_fingerprint(content)[:10]}.bak" + directory = _backup_dir(hass) + + def _write() -> None: + directory.mkdir(mode=0o700, parents=True, exist_ok=True) + target = directory / backup_id + target.write_text(content, encoding="utf-8") + target.chmod(0o600) + + await hass.async_add_executor_job(_write) + return backup_id + + +async def _list_backups(hass: HomeAssistant, filename: str, limit: int, offset: int) -> dict[str, Any]: + directory = _backup_dir(hass) + + def _list() -> list[dict[str, Any]]: + if not directory.exists(): + return [] + rows = [] + for path in directory.glob(f"{filename}.*.bak"): + stat = path.stat() + rows.append({"backup_id": path.name, "size": stat.st_size, "created_unix": int(stat.st_mtime)}) + return sorted(rows, key=lambda row: row["created_unix"], reverse=True) + + return paginate(await hass.async_add_executor_job(_list), limit, offset) + + +async def _read_backup(hass: HomeAssistant, filename: str, backup_id: str) -> str: + if Path(backup_id).name != backup_id or not backup_id.startswith(f"{filename}.") or not backup_id.endswith(".bak"): + raise ToolError("backup_id is not valid for this YAML kind") + path = _backup_dir(hass) / backup_id + + def _read() -> str: + if not path.is_file(): + raise ToolError("backup_id not found") + if path.stat().st_size > _MAX_SOURCE_BYTES: + raise ToolError("backup exceeds safety limit") + return path.read_text(encoding="utf-8") + + return await hass.async_add_executor_job(_read) + + +async def _validate_yaml_text(hass: HomeAssistant, content: str, structure: str, filename: str) -> Any: + from homeassistant.util.yaml import parse_yaml + + def _parse() -> Any: + parsed = parse_yaml(content) if content.strip() else ([] if structure == "list" else {}) + if structure == "list" and not isinstance(parsed, list): + raise ToolError(f"{filename} must be a YAML list, got {type(parsed).__name__}") + if structure == "dict" and not isinstance(parsed, dict): + raise ToolError(f"{filename} must be a YAML mapping, got {type(parsed).__name__}") + return parsed + + return await hass.async_add_executor_job(_parse) + + +async def _check_full_config(hass: HomeAssistant) -> dict[str, Any]: + try: + from homeassistant.components.config.core import async_check_ha_config_file + except ImportError: + from homeassistant.config import async_check_ha_config_file + + result = await async_check_ha_config_file(hass) + if result is None: + return {"valid": True} + if isinstance(result, str): + return {"valid": not bool(result), "error": result or None} + errors = getattr(result, "errors", None) + if errors: + return {"valid": False, "error": str(errors)} + return {"valid": True, "result": str(result)} + + +async def _atomic_write(hass: HomeAssistant, path: Path, content: str) -> None: + def _write() -> None: + temporary = path.with_name(f".{path.name}.hass-mcp-{secrets.token_hex(6)}.tmp") + try: + with temporary.open("w", encoding="utf-8") as handle: + handle.write(content) + handle.flush() + os.fsync(handle.fileno()) + os.replace(temporary, path) + finally: + if temporary.exists(): + temporary.unlink() + + await hass.async_add_executor_job(_write) + + +async def _commit_text( + hass: HomeAssistant, + path: Path, + filename: str, + structure: str, + reload_domain: str | None, + before: str, + proposed: str, +) -> dict[str, Any]: + current = await _read_text(hass, path) + if _fingerprint(current) != _fingerprint(before): + raise ToolError("YAML file changed after preview; refusing stale write and requiring a new preview") + await _validate_yaml_text(hass, proposed, structure, filename) + backup_id = await _create_backup(hass, filename, before) + await _atomic_write(hass, path, proposed) + validation = await _check_full_config(hass) + if not validation["valid"]: + await _atomic_write(hass, path, before) + raise ToolError(f"Home Assistant config validation failed; original restored from {backup_id}: {validation.get('error')}") + try: + if reload_domain: + await _reload(hass, reload_domain) + except Exception: + await _atomic_write(hass, path, before) + if reload_domain: + try: + await _reload(hass, reload_domain) + except Exception: + pass + raise + readback = await _read_text(hass, path) + return { + "changed": readback == proposed, + "file": filename, + "backup_id": backup_id, + "full_config_valid": True, + "reloaded": reload_domain, + "restart_required": reload_domain is None, + "new_sha256": _fingerprint(readback), + "exact_readback_match": readback == proposed, + } + + +async def _commit_data( + hass: HomeAssistant, + path: Path, + filename: str, + structure: str, + reload_domain: str | None, + before: str, + data: Any, +) -> dict[str, Any]: + from homeassistant.util.yaml import save_yaml + + def _render() -> str: + temporary = path.with_name(f".{path.name}.hass-mcp-render-{secrets.token_hex(6)}.tmp") + try: + save_yaml(str(temporary), data) + return temporary.read_text(encoding="utf-8") + finally: + if temporary.exists(): + temporary.unlink() + + proposed = await hass.async_add_executor_job(_render) + return await _commit_text(hass, path, filename, structure, reload_domain, before, proposed) + + +async def _load(hass: HomeAssistant, path: Path, structure: str) -> Any: + return await _validate_yaml_text(hass, await _read_text(hass, path), structure, path.name) + + +async def _reload(hass: HomeAssistant, domain: str | None) -> None: + if not domain: + return + try: + await hass.services.async_call(domain, "reload", {}, blocking=True, context=user_context()) + except Exception as error: + raise internal_error(f"{domain}.reload failed", error) from error + + +def _derive_entity_id(domain: str, structure: str, new_id: str, config: dict[str, Any]) -> str: + slug = slugify(new_id) if structure == "dict" else slugify(config.get("alias") or new_id) + return f"{domain}.{slug}" + + +def _to_list(data: Any, structure: str) -> list[dict[str, Any]]: + if structure == "list": + return list(data) + return [{"id": key, **value} for key, value in data.items()] + + +def _find(data: Any, structure: str, id: str) -> dict[str, Any] | None: + if structure == "list": + for entry in data: + if entry.get("id") == id or entry.get("alias") == id: + return entry + return None + return {"id": id, **data[id]} if id in data else None + + +def _replace(data: Any, structure: str, id: str, new: dict[str, Any]) -> bool: + if structure == "list": + for index, entry in enumerate(data): + if entry.get("id") == id: + data[index] = {"id": id, **{key: value for key, value in new.items() if key != "id"}} + return True + return False + if id in data: + data[id] = new + return True + return False + + +def _remove(data: Any, structure: str, id: str) -> bool: + if structure == "list": + for index, entry in enumerate(data): + if entry.get("id") == id: + del data[index] + return True + return False + if id in data: + del data[id] + return True + return False diff --git a/platform/openwebui/install-filters.sh b/platform/openwebui/install-filters.sh index 7ffc8a6..f74a1f3 100755 --- a/platform/openwebui/install-filters.sh +++ b/platform/openwebui/install-filters.sh @@ -153,8 +153,10 @@ with con: ), "homeassistant-local": ( "Home Assistant (lokal)", - "Nur für Home-Assistant-Entitäten, Zustände, Historie, Automationen, Dashboards " - "und HA-Diagnose. Nicht für Unraid, Sonarr/Radarr oder allgemeine Websuche.", + "Für Home-Assistant-Entitäten, Zustände, Historie, Automationen, Dashboards, " + "HA-Diagnose und freigegebene YAML-Dateien. YAML-Lesen ist begrenzt; Änderungen " + "benötigen serverseitige Vorschau, explizite Freigabe, Sicherung und Validierung. " + "Nicht für Unraid, Sonarr/Radarr oder allgemeine Websuche.", ), "arr-local": ( "Sonarr und Radarr (lokal)", diff --git a/platform/openwebui/install-models.sh b/platform/openwebui/install-models.sh index ce7c9ef..abec427 100644 --- a/platform/openwebui/install-models.sh +++ b/platform/openwebui/install-models.sh @@ -72,6 +72,16 @@ if not owner: ) owner = admins[0][0] +# Use Open WebUI's native per-user chat background. Keeping the value in the +# user's normal settings means the built-in contrast overlay, mobile layout +# and future UI migrations continue to work; custom.css must not replace this +# layer. +row = con.execute("select settings from user where id=?", (owner,)).fetchone() +if not row: + raise SystemExit("OpenWebUI model owner does not exist in user table.") +user_settings = json.loads(row[0] or "{}") +user_settings.setdefault("ui", {})["backgroundImageUrl"] = "/static/midnight-aurora.svg" + now = int(time.time()) filter_ids = [ "reasoning_default_off", @@ -226,6 +236,10 @@ def set_config(key, value): ) with con: + con.execute( + "update user set settings=? where id=?", + (json.dumps(user_settings, ensure_ascii=False), owner), + ) router_api_key = os.environ.get("OPENWEBUI_ROUTER_API_KEY", "") if router_api_key: set_config("openai.enable", True) diff --git a/platform/openwebui/theme/custom.css b/platform/openwebui/theme/custom.css index e7977fe..944af7e 100644 --- a/platform/openwebui/theme/custom.css +++ b/platform/openwebui/theme/custom.css @@ -1,20 +1,16 @@ /* - * Mike AI "Midnight Aurora" theme for Open WebUI. + * Mike AI Open WebUI refinements. * - * Open WebUI deliberately loads /static/custom.css from its base image. The - * file is mounted read-only by compose.yaml, so the theme survives container - * recreation and image updates without modifying the upstream image. + * The chat background itself is configured through Open WebUI's native + * per-user `backgroundImageUrl` setting. Do not make the structural chat + * layers transparent here: that would also hide Open WebUI's own image layer. */ :root { color-scheme: dark; --mike-ai-bg: #070a12; - --mike-ai-panel: rgba(15, 20, 32, 0.86); --mike-ai-panel-strong: rgba(12, 16, 27, 0.96); --mike-ai-border: rgba(148, 163, 184, 0.13); - --mike-ai-blue: rgba(59, 130, 246, 0.30); - --mike-ai-violet: rgba(139, 92, 246, 0.25); - --mike-ai-teal: rgba(20, 184, 166, 0.18); } html, @@ -22,91 +18,6 @@ body { background-color: var(--mike-ai-bg) !important; } -body { - min-height: 100vh; - background-image: - radial-gradient(circle at 12% 18%, var(--mike-ai-blue) 0, transparent 32%), - radial-gradient(circle at 88% 12%, var(--mike-ai-violet) 0, transparent 30%), - radial-gradient(circle at 68% 88%, var(--mike-ai-teal) 0, transparent 34%), - linear-gradient(145deg, #070a12 0%, #0b1020 52%, #080b14 100%) !important; - background-attachment: fixed !important; - background-position: 0% 30%, 100% 0%, 60% 100%, center !important; - background-size: 150% 150%, 145% 145%, 155% 155%, cover !important; - animation: mike-ai-aurora 30s ease-in-out infinite alternate; -} - -/* Keep the app above the decorative background without intercepting clicks. */ -#app { - position: relative; - min-height: 100vh; - isolation: isolate; -} - -/* Keep the aurora inside the application stacking context as well. The login - * page already exposes the body background, whereas the authenticated app - * creates its own full-screen stacking context. */ -#app::before { - content: ''; - position: fixed; - inset: 0; - z-index: -1; - pointer-events: none; - background-image: - radial-gradient(circle at 12% 18%, var(--mike-ai-blue) 0, transparent 32%), - radial-gradient(circle at 88% 12%, var(--mike-ai-violet) 0, transparent 30%), - radial-gradient(circle at 68% 88%, var(--mike-ai-teal) 0, transparent 34%), - linear-gradient(145deg, #070a12 0%, #0b1020 52%, #080b14 100%); - background-position: 0% 30%, 100% 0%, 60% 100%, center; - background-size: 150% 150%, 145% 145%, 155% 155%, cover; - animation: mike-ai-aurora 30s ease-in-out infinite alternate; -} - -/* Open WebUI's full-screen wrappers use Tailwind variant class names such as - * "dark:bg-gray-900". They must be transparent or they hide the body theme. */ -.dark #app .app > [class~='dark:bg-gray-900'], -.dark #auth-page > [class~='dark:bg-black'] { - background-color: transparent !important; -} - -/* Open WebUI 0.9.x authenticated/chat layout. These are structural, - * full-screen layers, not message cards, menus or dialogs. */ -.dark #app > div, -.dark #app .app, -.dark #app .app > div, -.dark #chat-container, -.dark #chat-container > div, -.dark #chat-container > div > div { - background-color: transparent !important; - background-image: none !important; - backdrop-filter: none !important; -} - -/* The remaining large application surfaces are slightly translucent so the - * aurora is visible, while content, dialogs and editors stay readable. */ -.dark #app [class~='dark:bg-gray-950'], -.dark #app [class~='dark:bg-gray-900'], -.dark #app [class~='dark:bg-gray-850'] { - background-color: var(--mike-ai-panel) !important; - backdrop-filter: blur(18px) saturate(115%); -} - -/* The two full-screen wrappers above need to win over the general panel rule. */ -.dark #app .app > [class~='dark:bg-gray-900'], -.dark #auth-page > [class~='dark:bg-black'] { - background-color: transparent !important; - backdrop-filter: none; -} - -/* Retain glassy controls inside the chat without turning the entire canvas - * back into a flat dark rectangle. This intentionally follows the general - * panel rule so the chat-specific opacity wins the cascade. */ -.dark #chat-container [class~='dark:bg-gray-950'], -.dark #chat-container [class~='dark:bg-gray-900'], -.dark #chat-container [class~='dark:bg-gray-850'] { - background-color: rgba(15, 20, 32, 0.70) !important; - backdrop-filter: blur(16px) saturate(115%); -} - .dark [role='dialog'], .dark dialog, .dark pre, @@ -123,35 +34,7 @@ body { border-color: var(--mike-ai-border) !important; } -/* A restrained accent for focused controls; no neon-glow or reduced contrast. */ .dark :is(button, a, input, textarea, select):focus-visible { outline-color: rgba(96, 165, 250, 0.78) !important; outline-offset: 2px; } - -@keyframes mike-ai-aurora { - 0% { - background-position: 0% 20%, 100% 0%, 65% 100%, center; - } - 50% { - background-position: 18% 36%, 84% 14%, 48% 82%, center; - } - 100% { - background-position: 4% 48%, 96% 28%, 72% 70%, center; - } -} - -@media (prefers-reduced-motion: reduce) { - body, - #app::before { - animation: none !important; - } -} - -@media (max-width: 768px) { - body { - /* Less compositing work and a calmer background on tablets and phones. */ - background-size: 125% 125%, 125% 125%, 135% 135%, cover !important; - animation-duration: 40s; - } -} diff --git a/platform/openwebui/theme/loader.js b/platform/openwebui/theme/loader.js index 2f01fd6..c294e19 100644 --- a/platform/openwebui/theme/loader.js +++ b/platform/openwebui/theme/loader.js @@ -7,7 +7,7 @@ if (themeStylesheet) { themeStylesheet.setAttribute( 'href', - '/static/custom.css?theme=midnight-aurora-v3' + '/static/custom.css?theme=native-background-v4' ); } diff --git a/platform/openwebui/theme/midnight-aurora.svg b/platform/openwebui/theme/midnight-aurora.svg new file mode 100644 index 0000000..5434e8a --- /dev/null +++ b/platform/openwebui/theme/midnight-aurora.svg @@ -0,0 +1,33 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +