Add native chat background and guarded HA YAML access
This commit is contained in:
@@ -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.
|
||||
|
||||
Executable
+27
@@ -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
|
||||
@@ -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)",
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 |
Reference in New Issue
Block a user