112 files
This commit is contained in:
1 parent
94867222bd
commit
b1fd0abc94
112 files changed
+11348
-2701
No files matched your search
@@ -271,8 +271,15 @@ ALLOWED_READ_FILES = [
|
||||
"scenes.yaml",
|
||||
"secrets.yaml",
|
||||
"home-assistant.log",
|
||||
# faulthandler's crash dump (HA Core's ``__main__`` enables it on this file).
|
||||
# Written only on a native fatal signal, so it is the one place that
|
||||
# traceback exists: journald / error_log never see it (issue #2373).
|
||||
"home-assistant.log.fault",
|
||||
]
|
||||
|
||||
# Root-level log files that the read service tails by default.
|
||||
TAILED_LOG_FILES = frozenset({"home-assistant.log", "home-assistant.log.fault"})
|
||||
|
||||
# Default tail lines for log files
|
||||
DEFAULT_LOG_TAIL_LINES = 1000
|
||||
|
||||
@@ -1345,6 +1352,53 @@ def _read_file_sync(target_file: Path) -> dict[str, Any]:
|
||||
return {"content": content, "size": stat.st_size, "mtime": stat.st_mtime}
|
||||
|
||||
|
||||
def _read_log_tail_sync(
|
||||
target_file: Path, tail_lines: int, *, chunk_size: int = 1 << 16
|
||||
) -> dict[str, Any]:
|
||||
"""Tail a log for one executor offload without materialising the whole file.
|
||||
|
||||
HA Core appends to ``home-assistant.log.fault`` for the life of the
|
||||
install, so reading the file whole just to keep its last ``tail_lines``
|
||||
lines scales with the crash history rather than the request. Line count
|
||||
streams the file in chunks; the tail seeks back from the end until it holds
|
||||
``tail_lines`` newlines. The result carries the same ``content`` /
|
||||
``total_lines`` / ``truncated`` the whole-file split produced (``split("\n")``
|
||||
semantics, so a trailing newline yields one empty last line), plus
|
||||
``size``/``mtime`` like ``_read_file_sync``.
|
||||
"""
|
||||
if not target_file.exists():
|
||||
return {"_error": "not_found"}
|
||||
if not target_file.is_file():
|
||||
return {"_error": "not_a_file"}
|
||||
stat = target_file.stat()
|
||||
newlines = 0
|
||||
with target_file.open("rb") as fh:
|
||||
for chunk in iter(lambda: fh.read(chunk_size), b""):
|
||||
newlines += chunk.count(b"\n")
|
||||
total_lines = newlines + 1
|
||||
pos = fh.tell()
|
||||
buf = b""
|
||||
while pos > 0 and buf.count(b"\n") < tail_lines:
|
||||
step = min(chunk_size, pos)
|
||||
pos -= step
|
||||
fh.seek(pos)
|
||||
buf = fh.read(step) + buf
|
||||
parts = buf.split(b"\n")
|
||||
truncated = total_lines > tail_lines
|
||||
if truncated:
|
||||
# The leading part may start mid-character at a chunk boundary; it is
|
||||
# never part of the tail, so decode only what is returned.
|
||||
parts = parts[-tail_lines:]
|
||||
content = b"\n".join(parts).decode("utf-8")
|
||||
return {
|
||||
"content": content,
|
||||
"size": stat.st_size,
|
||||
"mtime": stat.st_mtime,
|
||||
"total_lines": total_lines,
|
||||
"truncated": truncated,
|
||||
}
|
||||
|
||||
|
||||
def _write_file_sync(
|
||||
target_file: Path,
|
||||
content: str,
|
||||
@@ -2772,25 +2826,19 @@ async def _shape_read_file_response(
|
||||
if normalized == "secrets.yaml":
|
||||
content = await hass.async_add_executor_job(_mask_secrets_content, content)
|
||||
|
||||
# Apply tail for log files
|
||||
if normalized == "home-assistant.log":
|
||||
lines = content.split("\n")
|
||||
# Log files arrive already tailed by ``_read_log_tail_sync``.
|
||||
if normalized in TAILED_LOG_FILES:
|
||||
limit = tail_lines if tail_lines else DEFAULT_LOG_TAIL_LINES
|
||||
if len(lines) > limit:
|
||||
content = "\n".join(lines[-limit:])
|
||||
truncated = True
|
||||
else:
|
||||
truncated = False
|
||||
|
||||
total_lines = result["total_lines"]
|
||||
return {
|
||||
"success": True,
|
||||
"path": rel_path,
|
||||
"content": content,
|
||||
"size": stat_size,
|
||||
"modified": modified_dt.isoformat(),
|
||||
"lines_returned": min(len(lines), limit),
|
||||
"total_lines": len(lines),
|
||||
"truncated": truncated,
|
||||
"lines_returned": min(total_lines, limit),
|
||||
"total_lines": total_lines,
|
||||
"truncated": result["truncated"],
|
||||
}
|
||||
|
||||
# Apply tail for other files if requested
|
||||
@@ -2853,13 +2901,22 @@ def _build_read_file_handler(
|
||||
)
|
||||
return {
|
||||
"success": False,
|
||||
"error": f"Path not allowed. Allowed patterns: {', '.join(allowed_patterns)}",
|
||||
"error": f"Path not allowed. Allowed paths: {', '.join(allowed_patterns)}",
|
||||
}
|
||||
|
||||
target_file = config_dir / rel_path
|
||||
|
||||
try:
|
||||
result = await hass.async_add_executor_job(_read_file_sync, target_file)
|
||||
if os.path.normpath(rel_path) in TAILED_LOG_FILES: # noqa: ASYNC240
|
||||
# Bounded read: the fault log is append-only for the life of
|
||||
# the install, so never materialise the whole file for a tail.
|
||||
result = await hass.async_add_executor_job(
|
||||
_read_log_tail_sync,
|
||||
target_file,
|
||||
tail_lines if tail_lines else DEFAULT_LOG_TAIL_LINES,
|
||||
)
|
||||
else:
|
||||
result = await hass.async_add_executor_job(_read_file_sync, target_file)
|
||||
except PermissionError:
|
||||
_LOGGER.error("Permission denied reading: %s", rel_path)
|
||||
return {
|
||||
|
||||
@@ -25,7 +25,7 @@ DOMAIN = "ha_mcp_tools"
|
||||
# in CI. The
|
||||
# capability negotiation — not this version — gates each WS command (see
|
||||
# ``websocket_api.CAPABILITIES``).
|
||||
COMPONENT_VERSION = "2.1.3"
|
||||
COMPONENT_VERSION = "2.2.0"
|
||||
|
||||
# Config-entry discriminator (``entry.data[CONF_ENTRY_TYPE]``). A missing value
|
||||
# means "tools" so the pre-existing services entry keeps working across the
|
||||
@@ -53,9 +53,10 @@ MIN_EMBEDDED_HOME_ASSISTANT_VERSION = "2026.8.0"
|
||||
# allowlist, not an absolute guarantee: an admin who adds "blueprints" as a
|
||||
# custom extra directory (issue #1567, see _current_extra_dirs) grants it
|
||||
# read+write, since extra_dirs are honored on the write path too. Blueprint
|
||||
# writes should instead go through ha_import_blueprint (which invokes the
|
||||
# blueprint/save WS command internally). Prefer ha_get_blueprint for the parsed
|
||||
# body; raw read is the escape hatch for the exact on-disk text.
|
||||
# writes should instead go through ha_manage_blueprints(action="import") (which
|
||||
# invokes the blueprint/save WS command internally). Prefer
|
||||
# ha_manage_blueprints(action="get") for the parsed body; raw read is the escape
|
||||
# hatch for the exact on-disk text.
|
||||
ALLOWED_READ_DIRS = ["www", "themes", "custom_templates", "dashboards", "blueprints"]
|
||||
ALLOWED_WRITE_DIRS = ["www", "themes", "custom_templates", "dashboards"]
|
||||
|
||||
|
||||
@@ -1520,6 +1520,49 @@ class EmbeddedServerManager:
|
||||
|
||||
# -- worker thread -----------------------------------------------------
|
||||
|
||||
def _register_embedded_connection(
|
||||
self, hamcp_config: Any, access_token: str
|
||||
) -> None:
|
||||
"""Hand the loopback URL, admin token and config dir over in memory.
|
||||
|
||||
Optional keywords are dropped one at a time, newest first, when the
|
||||
installed server rejects them with ``TypeError``: an older release
|
||||
predates ``config_dir`` (issue #2329) or ``verify_ssl`` (issue #1890).
|
||||
Only the rejected keyword is dropped — falling straight back to the
|
||||
two-argument call would re-open #1890's certificate failure on an
|
||||
SSL-enabled instance just because the server is missing an unrelated,
|
||||
newer parameter.
|
||||
"""
|
||||
kwargs: dict[str, Any] = {"config_dir": self._hass_config_dir}
|
||||
if self._loopback_verify_ssl is not None:
|
||||
kwargs["verify_ssl"] = self._loopback_verify_ssl
|
||||
for optional in ("config_dir", "verify_ssl", None):
|
||||
try:
|
||||
hamcp_config.set_embedded_connection(
|
||||
self._server_url, access_token, **kwargs
|
||||
)
|
||||
return
|
||||
except TypeError:
|
||||
if optional is None:
|
||||
raise
|
||||
if optional not in kwargs:
|
||||
continue
|
||||
del kwargs[optional]
|
||||
if optional == "config_dir":
|
||||
# Only costs the direct blueprint file read; the server
|
||||
# falls back to reading through this component.
|
||||
_LOGGER.debug(
|
||||
"Installed ha-mcp server does not accept config_dir for "
|
||||
"the embedded connection; blueprint reads route through "
|
||||
"the component until the server package updates"
|
||||
)
|
||||
else:
|
||||
_LOGGER.warning(
|
||||
"Installed ha-mcp server does not accept verify_ssl for "
|
||||
"the embedded connection; loopback TLS verification stays "
|
||||
"enabled until the server package updates"
|
||||
)
|
||||
|
||||
def _note_startup_phase(self, phase: str) -> None:
|
||||
"""Publish the worker's startup phase (a plain attribute write).
|
||||
|
||||
@@ -1629,27 +1672,7 @@ class EmbeddedServerManager:
|
||||
"entry may serve stale override values until HA restarts"
|
||||
)
|
||||
|
||||
if self._loopback_verify_ssl is None:
|
||||
_hamcp_config.set_embedded_connection(self._server_url, access_token)
|
||||
else:
|
||||
try:
|
||||
_hamcp_config.set_embedded_connection(
|
||||
self._server_url,
|
||||
access_token,
|
||||
verify_ssl=self._loopback_verify_ssl,
|
||||
)
|
||||
except TypeError:
|
||||
# Server predates the verify_ssl parameter (< the release
|
||||
# carrying issue #1890's fix). Register url+token the old way;
|
||||
# on an SSL-enabled instance the wss loopback will fail
|
||||
# certificate verification until the server package updates —
|
||||
# no worse than the plaintext failure it replaces.
|
||||
_LOGGER.warning(
|
||||
"Installed ha-mcp server does not accept verify_ssl for "
|
||||
"the embedded connection; loopback TLS verification stays "
|
||||
"enabled until the server package updates"
|
||||
)
|
||||
_hamcp_config.set_embedded_connection(self._server_url, access_token)
|
||||
self._register_embedded_connection(_hamcp_config, access_token)
|
||||
|
||||
# Imported here, in the worker thread, after the connection is registered.
|
||||
from ha_mcp.server import HomeAssistantSmartMCPServer
|
||||
|
||||
@@ -33,9 +33,9 @@ read-only middleware exactly like any MCP client's call.
|
||||
The server runs on its own worker thread behind a loopback HTTP listener, and
|
||||
``ha_mcp`` must never be imported in the HA main process (see
|
||||
:mod:`embedded_server`), so this module talks real MCP to the server over
|
||||
loopback streamable HTTP. The ``mcp`` client SDK arrives with the
|
||||
runtime-installed ha-mcp package (a fastmcp dependency), so every SDK import
|
||||
here is lazy and the first one runs on the executor.
|
||||
loopback streamable HTTP with Home Assistant's shared ``mcp`` SDK (the manifest
|
||||
requires ``mcp>=1.24.0``, so 1.x or 2.x), never ha-mcp's vendored copy. Every SDK import here
|
||||
is lazy and the first one runs on the executor.
|
||||
|
||||
The tool list is fetched fresh on every ``async_get_api_instance`` call (once
|
||||
per conversation turn): exposure toggles and runtime-registered custom tools
|
||||
@@ -51,6 +51,7 @@ import copy
|
||||
import importlib
|
||||
import logging
|
||||
import math
|
||||
import sys
|
||||
from collections import Counter
|
||||
from collections.abc import AsyncIterator, Callable, Iterable
|
||||
from contextlib import AsyncExitStack, asynccontextmanager
|
||||
@@ -389,28 +390,37 @@ def _transport_error_leaves() -> tuple[type[BaseException], ...]:
|
||||
"""Return the non-group exception classes a loopback exchange can raise.
|
||||
|
||||
OSError covers a refused/dropped loopback connect; TimeoutError comes
|
||||
from our asyncio.timeout budget. httpx errors and protocol-level McpError
|
||||
can also escape a session call UNWRAPPED (HA core's mcp integration
|
||||
catches both the same way), but neither class is importable at module
|
||||
level — both arrive with the runtime-installed server package — hence a
|
||||
function instead of a module constant.
|
||||
from our asyncio.timeout budget. HTTP-client errors and the protocol-level
|
||||
MCP error can also escape a session call UNWRAPPED (HA core's mcp
|
||||
integration catches both the same way); SDK 1.x raises ``httpx`` /
|
||||
``McpError``, 2.x raises ``httpx2`` / ``MCPError``. Read from ``sys.modules``:
|
||||
the SDK already imported whichever it raised from, and this runs on the
|
||||
event loop, where an import is a blocking call.
|
||||
"""
|
||||
errors: tuple[type[BaseException], ...] = (TimeoutError, OSError)
|
||||
try:
|
||||
import httpx
|
||||
from mcp import McpError
|
||||
except ImportError: # pragma: no cover - SDK-less builds never open a session
|
||||
return errors
|
||||
return (*errors, httpx.HTTPError, McpError)
|
||||
errors: list[type[BaseException]] = [TimeoutError, OSError]
|
||||
for module_name, attribute in (
|
||||
("httpx", "HTTPError"),
|
||||
("httpx2", "HTTPError"),
|
||||
("mcp", "McpError"),
|
||||
("mcp", "MCPError"),
|
||||
):
|
||||
module = sys.modules.get(module_name)
|
||||
if module is None:
|
||||
continue
|
||||
error_class = getattr(module, attribute, None)
|
||||
if isinstance(error_class, type) and error_class not in errors:
|
||||
errors.append(error_class)
|
||||
return tuple(errors)
|
||||
|
||||
|
||||
def _transport_errors() -> tuple[type[BaseException], ...]:
|
||||
"""Return the ``except`` target for one loopback MCP exchange.
|
||||
|
||||
Evaluated at exception time (an ``except`` expression is), so the lazy
|
||||
imports in :func:`_transport_error_leaves` have already succeeded by
|
||||
then. Includes ExceptionGroup because the SDK's anyio task groups wrap
|
||||
in-session failures — but a caught group must still pass
|
||||
Evaluated at exception time (an ``except`` expression is), so whichever
|
||||
HTTP/MCP module raised is already in ``sys.modules`` for
|
||||
:func:`_transport_error_leaves` to find. Includes ExceptionGroup because
|
||||
the SDK's anyio task groups wrap in-session failures — but a caught group
|
||||
must still pass
|
||||
:func:`_is_transport_failure` before being mapped to a friendly error,
|
||||
or a genuine bug that happened inside the task group would be relabeled
|
||||
as a transport failure (review finding).
|
||||
@@ -547,34 +557,40 @@ async def _mcp_session(
|
||||
"""
|
||||
from mcp.client.session import ClientSession
|
||||
|
||||
sdk_transport = importlib.import_module("mcp.client.streamable_http")
|
||||
|
||||
async with AsyncExitStack() as stack:
|
||||
try:
|
||||
from mcp.client.streamable_http import streamable_http_client
|
||||
except ImportError:
|
||||
# Pre-rename SDK (an older ha-mcp resolved by a pip-spec override
|
||||
# pins an older fastmcp/mcp): same call shape, deprecated name,
|
||||
streamable_http_client = getattr(sdk_transport, "streamable_http_client", None)
|
||||
if streamable_http_client is None:
|
||||
# Pre-1.24 SDK (a third-party pin forcing the shared mcp below the
|
||||
# manifest's floor): same call shape, deprecated name,
|
||||
# and no http_client kwarg — but it does accept a factory for the
|
||||
# client it builds internally, so _loopback_httpx_client_factory
|
||||
# keeps this fallback on the same verify=False/trust_env=False
|
||||
# posture as the canonical path below.
|
||||
from mcp.client.streamable_http import streamablehttp_client
|
||||
|
||||
transport = streamablehttp_client(
|
||||
transport = sdk_transport.streamablehttp_client(
|
||||
url=url, httpx_client_factory=_loopback_httpx_client_factory
|
||||
)
|
||||
else:
|
||||
import httpx
|
||||
|
||||
# SDK 1.x takes an httpx client, 2.x an httpx2 one: use the library
|
||||
# the transport module itself imported.
|
||||
http_lib = getattr(sdk_transport, "httpx2", None) or getattr(
|
||||
sdk_transport, "httpx", None
|
||||
)
|
||||
if http_lib is None:
|
||||
http_lib = importlib.import_module("httpx")
|
||||
http_client = await stack.enter_async_context(
|
||||
httpx.AsyncClient(
|
||||
http_lib.AsyncClient(
|
||||
verify=False,
|
||||
trust_env=False,
|
||||
timeout=httpx.Timeout(_CALL_TOOL_TIMEOUT_SECONDS),
|
||||
timeout=http_lib.Timeout(_CALL_TOOL_TIMEOUT_SECONDS),
|
||||
)
|
||||
)
|
||||
transport = streamable_http_client(url=url, http_client=http_client)
|
||||
|
||||
read_stream, write_stream, _ = await stack.enter_async_context(transport)
|
||||
# 1.x yields (read, write, get_session_id); 2.x yields (read, write).
|
||||
streams = await stack.enter_async_context(transport)
|
||||
read_stream, write_stream = streams[0], streams[1]
|
||||
session = await stack.enter_async_context(
|
||||
ClientSession(read_stream, write_stream)
|
||||
)
|
||||
@@ -673,8 +689,30 @@ async def _forward_tool_call(
|
||||
# Full CallToolResult (content blocks, structuredContent, isError) —
|
||||
# the same shape HA core's mcp integration hands to agents; ha-mcp
|
||||
# signals tool failure via isError + structured error JSON, which the
|
||||
# agent reads and reacts to like any tool output.
|
||||
return result.model_dump(exclude_unset=True, exclude_none=True)
|
||||
# agent reads and reacts to like any tool output. Dumped by alias (2.x
|
||||
# fields are snake_case) with ``meta`` kept at its 1.x key, so both SDK
|
||||
# lines hand agents the same keys.
|
||||
dumped = result.model_dump(exclude_unset=True, exclude_none=True, by_alias=True)
|
||||
return cast("JsonObjectType", _meta_as_field_name(dumped))
|
||||
|
||||
|
||||
def _meta_as_field_name(dumped: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Rename ``_meta`` to ``meta`` on the SDK models only, never in tool payloads."""
|
||||
models: list[Any] = [dumped]
|
||||
for block in dumped.get("content", []):
|
||||
models += [block, block.get("resource") if isinstance(block, dict) else None]
|
||||
for model in models:
|
||||
if isinstance(model, dict) and "_meta" in model:
|
||||
model["meta"] = model.pop("_meta")
|
||||
return dumped
|
||||
|
||||
|
||||
def _tool_input_schema(tool: Any) -> Any:
|
||||
"""``input_schema`` on SDK 2.x, ``inputSchema`` on 1.x."""
|
||||
schema = getattr(tool, "input_schema", None)
|
||||
if schema is None:
|
||||
schema = getattr(tool, "inputSchema", None)
|
||||
return schema
|
||||
|
||||
|
||||
def _search_score(query_words: list[str], name: str, description: str) -> int:
|
||||
@@ -868,7 +906,7 @@ class HaMcpLlmApi(llm.API):
|
||||
"""Mirror every exposed tool directly (full-catalog mode)."""
|
||||
tools: list[llm.Tool] = []
|
||||
for tool in exposed:
|
||||
schema = _normalise_schema(tool.inputSchema, tool.name)
|
||||
schema = _normalise_schema(_tool_input_schema(tool), tool.name)
|
||||
parameters = self._convert_parameters(tool, schema)
|
||||
if parameters is None:
|
||||
continue
|
||||
@@ -896,7 +934,7 @@ class HaMcpLlmApi(llm.API):
|
||||
# recorded no mutating call at all. A
|
||||
# converter that wrote back would corrupt the catalog entry, so
|
||||
# re-check this before pointing the component at a third one.
|
||||
schema = _normalise_schema(tool.inputSchema, tool.name)
|
||||
schema = _normalise_schema(_tool_input_schema(tool), tool.name)
|
||||
catalog.append(
|
||||
{
|
||||
"name": tool.name,
|
||||
|
||||
@@ -20,8 +20,9 @@
|
||||
"iot_class": "local_push",
|
||||
"issue_tracker": "https://github.com/homeassistant-ai/ha-mcp/issues",
|
||||
"requirements": [
|
||||
"mcp>=1.24.0",
|
||||
"ruamel.yaml>=0.18.0",
|
||||
"voluptuous-openapi>=0.4.1"
|
||||
],
|
||||
"version": "2.1.3"
|
||||
"version": "2.2.0"
|
||||
}
|
||||
@@ -72,12 +72,24 @@ from .oauth_legacy import (
|
||||
build_unbound_legacy_provider,
|
||||
clear_scoped_legacy_credentials,
|
||||
)
|
||||
from .readonly_webhook import (
|
||||
readonly_url,
|
||||
register_readonly_webhook,
|
||||
unregister_readonly_webhook,
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _unregister_webhook(hass: HomeAssistant, webhook_id: str) -> None:
|
||||
"""Remove both endpoint variants, including on partial setup failure."""
|
||||
unregister_readonly_webhook(hass, webhook_id)
|
||||
async_unregister(hass, webhook_id)
|
||||
|
||||
|
||||
# Human-readable webhook name shown in the HA webhook registry.
|
||||
_WEBHOOK_NAME = "HA-MCP in-process server"
|
||||
|
||||
@@ -585,7 +597,11 @@ async def _check_webhook_auth(
|
||||
|
||||
|
||||
async def _async_handle_webhook(
|
||||
hass: HomeAssistant, webhook_id: str, request: web.Request
|
||||
hass: HomeAssistant,
|
||||
webhook_id: str,
|
||||
request: web.Request,
|
||||
*,
|
||||
read_only: bool = False,
|
||||
) -> web.StreamResponse:
|
||||
"""Forward an MCP request to the loopback server and stream the reply back."""
|
||||
domain_data = hass.data.get(DOMAIN)
|
||||
@@ -597,7 +613,9 @@ async def _async_handle_webhook(
|
||||
if auth_response is not None:
|
||||
return auth_response
|
||||
|
||||
target_url: str = cfg["target_url"]
|
||||
target_url: str = (
|
||||
readonly_url(cfg["target_url"]) if read_only else cfg["target_url"]
|
||||
)
|
||||
session: aiohttp.ClientSession = cfg["session"]
|
||||
|
||||
body = await request.read()
|
||||
@@ -824,7 +842,7 @@ async def async_register_webhook(
|
||||
# crashed unload before (re)registering — or before storing a local-only
|
||||
# config (async_unregister is a no-op pop when nothing is registered).
|
||||
# Runs before the session opens so a raise here cannot leak it.
|
||||
async_unregister(hass, webhook_id)
|
||||
_unregister_webhook(hass, webhook_id)
|
||||
target_url = f"http://127.0.0.1:{port}{secret_path}"
|
||||
session = aiohttp.ClientSession(timeout=_CLIENT_TIMEOUT)
|
||||
cimd_session: aiohttp.ClientSession | None = None
|
||||
@@ -862,6 +880,7 @@ async def async_register_webhook(
|
||||
_async_handle_webhook,
|
||||
allowed_methods=["POST", "GET"],
|
||||
)
|
||||
register_readonly_webhook(hass, webhook_id, _async_handle_webhook)
|
||||
if auth_mode == WEBHOOK_AUTH_HA:
|
||||
_bind_ha_auth_surface(hass, cfg, webhook_id, dcr_signing_key)
|
||||
elif auth_mode == WEBHOOK_AUTH_LEGACY:
|
||||
@@ -877,7 +896,7 @@ async def async_register_webhook(
|
||||
# auth-setup path. suppress: the ORIGINAL error must be what
|
||||
# propagates (review finding) - a raising cleanup would mask it.
|
||||
with suppress(Exception):
|
||||
async_unregister(hass, webhook_id)
|
||||
_unregister_webhook(hass, webhook_id)
|
||||
with suppress(Exception):
|
||||
await session.close()
|
||||
if cimd_session is not None:
|
||||
@@ -917,7 +936,7 @@ async def async_unregister_webhook(hass: HomeAssistant) -> None:
|
||||
return
|
||||
webhook_id = cfg.get("webhook_id")
|
||||
if webhook_id:
|
||||
async_unregister(hass, webhook_id)
|
||||
_unregister_webhook(hass, webhook_id)
|
||||
session = cfg.get("session")
|
||||
if session is not None:
|
||||
await session.close()
|
||||
|
||||
@@ -60,6 +60,7 @@ from .oauth_legacy import (
|
||||
_PKCE_CHALLENGE_RE,
|
||||
_TOKEN_RESPONSE_HEADERS,
|
||||
ACCESS_TOKEN_TTL,
|
||||
PKCE_S256_CHALLENGE_LEN,
|
||||
PKCECodeStore,
|
||||
_is_valid_redirect_uri,
|
||||
_issuer_for,
|
||||
@@ -165,20 +166,6 @@ def _webhook_cfg(hass: HomeAssistant) -> dict[str, Any] | None:
|
||||
return cfg if isinstance(cfg, dict) else None
|
||||
|
||||
|
||||
def _active_autoapprove_provider(hass: HomeAssistant) -> AutoApproveProvider | None:
|
||||
"""The live none-mode auto-approve provider, or None when it is not live.
|
||||
|
||||
Read live from ``hass.data`` (not captured at view construction) so the
|
||||
bound views serve only while none-autoapprove is the active mode and 404
|
||||
otherwise — mirrors ``mcp_webhook._active_webhook_id``'s per-request gating.
|
||||
"""
|
||||
cfg = _webhook_cfg(hass)
|
||||
if cfg is None:
|
||||
return None
|
||||
provider = cfg.get(CFG_AUTOAPPROVE_PROVIDER)
|
||||
return provider if isinstance(provider, AutoApproveProvider) else None
|
||||
|
||||
|
||||
def _validate_autoapprove_authorize(params: Any) -> web.Response | None:
|
||||
"""Validate the none-mode /authorize query; a 400 Response, or None if OK.
|
||||
|
||||
@@ -197,7 +184,9 @@ def _validate_autoapprove_authorize(params: Any) -> web.Response | None:
|
||||
return _json_error("invalid_request", 400, "code_challenge_method must be S256")
|
||||
if not _PKCE_CHALLENGE_RE.fullmatch(params.get("code_challenge", "")):
|
||||
return _json_error(
|
||||
"invalid_request", 400, "invalid code_challenge (43-char base64url)"
|
||||
"invalid_request",
|
||||
400,
|
||||
f"invalid code_challenge ({PKCE_S256_CHALLENGE_LEN}-char base64url)",
|
||||
)
|
||||
if not _is_valid_redirect_uri(params.get("redirect_uri", "")):
|
||||
return _json_error("invalid_request", 400, "invalid redirect_uri")
|
||||
|
||||
@@ -89,7 +89,7 @@ PKCE_VERIFIER_MAX = 128
|
||||
# SHA-256 → 32 bytes → 43 base64url chars (no padding).
|
||||
PKCE_S256_CHALLENGE_LEN = 43
|
||||
_PKCE_VERIFIER_RE = re.compile(r"[A-Za-z0-9._~-]+")
|
||||
_PKCE_CHALLENGE_RE = re.compile(r"[A-Za-z0-9_-]{43}")
|
||||
_PKCE_CHALLENGE_RE = re.compile(rf"[A-Za-z0-9_-]{{{PKCE_S256_CHALLENGE_LEN}}}")
|
||||
|
||||
# Pending-code dict cap. An attacker spamming /authorize with valid params
|
||||
# could grow the dict between the prune passes that run on each issuance.
|
||||
@@ -676,7 +676,8 @@ class LegacyOAuthProvider:
|
||||
return _text_error(400, "invalid code_challenge_method (S256 required)")
|
||||
if not _PKCE_CHALLENGE_RE.fullmatch(code_challenge):
|
||||
return _text_error(
|
||||
400, "invalid code_challenge (must be 43-char base64url)"
|
||||
400,
|
||||
f"invalid code_challenge (must be {PKCE_S256_CHALLENGE_LEN}-char base64url)",
|
||||
)
|
||||
if client_id != self.client_id:
|
||||
return _text_error(400, "invalid client_id", restart_hint=True)
|
||||
|
||||
@@ -83,7 +83,8 @@ read_file:
|
||||
description: >-
|
||||
Relative path from config directory. Allowed paths include
|
||||
configuration.yaml, automations.yaml, scripts.yaml, scenes.yaml,
|
||||
secrets.yaml (values masked), home-assistant.log, www/**, themes/**,
|
||||
secrets.yaml (values masked), home-assistant.log,
|
||||
home-assistant.log.fault, www/**, themes/**,
|
||||
custom_templates/**, dashboards/**, blueprints/**, packages/*.yaml,
|
||||
custom_components/**/*.py
|
||||
required: true
|
||||
|
||||
@@ -33,7 +33,7 @@
|
||||
"extra_yaml_keys": "Extra YAML write keys"
|
||||
},
|
||||
"data_description": {
|
||||
"allowed_dirs": "Paths relative to the config directory, including exact filenames such as sensor.yaml, or paths under /share, /media, /ssl, and /backup. Each entry is granted read and write for that path and anything below it; enter every path as a separate list item. Traversal and other disallowed paths are dropped.",
|
||||
"allowed_dirs": "Paths relative to the config directory, including exact filenames such as sensor.yaml, or paths under /share, /media, /ssl, and /backup. Each entry is granted read and write for that path and anything below it; enter every path as a separate list item. Entries are matched literally; wildcards such as * are not supported. Traversal and other disallowed paths are dropped.",
|
||||
"extra_yaml_keys": "Top-level keys ha_config_set_yaml may write in addition to the built-in ones, for YAML-first integrations on this install (e.g. alert2). Trust-boundary keys are dropped."
|
||||
}
|
||||
},
|
||||
|
||||
@@ -33,7 +33,7 @@
|
||||
"extra_yaml_keys": "Další klíče pro zápis YAML"
|
||||
},
|
||||
"data_description": {
|
||||
"allowed_dirs": "Cesty relativní k adresáři konfigurace, včetně přesných názvů souborů, jako je sensor.yaml, nebo cesty pod /share, /media, /ssl a /backup. Každá položka má uděleno oprávnění pro čtení a zápis pro danou cestu a vše pod ní; každou cestu zadejte jako samostatnou položku seznamu. Adresářový posun (traversal) a jiné nepovolené cesty budou ignorovány.",
|
||||
"allowed_dirs": "Cesty relativní ke složce konfigurační složky, včetně přesných názvů souborů jako sensor.yaml, nebo cesty pod /share, /media, /ssl a /backup. Každá položka má uděleno právo čtení i zápisu pro danou cestu a vše pod ní; zadejte každou cestu jako samostatnou položku seznamu. Položky se porovnávají doslovně; zástupné znaky jako * nejsou podporovány. Průchod adresářovou strukturou a jiné nepovolené cesty jsou zahazovány.",
|
||||
"extra_yaml_keys": "Klíče nejvyšší úrovně, které ha_config_set_yaml může zapisovat navíc k vestavěným, pro integrace s prioritou YAML v této instalaci (např. alert2). Klíče hranice důvěry jsou zahozeny."
|
||||
}
|
||||
},
|
||||
|
||||
@@ -33,7 +33,7 @@
|
||||
"extra_yaml_keys": "Zusätzliche YAML-Schreib-Keys"
|
||||
},
|
||||
"data_description": {
|
||||
"allowed_dirs": "Pfade relativ zum Konfigurationsverzeichnis, einschließlich genauer Dateinamen wie sensor.yaml, oder Pfade unter /share, /media, /ssl und /backup. Jedem Eintrag wird Lese- und Schreibzugriff für diesen Pfad und alles darunter gewährt; gib jeden Pfad als separaten Listeneintrag an. Verzeichnistraversierung und andere nicht erlaubte Pfade werden verworfen.",
|
||||
"allowed_dirs": "Pfade relativ zum Konfigurationsverzeichnis, einschließlich exakter Dateinamen wie sensor.yaml, oder Pfade unter /share, /media, /ssl und /backup. Jedem Eintrag wird Lese- und Schreibzugriff für diesen Pfad und alles darunter gewährt; gib jeden Pfad als separates Listenelement ein. Einträge werden wörtlich abgeglichen; Platzhalter wie * werden nicht unterstützt. Pfadtraversierung und andere unzulässige Pfade werden verworfen.",
|
||||
"extra_yaml_keys": "Top-Level-Keys, die ha_config_set_yaml zusätzlich zu den eingebauten schreiben darf, für YAML-first-Integrationen auf dieser Installation (z. B. alert2). Vertrauensgrenzen-Keys werden verworfen."
|
||||
}
|
||||
},
|
||||
|
||||
@@ -33,7 +33,7 @@
|
||||
"extra_yaml_keys": "Extra YAML write keys"
|
||||
},
|
||||
"data_description": {
|
||||
"allowed_dirs": "Paths relative to the config directory, including exact filenames such as sensor.yaml, or paths under /share, /media, /ssl, and /backup. Each entry is granted read and write for that path and anything below it; enter every path as a separate list item. Traversal and other disallowed paths are dropped.",
|
||||
"allowed_dirs": "Paths relative to the config directory, including exact filenames such as sensor.yaml, or paths under /share, /media, /ssl, and /backup. Each entry is granted read and write for that path and anything below it; enter every path as a separate list item. Entries are matched literally; wildcards such as * are not supported. Traversal and other disallowed paths are dropped.",
|
||||
"extra_yaml_keys": "Top-level keys ha_config_set_yaml may write in addition to the built-in ones, for YAML-first integrations on this install (e.g. alert2). Trust-boundary keys are dropped."
|
||||
}
|
||||
},
|
||||
|
||||
@@ -33,7 +33,7 @@
|
||||
"extra_yaml_keys": "Ekstraj YAML-skribaj ŝlosiloj"
|
||||
},
|
||||
"data_description": {
|
||||
"allowed_dirs": "Vojoj relative al la agorda dosierujo, inkluzive de ekzakta dosiernomo kiel sensor.yaml, aŭ vojoj sub /share, /media, /ssl, kaj /backup. Ĉiu eniro ricevas leg- kaj skrib-permeson por tiu vojo kaj ĉio sub ĝi; enigu ĉiun vojon kiel apartan listan eron. Trairado kaj aliaj nepermesitaj vojoj estas forigitaj.",
|
||||
"allowed_dirs": "Poj do la agorda dosierujo, inkluzive de ekzakta dosiernomo kiel sensor.yaml, aŭ vojoj sub /share, /media, /ssl, kaj /backup. Ĉiu eniro ricevas leg- kaj skrib-permeson por tiu vojo kaj ĉio sub ĝi; enigu ĉiun vojon kiel apartan listan eron. Eniroj estas kongruigataj laŭlitere; sovaĝaj signoj kiel * ne estas subtenataj. Trapasado kaj aliaj nepermesitaj vojoj estas forĵetitaj.",
|
||||
"extra_yaml_keys": "Ĉefnivelaj ŝlosiloj, kiujn ha_config_set_yaml povas skribi krom la enkonstruitaj, por YAML-unuaj integriĝoj en ĉi tiu instalaĵo (ekz. alert2). Fid-limaj ŝlosiloj estas forĵetitaj."
|
||||
}
|
||||
},
|
||||
|
||||
@@ -33,7 +33,7 @@
|
||||
"extra_yaml_keys": "Claves YAML de escritura adicionales"
|
||||
},
|
||||
"data_description": {
|
||||
"allowed_dirs": "Rutas relativas al directorio de configuración, incluidos nombres de archivo exactos como sensor.yaml, o rutas bajo /share, /media, /ssl y /backup. A cada entrada se le concede lectura y escritura para esa ruta y todo lo que esté debajo de ella; introduce cada ruta como un elemento de lista separado. Se descartarán los recorridos de directorios y otras rutas no permitidas.",
|
||||
"allowed_dirs": "Rutas relativas al directorio de configuración, incluidos nombres de archivo exactos como sensor.yaml, o rutas bajo /share, /media, /ssl y /backup. A cada entrada se le concede lectura y escritura para esa ruta y todo lo que esté debajo de ella; introduce cada entrada como un elemento de lista separado. Las entradas se comparan literalmente; no se admiten comodines como *. Se omiten las rutas con recorrido de directorios y otras no permitidas.",
|
||||
"extra_yaml_keys": "Claves de primer nivel que ha_config_set_yaml puede escribir además de las integradas, para integraciones basadas en YAML de esta instalación (p. ej. alert2). Las claves de la frontera de confianza se descartan."
|
||||
}
|
||||
},
|
||||
|
||||
@@ -33,7 +33,7 @@
|
||||
"extra_yaml_keys": "Clés d'écriture YAML supplémentaires"
|
||||
},
|
||||
"data_description": {
|
||||
"allowed_dirs": "Chemins relatifs au répertoire de configuration, incluant les noms de fichiers exacts tels que sensor.yaml, ou les chemins sous /share, /media, /ssl et /backup. Chaque entrée bénéficie d'un accès en lecture et en écriture pour ce chemin et tout ce qu'il contient ; saisissez chaque chemin comme un élément de liste séparé. Les parcours de répertoires (traversal) et autres chemins non autorisés sont ignorés.",
|
||||
"allowed_dirs": "Chemins relatifs au répertoire de configuration, y compris les noms de fichiers exacts tels que sensor.yaml, ou les chemins sous /share, /media, /ssl et /backup. Chaque entrée se voit accorder un accès en lecture et en écriture pour ce chemin et tout ce qui se trouve en dessous ; entrez chaque chemin comme un élément de liste séparé. Les entrées sont comparées littéralement ; les caractères génériques tels que * ne sont pas pris en charge. Les traversées de répertoires et autres chemins non autorisés sont ignorés.",
|
||||
"extra_yaml_keys": "Clés de premier niveau que ha_config_set_yaml peut écrire en plus de celles intégrées, pour les intégrations YAML-first de cette installation (ex. alert2). Les clés de limite de confiance sont ignorées."
|
||||
}
|
||||
},
|
||||
|
||||
@@ -33,7 +33,7 @@
|
||||
"extra_yaml_keys": "Chiavi YAML scrivibili aggiuntive"
|
||||
},
|
||||
"data_description": {
|
||||
"allowed_dirs": "Percorsi relativi alla directory di configurazione, inclusi nomi di file esatti come sensor.yaml, o percorsi sotto /share, /media, /ssl e /backup. A ciascuna voce viene concesso l'accesso in lettura e scrittura per quel percorso e per tutto ciò che si trova al di sotto; inserisci ogni percorso come elemento di elenco separato. I percorsi di attraversamento e altri non consentiti vengono scartati.",
|
||||
"allowed_dirs": "Percorsi relativi alla directory di configurazione, inclusi nomi file esatti come sensor.yaml, o percorsi sotto /share, /media, /ssl e /backup. A ogni voce vengono concessi i permessi di lettura e scrittura per quel percorso e per tutto ciò che si trova al di sotto di esso; inserisci ogni percorso come elemento di elenco separato. Le voci vengono confrontate letteralmente; i caratteri jolly come * non sono supportati. I percorsi di attraversamento e altri percorsi non consentiti vengono scartati.",
|
||||
"extra_yaml_keys": "Chiavi di primo livello che ha_config_set_yaml può scrivere oltre a quelle integrate, per le integrazioni basate su YAML di questa installazione (per esempio alert2). Le chiavi del confine di fiducia vengono scartate."
|
||||
}
|
||||
},
|
||||
|
||||
@@ -33,7 +33,7 @@
|
||||
"extra_yaml_keys": "추가 YAML 쓰기 허용 키"
|
||||
},
|
||||
"data_description": {
|
||||
"allowed_dirs": "sensor.yaml 같은 정확한 파일 이름을 포함하여 config 디렉토리 상대 경로, 또는 /share, /media, /ssl, /backup 아래의 경로입니다. 각 항목에는 해당 경로 및 그 하위 경로에 대한 읽기 및 쓰기 권한이 부여되며, 각 경로는 별도의 목록 항목으로 입력하세요. 상위 디렉토리 순회 및 허용되지 않는 경로는 제외됩니다.",
|
||||
"allowed_dirs": "config 디렉터리를 기준으로 한 상대 경로이며, sensor.yaml 같은 정확한 파일 이름이나 /share, /media, /ssl, /backup 아래의 경로를 포함합니다. 각 항목은 해당 경로와 그 하위 항목에 대한 읽기 및 쓰기 권한이 부여되며, 모든 경로는 별도의 목록 항목으로 입력해야 합니다. 항목은 정확히 일치하는 문자열로 처리되며, * 같은 와일드카드는 지원되지 않습니다. 상위 디렉터리 접근(Traversal) 및 허용되지 않는 경로는 무시됩니다.",
|
||||
"extra_yaml_keys": "이 설치본에서 YAML 우선으로 동작하는 통합 구성요소(예: alert2)를 위해, ha_config_set_yaml이 기본 허용 키 외에 추가로 쓸 수 있는 최상위 키입니다. 신뢰 경계 키는 무시됩니다."
|
||||
}
|
||||
},
|
||||
|
||||
@@ -33,7 +33,7 @@
|
||||
"extra_yaml_keys": "Extra YAML-schrijfsleutels"
|
||||
},
|
||||
"data_description": {
|
||||
"allowed_dirs": "Paden relatief ten opzichte van deconfiguratiemap, inclusief exacte bestandsnamen zoals sensor.yaml, of paden onder /share, /media, /ssl en /backup. Elk opgegeven pad en alles daaronder krijgt lees- en schrijfrechten; voer elk pad in als een apart lijstitem. Padtraversatie en andere niet-toegestane paden worden genegeerd.",
|
||||
"allowed_dirs": "Paden relatief ten opzichte van de config-directory, inclusief exacte bestandsnamen zoals sensor.yaml, of paden onder /share, /media, /ssl en /backup. Aan elk item wordt lees- en schrijftoegang verleend voor dat pad en alles daaronder; voer elk pad in als een apart lijstitem. Items worden letterlijk gematcht; jokers zoals * worden niet ondersteund. Directory-traversal en andere niet-toegestane paden worden geweigerd.",
|
||||
"extra_yaml_keys": "Top-level sleutels die ha_config_set_yaml mag schrijven naast de ingebouwde, voor YAML-first integraties op deze installatie (bijv. alert2). Trust-boundary sleutels worden genegeerd."
|
||||
}
|
||||
},
|
||||
|
||||
@@ -33,7 +33,7 @@
|
||||
"extra_yaml_keys": "Dodatkowe klucze zapisu YAML"
|
||||
},
|
||||
"data_description": {
|
||||
"allowed_dirs": "Ścieżki względne do katalogu konfiguracyjnego, w tym dokładne nazwy plików, takie jak sensor.yaml, lub ścieżki w katalogach /share, /media, /ssl i /backup. Każdy wpis otrzymuje uprawnienia do odczytu i zapisu dla danej ścieżki oraz wszystkiego, co się w niej znajduje; wpisz każdą ścieżkę jako oddzielny element listy. Przechodzenie w górę drzewa katalogów (traversal) i inne niedozwolone ścieżki są odrzucane.",
|
||||
"allowed_dirs": "Ścieżki względem katalogu konfiguracyjnego, w tym dokładne nazwy plików, takie jak sensor.yaml, lub ścieżki w katalogach /share, /media, /ssl i /backup. Każdy wpis otrzymuje uprawnienia do odczytu i zapisu dla tej ścieżki oraz wszystkiego, co się w niej znajduje; wpisz każdą ścieżkę jako osobny element listy. Wpisy są dopasowywane dosłownie; symbole wieloznaczne, takie jak *, nie są obsługiwane. Przechodzenie w górę drzewa katalogów (traversal) i inne niedozwolone ścieżki są odrzucane.",
|
||||
"extra_yaml_keys": "Klucze najwyższego poziomu, które ha_config_set_yaml może zapisywać oprócz wbudowanych, dla integracji opartych na YAML w tej instalacji (np. alert2). Klucze graniczne zaufania są odrzucane."
|
||||
}
|
||||
},
|
||||
|
||||
@@ -33,7 +33,7 @@
|
||||
"extra_yaml_keys": "Дополнительные YAML-ключи для записи"
|
||||
},
|
||||
"data_description": {
|
||||
"allowed_dirs": "Пути относительно каталога конфигурации, включая точные имена файлов, такие как sensor.yaml, или пути в директориях /share, /media, /ssl и /backup. Для каждого элемента предоставляется доступ на чтение и запись к самому пути и всему, что находится в нем; вводите каждый путь как отдельный элемент списка. Обход каталогов и другие запрещенные пути отбрасываются.",
|
||||
"allowed_dirs": "Пути относительно каталога конфигурации, включая точные имена файлов, такие как sensor.yaml, или пути в каталогах /share, /media, /ssl и /backup. Для каждого элемента предоставляются права на чтение и запись для указанного пути и всего, что находится внутри него; каждый путь указывается как отдельный элемент списка. Элементы сопоставляются точным совпадением; подстановочные знаки, такие как *, не поддерживаются. Обход каталогов и другие запрещенные пути отклоняются.",
|
||||
"extra_yaml_keys": "Верхнеуровневые ключи, которые ha_config_set_yaml может записывать вдобавок к встроенным, — для YAML-ориентированных интеграций на этой установке (например, alert2). Ключи границы доверия отбрасываются."
|
||||
}
|
||||
},
|
||||
|
||||
@@ -33,7 +33,7 @@
|
||||
"extra_yaml_keys": "Extra YAML-skrivnycklar"
|
||||
},
|
||||
"data_description": {
|
||||
"allowed_dirs": "Sökvägar relativt konfigurationskatalogen, inklusive exakta filnamn som sensor.yaml, eller sökvägar under /share, /media, /ssl och /backup. Varje post ges läs- och skrivbehörighet för den sökvägen och allt under den; ange varje sökväg som ett separat listobjekt. Sökordsbläddring (traversal) och andra otillåtna sökvägar tas bort.",
|
||||
"allowed_dirs": "Sökvägar relativa till konfigurationskatalogen, inklusive exakta filnamn som sensor.yaml, eller sökvägar under /share, /media, /ssl och /backup. Varje post ges läs- och skrivbehörighet för den sökvägen och allt under den; ange varje sökväg som ett separat listobjekt. Poster matchas bokstavligt; jokertecken som * stöds inte. Sökvägsöverskridningar och andra otillåtna sökvägar ignoreras.",
|
||||
"extra_yaml_keys": "Toppnivånycklar som ha_config_set_yaml får skriva utöver de inbyggda, för YAML-först-integrationer på denna installation (t.ex. alert2). Nycklar för förtroendegränser ignoreras."
|
||||
}
|
||||
},
|
||||
|
||||
@@ -33,7 +33,7 @@
|
||||
"extra_yaml_keys": "额外的 YAML 可写键"
|
||||
},
|
||||
"data_description": {
|
||||
"allowed_dirs": "相对于配置目录的路径,包括诸如 sensor.yaml 的确切文件名,或 /share、/media、/ssl 和 /backup 下的路径。每个条目均被授予对该路径及其中所有内容的读写权限;每个路径请作为一个单独的列表项输入。将丢弃目录遍历及其他不允许的路径。",
|
||||
"allowed_dirs": "相对于 config 目录的路径,包括诸如 sensor.yaml 的准确文件名,或 /share、/media、/ssl 和 /backup 下的路径。每个条目都会被授予对该路径及其中任何内容的读写权限;请输入每个路径作为单独的列表项。条目按字面量匹配,不支持诸如 * 的通配符。路径遍历和其他不允许的路径将被丢弃。",
|
||||
"extra_yaml_keys": "除内置键外,ha_config_set_yaml 还可写入的顶层键,适用于本安装中以 YAML 为主的集成(例如 alert2)。信任边界键会被丢弃。"
|
||||
}
|
||||
},
|
||||
|
||||
@@ -0,0 +1,168 @@
|
||||
{
|
||||
"config": {
|
||||
"step": {
|
||||
"user": {
|
||||
"title": "HA-MCP 自訂元件",
|
||||
"description": "選擇要新增的項目。**HA-MCP Server** 會在 Home Assistant 內部執行完整的 ha-mcp 伺服器,並透過 Home Assistant Webhook 對外暴露——這是多數人想要的安裝方式。它是獨立伺服器,可完全取代所有其他安裝方法:App(附加元件)、Docker、uvx/PyPI、stdio;若執行了此項,請勿同時執行其中任一其他方式。**HA-MCP File & YAML Tools** 有兩種角色。如果您的 ha-mcp 伺服器是在 Home Assistant 外部執行(app、Docker、uvx/PyPI、stdio),此項目可讓該伺服器獲得無法從外部取得的處理程序內元件功能(快速的處理程序內搜尋、設定項目後設資料、登錄檔支援的讀取),因此非常值得新增至每個外部安裝中,它同時也是 ha-mcp 選擇性啟用的檔案/YAML 工具所需的具特權檔案與 YAML 編輯服務之閘道。如果您改為新增 **HA-MCP Server** 項目,該項目已提供這些共用功能,因此只有在需要作為選擇性檔案/YAML 工具的閘道時,才需要這個第二個項目。它適用於所有伺服器類型,且隨時都能在之後新增。",
|
||||
"menu_options": {
|
||||
"server": "HA-MCP Server(建議)",
|
||||
"tools": "HA-MCP File & YAML Tools(選用)"
|
||||
}
|
||||
},
|
||||
"tools": {
|
||||
"title": "HA-MCP File & YAML Tools",
|
||||
"description": "設定具特權的檔案與 YAML 設定服務,這些服務受限於 ha-mcp 選擇性啟用的檔案/YAML 編輯工具(功能旗標,預設為關閉)之閘道保護——這適用於所有伺服器類型,包含處理程序內的 HA-MCP Server。如果您的 ha-mcp 伺服器在 Home Assistant 外部執行,此項目還能讓它獲得無法從外部取得的處理程序內元件功能(快速的處理程序內搜尋、設定項目後設資料、登錄檔支援的讀取),即使未開啟檔案/YAML 工具也很值得擁有。您可以隨時新增或移除此項目。"
|
||||
},
|
||||
"server": {
|
||||
"title": "HA-MCP Server",
|
||||
"description": "這會在 Home Assistant 內部執行完整的 ha-mcp 伺服器,並透過 Home Assistant Webhook 將其遠端暴露(可透過 Nabu Casa 或任何反向代理存取)。它是獨立伺服器,可完全取代 app(附加元件)、Docker、uvx/PyPI 與 stdio 安裝方法——請勿與其他 ha-mcp 伺服器同時執行。選取**提交**以啟動;您隨後可在整合選項中變更連接埠、繫結與驗證。"
|
||||
}
|
||||
},
|
||||
"abort": {
|
||||
"already_configured": "此項目已經設定過了。",
|
||||
"unsupported_home_assistant": "處理程序內的 HA-MCP Server 需要 Home Assistant {required} 或更新版本,但此執行個體目前執行的版本為 {installed}。您仍然可以在以 app(附加元件)或 Docker 容器方式執行的外部 ha-mcp 伺服器上,使用此元件的 HA-MCP File & YAML Tools 項目。"
|
||||
}
|
||||
},
|
||||
"options": {
|
||||
"step": {
|
||||
"tools_info": {
|
||||
"title": "HA-MCP File & YAML Tools",
|
||||
"description": "設定 ha-mcp 選擇性啟用的檔案/YAML 編輯工具所使用的具特權檔案與 YAML 編輯服務。這些設定也會顯示在 ha-mcp 伺服器的自身的設定 UI 中,並立即生效而無需重新啟動。無法覆寫的拒絕底線仍會阻擋敏感路徑(例如 .storage)與信任邊界金鑰(homeassistant、http、frontend、lovelace)。",
|
||||
"data": {
|
||||
"allowed_dirs": "額外檔案路徑",
|
||||
"extra_yaml_keys": "額外的 YAML 寫入金鑰"
|
||||
},
|
||||
"data_description": {
|
||||
"allowed_dirs": "相對於 config 目錄的路徑,包含精確的檔名(例如 sensor.yaml),或是 /share、/media、/ssl 與 /backup底下的路徑。每個項目都會被授予該路徑及其下方所有內容的讀寫權限;請將每個路徑輸入為獨立的列表項目。項目會以字面值進行比對;不支援如 * 的萬用字元。會略過目錄普遍遍歷與其他不允許的路徑。",
|
||||
"extra_yaml_keys": "除了內建金鑰之外,ha_config_set_yaml 可以寫入的頂層金鑰,適用於此安裝上的 YAML 優先整合(例如 alert2)。信任邊界金鑰將被略過。"
|
||||
}
|
||||
},
|
||||
"init": {
|
||||
"title": "HA-MCP Server",
|
||||
"description": "設定 HA-MCP 伺服器。{panel_hint}此處的變更會在儲存時套用。\n\n{versions}\n\n{connect_url}\n\n{oauth_creds}",
|
||||
"data": {
|
||||
"channel": "發布管道",
|
||||
"auto_update": "自動伺服器更新",
|
||||
"server_port": "MCP 伺服器監聽連接埠",
|
||||
"bind_host": "網路存取",
|
||||
"webhook_auth": "驗證模式",
|
||||
"oauth_client_id_override": "舊版 OAuth:自訂用戶端 ID(選用)",
|
||||
"oauth_client_secret_override": "舊版 OAuth:自訂用戶端密鑰(選用)",
|
||||
"oauth_regenerate": "舊版 OAuth:立即重新產生用戶端 ID/密鑰",
|
||||
"pip_spec": "開發人員:ha-mcp 套件覆寫",
|
||||
"server_url": "Home Assistant URL(進階)",
|
||||
"external_url": "外部 URL(選用)",
|
||||
"webhook_id_override": "自訂 Webhook 密鑰(選用)",
|
||||
"secret_path_override": "自訂直接存取路徑(選用)",
|
||||
"regenerate_secrets": "立即重新產生連線密鑰",
|
||||
"enable_webhook": "透過 Webhook 進行遠端存取",
|
||||
"enable_llm_api": "對話代理 LLM API",
|
||||
"llm_api_exposure": "對話代理工具暴露",
|
||||
"enable_startup_notification": "啟動通知",
|
||||
"enable_sidebar_panel": "側邊欄設定面板"
|
||||
},
|
||||
"data_description": {
|
||||
"channel": "「穩定版 (Stable)」會安裝最新的穩定發布版本;「開發版 (Development)」會安裝最新的開發建置版本。當啟用自動更新時,重新載入或重新啟動,加上定期檢查,將會安裝所選管道的最新建置版本。下方覆寫的開發人員套件具有較高優先權,並會停用自動更新。",
|
||||
"auto_update": "啟用時,將會自動安裝所選管道的最新發布版本——於重新載入或重新啟動時,以及透過定期檢查。關閉時,伺服器將維持在目前已安裝的版本,直到您重新開啟此功能為止。這僅控制 ha-mcp 伺服器套件;HA-MCP 自訂元件本身的更新依然透過 HACS 進行。",
|
||||
"server_port": "此伺服器監聽的連接埠。ha-mcp App(附加元件)使用 9583,因此預設為 9584,讓兩者可同時執行;若未執行該 App(附加元件),可使用任何未占用的連接埠。",
|
||||
"bind_host": "誰可以直接連線至 MCP 伺服器連接埠。預設值符合 app(附加元件):可在您的本機網路上存取,並以密鑰路徑作為憑證。選擇「回送 (loopback)」以僅允許來自 Home Assistant 電腦本身的連線——無論哪種方式,Webhook URL 與側邊欄面板都能正常運作。",
|
||||
"webhook_auth": "MCP 用戶端如何在 Webhook URL 證明其身分。使用密鑰 URL 時,連結本身就是唯一的憑證——OAuth 仍會為使用它的用戶端執行,但每個登入都會自動核准:不需要登入,也不需要用戶端 ID 或密鑰。使用 Home Assistant 登入時,像 claude.ai 這類的用戶端會使用 Home Assistant 管理員帳號 (OAuth) 登入。使用舊版 OAuth 時,此整合會執行自己的登入服務,並發行用戶端 ID 與密鑰,以貼到需要它們的用戶端中——可能需要重新啟動 Home Assistant 才能完全啟用或停用它。",
|
||||
"oauth_client_id_override": "取代自動產生的舊版 OAuth 用戶端 ID。保持空白以保留目前的值。僅在驗證模式設為舊版 OAuth 時使用。",
|
||||
"oauth_client_secret_override": "取代自動產生的舊版 OAuth 用戶端密鑰。保持空白以保留目前的值。僅在驗證模式設為舊版 OAuth 時使用。",
|
||||
"oauth_regenerate": "單次動作:為舊版 OAuth 模式產生全新的用戶端 ID(Client ID)與密鑰(Secret)。它只會在修復提示要求的 Home Assistant 重新啟動後生效——在重新啟動之前,先前的用戶端 ID 與密鑰會繼續運作,新的則不會。同時也會清除上方的兩個覆寫欄位。",
|
||||
"pip_spec": "保持空白。僅用於測試特定的 ha-mcp 建置(例如預先發布版本釘選);會覆寫發布管道並停用自動更新,直到清除為止。",
|
||||
"server_url": "處理程序內伺服器用來連線至 Home Assistant 的 URL(通常就是此執行個體)。留空會依此執行個體的連接埠與 SSL 設定自動推導;只有在伺服器必須改走其他路由時才需設定。",
|
||||
"external_url": "顯示為主要的連線 URL ——當 Home Assistant 位於您自己的網域或反向代理背後時請使用此項。請輸入包含通訊協定的完整基礎位址。它必須直接指向 Home Assistant ——在瀏覽器中開啟它應該能到達您的 HA 登入頁面——且不得包含如 :8123(或任何其他連接埠)的連接埠,否則遠端 MCP 用戶端將無法連線。留空以自動使用 Nabu Casa / 本機位址。",
|
||||
"webhook_id_override": "取代連線 URL (/api/webhook/...) 中的隨機 webhook 密鑰。URL 即為憑證——請使用長且難以猜測的值。留空以保留目前的值。",
|
||||
"secret_path_override": "取代用於伺服器連接埠上直接存取的隨機路徑。相同的規則:路徑即為憑證。留空以保留目前的值。",
|
||||
"regenerate_secrets": "單次動作:產生全新的隨機 webhook 密鑰與直接存取路徑,並立即使舊的連線 URL 失效。同時也會清除上方的兩個覆寫欄位。",
|
||||
"enable_webhook": "為僅限本機模式關閉:完全不會註冊 Home Assistant webhook,因此任何事物(包含 Nabu Casa)都無法透過 Home Assistant 連線至伺服器。直接伺服器連接埠與側邊欄面板將繼續運作。",
|
||||
"enable_llm_api": "提供完整工具集給 Home Assistant 對話代理程式(OpenAI、Google、Ollama 等):啟用時,代理程式可在「控制 Home Assistant」下方選取「HA-MCP Server」,並從 Assist 聊天與語音中驅動工具。啟用只會使其可被選取——在您於代理程式上挑選它之前,不會暴露任何內容。使用指南:{llm_api_docs_url}",
|
||||
"llm_api_exposure": "提供給對話代理程式的工具集外觀。工具搜尋(預設)可保持代理程式的上下文簡短:具有釘選工具加上搜尋/執行中介工具的精簡 API。完整目錄直接列出每個暴露的工具——較適合大上下文模型。兩者會並排註冊,以便每個代理程式在「控制 Home Assistant」下方挑選自己的工具。個別工具暴露管理可在 HA-MCP 設定面板中進行;詳細資訊:{llm_api_docs_url}",
|
||||
"enable_startup_notification": "每次伺服器啟動時顯示通知,指向僅限管理員的設定介面。關閉以安靜地啟動——連線 URL 仍會顯示在 Home Assistant 記錄檔中。",
|
||||
"enable_sidebar_panel": "在側邊欄中顯示 HA-MCP 設定面板(僅限管理員)。關閉以移除側邊欄項目——伺服器選項仍可在此畫面上使用。"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"issues": {
|
||||
"server_start_failed": {
|
||||
"title": "HA-MCP 處理程序內伺服器啟動失敗",
|
||||
"description": "無法在 Home Assistant 內啟動 HA-MCP 處理程序內伺服器:\n\n{detail}\n\n請檢查 Home Assistant 記錄檔,然後重新載入整合(或修正根本問題並重新載入)以重試。"
|
||||
},
|
||||
"server_package_install_failed": {
|
||||
"title": "無法安裝 HA-MCP 處理程序內伺服器套件",
|
||||
"description": "安裝用於處理程序內伺服器的 ha-mcp 套件失敗:\n\n{detail}\n\n請解決上述描述的相容性或安裝問題,然後重新載入整合以重試。"
|
||||
},
|
||||
"component_outdated": {
|
||||
"title": "透過 HACS 更新 HA-MCP 自訂元件",
|
||||
"description": "已安裝的 ha-mcp 伺服器需要 HA-MCP 自訂元件 {required} 或更新版本,但您目前安裝的是 {installed}。請透過 HACS 更新元件(開啟 HA-MCP 自訂元件項目,若尚未顯示更新則使用「更新資訊」)並重新整理 Home Assistant。伺服器在此期間會繼續執行,但在更新元件之前,某些較新功能可能無法運作。"
|
||||
},
|
||||
"server_update_held": {
|
||||
"title": "HA-MCP 伺服器更新正在等待元件更新",
|
||||
"description": "ha-mcp 伺服器 {latest} 可供使用,但該版本同時也更新了 HA-MCP 自訂元件(至 {shipped};您目前執行的是 {running})。為避免啟動執行中元件從未測試過的伺服器版本,自動伺服器更新將暫緩,直到元件更新為止。\n\n請透過 HACS 更新元件(開啟 HA-MCP 自訂元件項目,若尚未顯示更新則使用「更新資訊」),然後重新啟動 Home Assistant——伺服器更新隨後會自動安裝。若無論如何都要安裝伺服器更新,請按下 HA-MCP 伺服器更新實體上的「安裝」。"
|
||||
},
|
||||
"legacy_hacs_source": {
|
||||
"title": "從舊版儲存庫安裝的元件",
|
||||
"description": "HACS 正在追蹤此元件的主要 ha-mcp 伺服器儲存庫,因此 HACS 在此顯示的是伺服器的版本號碼與發布說明,而非元件本身的版本(2.x)。更新會繼續運作,但標籤會維持錯誤狀態。若要修正:從 HACS 中移除此儲存庫(您的整合設定與設定項目會被保留),將 homeassistant-ai/ha-mcp-integration 新增為自訂儲存庫,從中重新安裝元件,然後重新啟動 Home Assistant。"
|
||||
},
|
||||
"legacy_oauth_restart": {
|
||||
"title": "重新啟動 Home Assistant 以套用舊版 OAuth 設定",
|
||||
"fix_flow": {
|
||||
"step": {
|
||||
"confirm": {
|
||||
"title": "要重新啟動 Home Assistant 嗎?",
|
||||
"description": "送出此表單將會重新啟動 Home Assistant 並套用舊版 OAuth 設定。\n\n按一下**送出**以立即重新啟動。"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"selector": {
|
||||
"server_channel": {
|
||||
"options": {
|
||||
"stable": "穩定版(建議)",
|
||||
"dev": "開發版(最新建置)"
|
||||
}
|
||||
},
|
||||
"server_webhook_auth": {
|
||||
"options": {
|
||||
"none": "機密 webhook URL(預設)",
|
||||
"ha_auth": "使用 Home Assistant 登入(OAuth)",
|
||||
"legacy": "舊版 OAuth(此整合會發行要貼上的用戶端 ID/密鑰)"
|
||||
}
|
||||
},
|
||||
"llm_api_exposure": {
|
||||
"options": {
|
||||
"tool_search": "工具搜尋(精簡,預設)",
|
||||
"full": "完整目錄",
|
||||
"both": "兩者(依每個代理程式選擇)"
|
||||
}
|
||||
}
|
||||
},
|
||||
"entity": {
|
||||
"update": {
|
||||
"server_update": {
|
||||
"name": "更新"
|
||||
}
|
||||
}
|
||||
},
|
||||
"common": {
|
||||
"connect_direct_access": "來自 Home Assistant 機器的直接存取:{url}",
|
||||
"connect_local_lan": "本機/區域網路 (當網路存取為 \"Local network\"):{url}",
|
||||
"connect_remote_url": "遠端連線 URL:{url}",
|
||||
"connect_urls_label": "連線 URL:",
|
||||
"connect_urls_pending": "伺服器啟動後,連線 URL 將會顯示在此處(以及 Home Assistant 記錄檔中)。",
|
||||
"connect_webhook_disabled": "透過 webhook 的遠端存取已停用(僅限本機模式)。",
|
||||
"oauth_creds_pending": "伺服器啟動後,用戶端 ID 與用戶端密鑰將會顯示在此處。",
|
||||
"oauth_not_serving": "舊版 OAuth 尚未提供這些憑證;請在 Home Assistant 提示時重新啟動以啟用。",
|
||||
"oauth_select_legacy_mode": "將驗證模式設為上方的舊版 OAuth 並儲存,以產生用戶端 ID 與用戶端密鑰。",
|
||||
"panel_hint": "開啟 [HA-MCP 設定面板](/ha-mcp)進行工具管理與伺服器設定。",
|
||||
"tools_module_installed": "Beta/進階檔案與 YAML 工具模組(選用):已安裝",
|
||||
"tools_module_not_installed": "Beta/進階檔案與 YAML 工具模組(選填):未安裝 — 請在此整合頁面上按一下「新增項目」並選擇 \"HA-MCP File & YAML Tools\" 來加以新增",
|
||||
"tools_module_not_loaded": "Beta/進階檔案與 YAML 工具模組(選填):已安裝但未載載 — 請在此整合頁面上啟用或重新載入「HA-MCP File & YAML Tools」項目",
|
||||
"version_line": "元件 {component_version} - 伺服器 ha-mcp {server_version}({channel} 通道)",
|
||||
"version_not_installed": "尚未安裝",
|
||||
"version_unknown": "未知"
|
||||
}
|
||||
}
|
||||
@@ -37,7 +37,7 @@ the info handshake carries no capability entry:
|
||||
is byte-identical to the REST ``/api/states/<id>`` serialization by
|
||||
construction; the server maps found/missing onto its per-id error contract.
|
||||
* ``ha_mcp_tools/blueprint_get`` — the full body of one installed blueprint
|
||||
(``{metadata, config}``), which core's ``blueprint/list`` never returns (it
|
||||
(``{metadata, config, yaml}``), which core's ``blueprint/list`` never returns (it
|
||||
serves only ``{metadata}``). The path is jailed under
|
||||
``<config>/blueprints/<domain>/`` (symlink-safe containment, mirroring the
|
||||
file-tool jail) and the file read + parse run off the event loop in the async
|
||||
@@ -335,6 +335,7 @@ WS_ENTITY_LOOKUP = f"{WS_API_PREFIX}/entity_lookup"
|
||||
WS_BACKUP_PREP = f"{WS_API_PREFIX}/backup_prep"
|
||||
WS_REGISTRIES = f"{WS_API_PREFIX}/registries"
|
||||
WS_DASHBOARDS = f"{WS_API_PREFIX}/dashboards"
|
||||
WS_DASHBOARD_EDIT = f"{WS_API_PREFIX}/dashboard_edit"
|
||||
WS_SERVICES_LIST = f"{WS_API_PREFIX}/services_list"
|
||||
WS_REFERENCE_DATA = f"{WS_API_PREFIX}/reference_data"
|
||||
WS_SERVER_ENTRY = f"{WS_API_PREFIX}/server_entry"
|
||||
@@ -360,6 +361,11 @@ CAPABILITIES: list[str] = [
|
||||
"helpers_list",
|
||||
"states",
|
||||
"blueprint_get",
|
||||
# A flag on blueprint_get: gates its additive ``yaml`` result field (the raw
|
||||
# on-disk blueprint text). The server only asks for the text when this is
|
||||
# advertised, so an older build simply serves the parsed body and the server
|
||||
# looks for the text elsewhere.
|
||||
"blueprint_text",
|
||||
"device_get",
|
||||
"device_list",
|
||||
# A semantic flag shared by every device-registry-backed read. Components
|
||||
@@ -377,6 +383,7 @@ CAPABILITIES: list[str] = [
|
||||
"backup_prep",
|
||||
"registries",
|
||||
"dashboards",
|
||||
"dashboard_edit",
|
||||
# A flag, not a standalone command: gates the additive whole-document
|
||||
# search-result keys on ``ha_mcp_tools/dashboards`` mode=search
|
||||
# (``document_matches`` + ``yaml_skipped`` + ``load_failed``, issue #2008).
|
||||
@@ -570,9 +577,12 @@ def async_register_commands(hass: HomeAssistant) -> None:
|
||||
HA-core state rather than anything the unloaded entry cached, HA core
|
||||
authenticates the connection, and ``@require_admin`` gates each command — so
|
||||
a caller reaching it can already do the same through HA's own WS API. The
|
||||
write commands are no exception: their D1 domain block refuses
|
||||
``domain == "ha_mcp_tools"`` unconditionally, so the leftover surface can
|
||||
never reach the privileged filesystem/YAML services, which
|
||||
service-dispatching writes enforce D1: they refuse
|
||||
``domain == "ha_mcp_tools"`` unconditionally. Dashboard edits do not dispatch
|
||||
services: they use Core's Lovelace storage API under the same admin gate as
|
||||
``lovelace/config/save``. Server-entry updates retain their own live-entry
|
||||
and option validation. These commands do not grant access to the privileged
|
||||
filesystem/YAML services, which
|
||||
:func:`~custom_components.ha_mcp_tools._async_unload_tools_entry` does remove
|
||||
on unload. Admin-gated commands answering from live core state until the
|
||||
next restart is the trade this makes.
|
||||
@@ -613,6 +623,7 @@ def _command_specs() -> list[tuple[dict[Any, Any], Any, Any]]:
|
||||
(_backup_prep_schema(), _do_backup_prep, None),
|
||||
(_registries_schema(), _do_registries, None),
|
||||
(_dashboards_schema(), _do_dashboards, _dashboards_prep),
|
||||
(_dashboard_edit_schema(), _do_dashboard_edit, _dashboard_edit_prep),
|
||||
(_services_list_schema(), _do_services_list, _services_list_prep),
|
||||
(_reference_data_schema(), _do_reference_data, None),
|
||||
(_server_entry_schema(), _do_server_entry, None),
|
||||
@@ -625,7 +636,7 @@ def _command_specs() -> list[tuple[dict[Any, Any], Any, Any]]:
|
||||
_do_server_entry_update,
|
||||
_server_entry_update_prep,
|
||||
),
|
||||
# The first WRITE command: the dispatch + the bounded confirmation wait are
|
||||
# The service WRITE command: dispatch + the bounded confirmation wait are
|
||||
# inherently async, so ALL of the work lives in the ``_call_service_prep``
|
||||
# async pre-step and ``_do_call_service`` is a pure response formatter.
|
||||
(_call_service_schema(), _do_call_service, _call_service_prep),
|
||||
@@ -3205,27 +3216,31 @@ def _do_blueprint_get(
|
||||
params: dict[str, Any],
|
||||
*,
|
||||
body: dict[str, Any] | None = None,
|
||||
text: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Return one installed blueprint's full body as ``{metadata, config}``.
|
||||
"""Return one installed blueprint as ``{metadata, config, yaml}``.
|
||||
|
||||
core's ``blueprint/list`` returns only ``{metadata}`` (no triggers /
|
||||
conditions / actions / sequence), so the server can otherwise serve metadata
|
||||
only. This reads the on-disk blueprint file and returns the parsed body:
|
||||
``config`` is the full file (the server merges it additively over the
|
||||
``blueprint/list`` metadata) and ``metadata`` is its ``blueprint:`` section.
|
||||
When the file is missing, unparseable, or the requested path escapes the jail,
|
||||
both come back ``None`` (the server keeps metadata-only) — see
|
||||
:func:`_read_blueprint_file`.
|
||||
conditions / actions / sequence, and never the file text), so the server can
|
||||
otherwise serve metadata only. This reads the on-disk blueprint file once and
|
||||
returns both views of it: ``config`` is the parsed file (the server merges it
|
||||
additively over the ``blueprint/list`` metadata), ``metadata`` is its
|
||||
``blueprint:`` section, and ``yaml`` is the raw text the server hands back for
|
||||
a round trip through ``blueprint/save``. Each is ``None`` when it could not be
|
||||
produced — a file that reads but does not parse still yields its ``yaml`` —
|
||||
and all three are ``None`` when the file is missing or the requested path
|
||||
escapes the jail (see :func:`_read_blueprint_file`).
|
||||
|
||||
Pure: the blocking jail-resolve + file read + YAML parse run in the executor
|
||||
via :func:`_blueprint_get_prep`, which passes the parsed ``body`` in.
|
||||
via :func:`_blueprint_get_prep`, which passes both views in.
|
||||
"""
|
||||
if not isinstance(body, dict):
|
||||
return {"metadata": None, "config": None}
|
||||
return {"metadata": None, "config": None, "yaml": text}
|
||||
metadata = body.get("blueprint")
|
||||
return {
|
||||
"metadata": _plainify(metadata) if isinstance(metadata, dict) else None,
|
||||
"config": _plainify(body),
|
||||
"yaml": text,
|
||||
}
|
||||
|
||||
|
||||
@@ -3237,27 +3252,41 @@ async def _blueprint_get_prep(
|
||||
The path jail (symlink-safe ``Path.resolve`` containment), the ``open()`` and
|
||||
the YAML parse are all blocking filesystem work, so they run in the executor
|
||||
via :meth:`hass.async_add_executor_job` — keeping :func:`_do_blueprint_get` a
|
||||
pure assembler over the parsed ``body`` this returns (``None`` on any failure).
|
||||
pure assembler over the raw text and parsed body this returns (both ``None``
|
||||
on a failed read).
|
||||
"""
|
||||
domain = msg["domain"]
|
||||
path = msg["path"]
|
||||
body = await hass.async_add_executor_job(_read_blueprint_file, hass, domain, path)
|
||||
return {"body": body}
|
||||
read = await hass.async_add_executor_job(_read_blueprint_file, hass, domain, path)
|
||||
return {"body": read.body, "text": read.text}
|
||||
|
||||
|
||||
def _read_blueprint_file(
|
||||
hass: HomeAssistant, domain: str, path: str
|
||||
) -> dict[str, Any] | None:
|
||||
"""Resolve + jail + read + parse one blueprint YAML file. ``None`` on failure.
|
||||
class _BlueprintFile(NamedTuple):
|
||||
"""One blueprint file read once: its raw ``text`` and its parsed ``body``.
|
||||
|
||||
``text`` is ``None`` when the file could not be read at all; ``body`` is
|
||||
additionally ``None`` when it read but did not parse into a mapping.
|
||||
"""
|
||||
|
||||
text: str | None
|
||||
body: dict[str, Any] | None
|
||||
|
||||
|
||||
_UNREADABLE_BLUEPRINT = _BlueprintFile(None, None)
|
||||
|
||||
|
||||
def _read_blueprint_file(hass: HomeAssistant, domain: str, path: str) -> _BlueprintFile:
|
||||
"""Resolve + jail + read + parse one blueprint YAML file, reading it once.
|
||||
|
||||
Blueprint files live under ``<config>/blueprints/<domain>/``. The requested
|
||||
``path`` is joined under that root and resolved symlink-safe (mirrors the
|
||||
file-tool jail's ``_resolves_within`` — resolve the RAW input, following
|
||||
symlinks, THEN check containment, so ``<root>/<symlink>/..`` cannot escape). A
|
||||
path escaping the root — via ``..``, an absolute path, or a symlink — yields
|
||||
``None`` (rejected, never opened). A missing file, a non-file target, a read
|
||||
error, or a YAML parse error also yields ``None``. Only a valid, contained,
|
||||
parseable blueprint returns its full parsed body.
|
||||
an empty result (rejected, never opened), as does a missing file, a non-file
|
||||
target, or a read error. A file that reads but does not parse into a mapping
|
||||
keeps its ``text`` and drops its ``body``, so the caller can still round-trip
|
||||
the exact bytes it holds.
|
||||
|
||||
Parsed with :class:`_BlueprintLoader`: ``!input`` markers are preserved and
|
||||
every other custom tag (``!secret`` / ``!include`` / …) is neutralized to
|
||||
@@ -3267,29 +3296,32 @@ def _read_blueprint_file(
|
||||
config = getattr(hass, "config", None)
|
||||
path_fn = getattr(config, "path", None)
|
||||
if not callable(path_fn):
|
||||
return None
|
||||
return _UNREADABLE_BLUEPRINT
|
||||
try:
|
||||
base = Path(path_fn("blueprints", domain))
|
||||
candidate = Path(path) if path.startswith("/") else base / path
|
||||
real = candidate.resolve()
|
||||
base_real = base.resolve()
|
||||
except (OSError, ValueError):
|
||||
return None
|
||||
return _UNREADABLE_BLUEPRINT
|
||||
if not (real == base_real or real.is_relative_to(base_real)):
|
||||
return None
|
||||
return _UNREADABLE_BLUEPRINT
|
||||
try:
|
||||
with open(real, encoding="utf-8") as handle:
|
||||
# Instance form (not yaml.load) mirrors the component's existing
|
||||
# _PackagesDirLoader usage; _BlueprintLoader is a SafeLoader subclass,
|
||||
# so no !!python/object can construct arbitrary types.
|
||||
loader = _BlueprintLoader(handle)
|
||||
try:
|
||||
parsed = loader.get_single_data()
|
||||
finally:
|
||||
loader.dispose()
|
||||
except (OSError, ValueError, yaml.YAMLError):
|
||||
return None
|
||||
return parsed if isinstance(parsed, dict) else None
|
||||
text = real.read_text(encoding="utf-8")
|
||||
except (OSError, ValueError):
|
||||
return _UNREADABLE_BLUEPRINT
|
||||
try:
|
||||
# Instance form (not yaml.load) mirrors the component's existing
|
||||
# _PackagesDirLoader usage; _BlueprintLoader is a SafeLoader subclass,
|
||||
# so no !!python/object can construct arbitrary types.
|
||||
loader = _BlueprintLoader(text)
|
||||
try:
|
||||
parsed = loader.get_single_data()
|
||||
finally:
|
||||
loader.dispose()
|
||||
except (ValueError, yaml.YAMLError):
|
||||
return _BlueprintFile(text, None)
|
||||
return _BlueprintFile(text, parsed if isinstance(parsed, dict) else None)
|
||||
|
||||
|
||||
def _construct_blueprint_input(loader: Any, node: Any) -> dict[str, str]:
|
||||
@@ -6803,3 +6835,29 @@ def _dispatched_unconfirmed_bulk_result(
|
||||
"dispatched": sum(1 for r in op_results if r["dispatched"]),
|
||||
"failed": sum(1 for r in op_results if r.get("error") is not None),
|
||||
}
|
||||
|
||||
|
||||
def _dashboard_edit_schema() -> dict[Any, Any]:
|
||||
"""The additive edit command; cross-field validation precedes any save."""
|
||||
return {
|
||||
vol.Required("type"): WS_DASHBOARD_EDIT,
|
||||
vol.Optional("url_path"): vol.Any(str, None),
|
||||
vol.Optional("expected_hash"): vol.Any(str, None),
|
||||
vol.Optional("config"): dict,
|
||||
vol.Optional("patch"): list,
|
||||
}
|
||||
|
||||
|
||||
async def _dashboard_edit_prep(
|
||||
hass: HomeAssistant, msg: dict[str, Any]
|
||||
) -> dict[str, Any]:
|
||||
from .dashboard_edit import async_edit_dashboard
|
||||
|
||||
return {"result": await async_edit_dashboard(hass, msg)}
|
||||
|
||||
|
||||
def _do_dashboard_edit(
|
||||
hass: HomeAssistant, msg: dict[str, Any], *, result: dict[str, Any]
|
||||
) -> dict[str, Any]:
|
||||
"""Preserve the write outcome assembled by the async edit lifecycle."""
|
||||
return result
|
||||
Reference in new issue
Block a user