Add native chat background and guarded HA YAML access

This commit is contained in:
Mikei386
2026-08-22 22:12:14 +02:00
parent b338c91878
commit ced8d36a0c
11 changed files with 838 additions and 125 deletions
+2 -1
View File
@@ -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}"]
+91
View File
@@ -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: <redacted>"
assert guard._redact_line("token: abc") == "token: <redacted>"
assert guard._redact_line("value: !secret private_name") == "value: !secret <redacted-reference>"
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")
+7
View File
@@ -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.
+34
View File
@@ -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.
+27
View File
@@ -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'
@@ -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<prefix>\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')}<redacted>"
return _SECRET_REFERENCE.sub("!secret <redacted-reference>", 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
+4 -2
View File
@@ -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)",
+14
View File
@@ -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)
+4 -121
View File
@@ -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;
}
}
+1 -1
View File
@@ -7,7 +7,7 @@
if (themeStylesheet) {
themeStylesheet.setAttribute(
'href',
'/static/custom.css?theme=midnight-aurora-v3'
'/static/custom.css?theme=native-background-v4'
);
}
@@ -0,0 +1,33 @@
<svg xmlns="http://www.w3.org/2000/svg" width="2560" height="1440" viewBox="0 0 2560 1440">
<defs>
<linearGradient id="base" x1="0" y1="0" x2="1" y2="1">
<stop offset="0" stop-color="#050811"/>
<stop offset="0.52" stop-color="#0a1020"/>
<stop offset="1" stop-color="#070a12"/>
</linearGradient>
<radialGradient id="blue" cx="0.14" cy="0.18" r="0.58">
<stop offset="0" stop-color="#2563eb" stop-opacity="0.62"/>
<stop offset="0.42" stop-color="#2563eb" stop-opacity="0.17"/>
<stop offset="1" stop-color="#2563eb" stop-opacity="0"/>
</radialGradient>
<radialGradient id="violet" cx="0.86" cy="0.12" r="0.52">
<stop offset="0" stop-color="#7c3aed" stop-opacity="0.58"/>
<stop offset="0.44" stop-color="#7c3aed" stop-opacity="0.16"/>
<stop offset="1" stop-color="#7c3aed" stop-opacity="0"/>
</radialGradient>
<radialGradient id="teal" cx="0.68" cy="0.94" r="0.55">
<stop offset="0" stop-color="#0f766e" stop-opacity="0.48"/>
<stop offset="0.46" stop-color="#0f766e" stop-opacity="0.13"/>
<stop offset="1" stop-color="#0f766e" stop-opacity="0"/>
</radialGradient>
<filter id="soft"><feGaussianBlur stdDeviation="54"/></filter>
</defs>
<rect width="2560" height="1440" fill="url(#base)"/>
<rect width="2560" height="1440" fill="url(#blue)"/>
<rect width="2560" height="1440" fill="url(#violet)"/>
<rect width="2560" height="1440" fill="url(#teal)"/>
<g fill="none" filter="url(#soft)" opacity="0.20">
<path d="M-180 1040 C420 650 760 1250 1420 820 S2250 540 2750 920" stroke="#38bdf8" stroke-width="70"/>
<path d="M-120 260 C520 580 840 110 1470 430 S2250 710 2740 260" stroke="#a78bfa" stroke-width="58"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.7 KiB